Update Vite configuration to deduplicate React and React-DOM; enhance DeviceContext with syncPeqCatalog method for improved PEQ state management; refactor LuxsinAPI to streamline PEQ filter updates; replace range input with ThumbOnlyRangeSlider in Home component for better user experience.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRef, type CSSProperties } from "react";
|
||||
|
||||
/** Matches .cyan-slider thumb (~17.6px); generous hit area for touch. */
|
||||
const THUMB_HIT_RADIUS_PX = 24;
|
||||
|
||||
function valueFromClientX(input: HTMLInputElement, clientX: number): number {
|
||||
const rect = input.getBoundingClientRect();
|
||||
const min = Number(input.min);
|
||||
const max = Number(input.max);
|
||||
if (rect.width <= 0) return Number(input.value);
|
||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
return Math.round(min + ratio * (max - min));
|
||||
}
|
||||
|
||||
function isPointerOnThumb(input: HTMLInputElement, clientX: number): boolean {
|
||||
const rect = input.getBoundingClientRect();
|
||||
const min = Number(input.min);
|
||||
const max = Number(input.max);
|
||||
const value = Number(input.value);
|
||||
const span = max - min;
|
||||
const ratio = span > 0 ? (value - min) / span : 0;
|
||||
const thumbCenterX = rect.left + ratio * rect.width;
|
||||
return Math.abs(clientX - thumbCenterX) <= THUMB_HIT_RADIUS_PX;
|
||||
}
|
||||
|
||||
const RELEASE_KEYS = [
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
] as const;
|
||||
|
||||
export interface ThumbOnlyRangeSliderProps {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
onChange: (value: number) => void;
|
||||
/** Fires when the user releases the thumb after a drag or keyboard adjust. */
|
||||
onRelease?: (value: number) => void;
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range slider that ignores track clicks/taps — only the thumb can be dragged.
|
||||
* Prevents accidental jumps to high volume when missing the thumb.
|
||||
*/
|
||||
export default function ThumbOnlyRangeSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
disabled = false,
|
||||
className,
|
||||
style,
|
||||
onChange,
|
||||
onRelease,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
}: ThumbOnlyRangeSliderProps) {
|
||||
const draggingRef = useRef(false);
|
||||
|
||||
const emitRelease = (el: EventTarget | null) => {
|
||||
if (disabled) return;
|
||||
if (!(el instanceof HTMLInputElement)) return;
|
||||
onRelease?.(Number(el.value));
|
||||
onDragEnd?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn("cyan-slider relative z-10", className)}
|
||||
style={{ touchAction: "none", ...style }}
|
||||
onChange={(e) => {
|
||||
if (disabled || draggingRef.current) return;
|
||||
onChange(Number(e.target.value));
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return;
|
||||
const input = e.currentTarget;
|
||||
if (!isPointerOnThumb(input, e.clientX)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
onDragStart?.();
|
||||
input.setPointerCapture(e.pointerId);
|
||||
onChange(valueFromClientX(input, e.clientX));
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (disabled || !draggingRef.current) return;
|
||||
onChange(valueFromClientX(e.currentTarget, e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onLostPointerCapture={(e) => {
|
||||
if (!draggingRef.current) return;
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false;
|
||||
emitRelease(e.currentTarget);
|
||||
return;
|
||||
}
|
||||
if (!disabled) {
|
||||
onRelease?.(Number(e.currentTarget.value));
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (disabled) return;
|
||||
if (RELEASE_KEYS.includes(e.key as (typeof RELEASE_KEYS)[number])) {
|
||||
emitRelease(e.currentTarget);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,8 @@ interface DeviceContextType {
|
||||
updatePeq: (filters: PeqFilter[]) => Promise<void>;
|
||||
upgradePeqChange: (payload: PeqChangePayload) => Promise<void>;
|
||||
upgradePeqApply: (payload: PeqApplyPayload) => Promise<void>;
|
||||
/** 与设备 syncPeq 结果对齐全局 peqState,避免 EQ 页本地列表与轮询缓存不一致 */
|
||||
syncPeqCatalog: (state: PeqState) => void;
|
||||
// Optimistic state updaters
|
||||
setVolume: (v: number) => void;
|
||||
setInput: (v: number) => void;
|
||||
@@ -72,6 +74,10 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
setPeqState(peq);
|
||||
}, []);
|
||||
|
||||
const syncPeqCatalog = useCallback((state: PeqState) => {
|
||||
setPeqState(state);
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (forceDemo?: boolean, connectIp?: string) => {
|
||||
const useDemo = forceDemo !== undefined ? forceDemo : isDemoMode;
|
||||
const targetIp = (connectIp ?? ip).trim();
|
||||
@@ -176,6 +182,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const upgradePeqChange = useCallback(async (payload: PeqChangePayload) => {
|
||||
console.log("[peqChange]", payload.peqChange);
|
||||
if (isDemoMode) {
|
||||
applyPeqFiltersToState(payload.peqChange.filters);
|
||||
return;
|
||||
@@ -186,6 +193,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [api, isDemoMode, applyPeqFiltersToState]);
|
||||
|
||||
const upgradePeqApply = useCallback(async (payload: PeqApplyPayload) => {
|
||||
console.log("[peqApply]", payload.peqApply);
|
||||
if (isDemoMode) {
|
||||
applyPeqFiltersToState(payload.peqApply.filters);
|
||||
return;
|
||||
@@ -263,7 +271,7 @@ export function DeviceProvider({ children }: { children: React.ReactNode }) {
|
||||
isConnected, isConnecting, isDemoMode, setDemoMode,
|
||||
deviceState, peqState, lastUpdated, error,
|
||||
connect, disconnect, refresh, api,
|
||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply,
|
||||
updateSetting, updatePeq, upgradePeqChange, upgradePeqApply, syncPeqCatalog,
|
||||
setVolume, setInput, setOutput, setBalance,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
+11
-14
@@ -248,13 +248,7 @@ export class LuxsinAPI {
|
||||
}
|
||||
|
||||
async setPeqFilters(filters: PeqFilter[]): Promise<void> {
|
||||
const payload = JSON.stringify({ peqChange: { filters } });
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
});
|
||||
await this.postPeqJson({ peqChange: { filters } } as PeqChangePayload);
|
||||
}
|
||||
|
||||
/** Full peq preset: name, filters, autoPre, preamp, canDel — same encoding as legacy `upgradePeq`. */
|
||||
@@ -267,24 +261,27 @@ export class LuxsinAPI {
|
||||
await this.postPeqJson(body);
|
||||
}
|
||||
|
||||
/** POST `json=<custom-base64>` — matches legacy axios `upgradePeq`. */
|
||||
private async postPeqJson(body: PeqChangePayload | PeqApplyPayload): Promise<void> {
|
||||
const payload = JSON.stringify(body);
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
const encoded = encodeCustomBase64(JSON.stringify(body));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: form.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove one or more headphone PEQ profiles. */
|
||||
async removePeq(names: string[]): Promise<void> {
|
||||
const payload = JSON.stringify({ peqRemove: names });
|
||||
const encoded = encodeCustomBase64(payload);
|
||||
const encoded = encodeCustomBase64(JSON.stringify({ peqRemove: names }));
|
||||
const form = new URLSearchParams();
|
||||
form.set("json", encoded);
|
||||
await fetch(`${this.baseUrl}/dev/info.cgi`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
||||
body: `json=${encodeURIComponent(encoded)}`,
|
||||
body: form.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+184
-671
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
||||
import { useDevice } from "@/contexts/DeviceContext";
|
||||
import ConnectionPlaceholder from "@/components/ConnectionPlaceholder";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import ThumbOnlyRangeSlider from "@/components/ThumbOnlyRangeSlider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -646,54 +647,25 @@ export default function Home() {
|
||||
boxShadow: volumeControlsLocked ? "none" : "0 0 8px rgba(0,255,246,0.5)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
<ThumbOnlyRangeSlider
|
||||
min={0}
|
||||
max={200}
|
||||
value={localVol}
|
||||
disabled={volumeControlsLocked}
|
||||
className="cyan-slider relative z-10"
|
||||
onChange={(e) => {
|
||||
onChange={(v) => {
|
||||
if (volumeControlsLocked) return;
|
||||
const v = Number(e.target.value);
|
||||
setLocalVol(v);
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
onDragStart={() => {
|
||||
if (volumeControlsLocked) return;
|
||||
isDragging.current = true;
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
onDragEnd={() => {
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) return;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
isDragging.current = false;
|
||||
onRelease={(v) => {
|
||||
if (volumeControlsLocked) return;
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
isDragging.current = false;
|
||||
if (volumeControlsLocked) {
|
||||
return;
|
||||
}
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (volumeControlsLocked) return;
|
||||
const k = e.key;
|
||||
if (
|
||||
k === "ArrowLeft" ||
|
||||
k === "ArrowRight" ||
|
||||
k === "ArrowUp" ||
|
||||
k === "ArrowDown" ||
|
||||
k === "Home" ||
|
||||
k === "End" ||
|
||||
k === "PageUp" ||
|
||||
k === "PageDown"
|
||||
) {
|
||||
setVolume(Number(e.currentTarget.value));
|
||||
}
|
||||
setVolume(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export function IOSToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="ios-toggle" onClick={(e) => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span className="ios-toggle-track">
|
||||
<span className="ios-toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function CyanSlider({
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (v: number) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const fillPct = Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
|
||||
return (
|
||||
<div className={`relative flex items-center w-full mt-2 ${disabled ? "opacity-60" : ""}`}>
|
||||
<div
|
||||
className="absolute left-0 h-[4px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
width: `${fillPct}%`,
|
||||
background: disabled ? "rgba(148,163,184,0.7)" : "#00FFF6",
|
||||
boxShadow: disabled ? "none" : "0 0 6px rgba(0,255,246,0.45)",
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
className="cyan-slider relative z-10"
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
|
||||
import { buildPeqSvgCurveData, sampleCombinedPeqMagnitudeDb } from "@/lib/peqAudio";
|
||||
import { eqInterp } from "../eqFormatters";
|
||||
import type { EqBand, PeqEqUi } from "../types";
|
||||
|
||||
export type FreqChartProps = {
|
||||
bands: EqBand[];
|
||||
rawCurve: number[] | null;
|
||||
selectedBand: number;
|
||||
abMode: "A" | "B";
|
||||
onAbToggle: (m: "A" | "B") => void;
|
||||
onCopyAndSwitchTo: (to: "A" | "B") => void;
|
||||
onApplyB: () => void;
|
||||
onSaveB: () => void;
|
||||
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
|
||||
onBandSelect: (idx: number) => void;
|
||||
eqUi: PeqEqUi;
|
||||
};
|
||||
|
||||
export function FreqChart({
|
||||
bands,
|
||||
rawCurve,
|
||||
selectedBand,
|
||||
abMode,
|
||||
onAbToggle,
|
||||
onCopyAndSwitchTo,
|
||||
onApplyB,
|
||||
onSaveB,
|
||||
onBandDrag,
|
||||
onBandSelect,
|
||||
eqUi,
|
||||
}: FreqChartProps) {
|
||||
/** 频响图 SVG 高度(viewBox 与 CSS 一致) */
|
||||
const H = 400;
|
||||
const chartContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const draggingBandRef = useRef<number | null>(null);
|
||||
const curvePathRef = useRef<SVGPathElement | null>(null);
|
||||
const rawPathRef = useRef<SVGPathElement | null>(null);
|
||||
const equalizedPathRef = useRef<SVGPathElement | null>(null);
|
||||
const [chartWidth, setChartWidth] = useState(340);
|
||||
const W = chartWidth;
|
||||
const [isBandDragging, setIsBandDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = chartContainerRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = Math.round(node.clientWidth);
|
||||
if (nextWidth > 0) setChartWidth(nextWidth);
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const Y_DB_MAX = 20;
|
||||
|
||||
const freqToX = (f: number) => {
|
||||
const logMin = Math.log10(20),
|
||||
logMax = Math.log10(20000);
|
||||
return ((Math.log10(Math.max(20, Math.min(20000, f))) - logMin) / (logMax - logMin)) * W;
|
||||
};
|
||||
const gainToY = (g: number) => H / 2 - (g / Y_DB_MAX) * (H / 2 - 10);
|
||||
const xToFreq = (x: number) => {
|
||||
const logMin = Math.log10(20),
|
||||
logMax = Math.log10(20000);
|
||||
const clampedX = Math.max(0, Math.min(W, x));
|
||||
return Math.pow(10, logMin + (clampedX / W) * (logMax - logMin));
|
||||
};
|
||||
const yToGain = (y: number) => {
|
||||
const clampedY = Math.max(10, Math.min(H - 10, y));
|
||||
return ((H / 2 - clampedY) / (H / 2 - 10)) * Y_DB_MAX;
|
||||
};
|
||||
|
||||
const updateBandFromPointer = (idx: number, clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg || W <= 0) return;
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
|
||||
const x = ((clientX - rect.left) / rect.width) * W;
|
||||
const y = ((clientY - rect.top) / rect.height) * H;
|
||||
const freq = Math.round(Math.max(20, Math.min(20000, xToFreq(x))));
|
||||
const gain = Number(Math.max(-Y_DB_MAX, Math.min(Y_DB_MAX, yToGain(y))).toFixed(1));
|
||||
onBandDrag(idx, { freq, gain });
|
||||
};
|
||||
|
||||
const handleBandPointerDown = (idx: number, e: React.PointerEvent<SVGGElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
draggingBandRef.current = idx;
|
||||
setIsBandDragging(true);
|
||||
onBandSelect(idx);
|
||||
updateBandFromPointer(idx, e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleSvgPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
const idx = draggingBandRef.current;
|
||||
if (idx === null) return;
|
||||
e.preventDefault();
|
||||
updateBandFromPointer(idx, e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
draggingBandRef.current = null;
|
||||
setIsBandDragging(false);
|
||||
};
|
||||
|
||||
const curveData = useMemo(
|
||||
() =>
|
||||
buildPeqSvgCurveData({
|
||||
bands,
|
||||
width: W,
|
||||
height: H,
|
||||
fs: 48000,
|
||||
yDbMax: Y_DB_MAX,
|
||||
minFreq: 20,
|
||||
maxFreq: 20000,
|
||||
paddingY: 10,
|
||||
}),
|
||||
[bands, W, H],
|
||||
);
|
||||
const pathD = curveData.pathD;
|
||||
const fillD = curveData.fillD;
|
||||
|
||||
/** 各带中心频率处级联响应 dB,与黄线同源 — 手柄纵坐标须用此值才能落在曲线上 */
|
||||
const combinedMagDbAtHandles = useMemo(
|
||||
() => bands.map((b) => sampleCombinedPeqMagnitudeDb(bands, b.freq, 48000)),
|
||||
[bands],
|
||||
);
|
||||
|
||||
const [curveVisibilityByMode, setCurveVisibilityByMode] = useState<
|
||||
Record<"A" | "B", { eq: boolean; raw: boolean; equalized: boolean }>
|
||||
>({
|
||||
A: { eq: true, raw: true, equalized: true },
|
||||
B: { eq: true, raw: false, equalized: false },
|
||||
});
|
||||
const showEq = curveVisibilityByMode[abMode].eq;
|
||||
const showRaw = curveVisibilityByMode[abMode].raw;
|
||||
const showEqualized = curveVisibilityByMode[abMode].equalized;
|
||||
|
||||
const toggleCurveVisibility = (key: "eq" | "raw" | "equalized") => {
|
||||
setCurveVisibilityByMode((prev) => ({
|
||||
...prev,
|
||||
[abMode]: {
|
||||
...prev[abMode],
|
||||
[key]: !prev[abMode][key],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const rawPathD = useMemo(() => {
|
||||
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
|
||||
return curveData.points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${gainToY(rawCurve[i] ?? 0).toFixed(1)}`)
|
||||
.join(" ");
|
||||
}, [rawCurve, curveData.points]);
|
||||
const equalizedPathD = useMemo(() => {
|
||||
if (!Array.isArray(rawCurve) || rawCurve.length !== curveData.points.length) return "";
|
||||
return curveData.points
|
||||
.map((p, i) => {
|
||||
const y = gainToY((p.gainDb ?? 0) + (rawCurve[i] ?? 0));
|
||||
return `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
}, [rawCurve, curveData.points]);
|
||||
const hasRawCurve = !!rawPathD;
|
||||
const hasEqualizedCurve = !!equalizedPathD;
|
||||
|
||||
// Intro draw once; EQ param edits update path in place without replaying the stroke animation.
|
||||
const strokeAnimOpts = { durationMs: 1350, replayOnPathChange: false as const };
|
||||
useStrokeDrawAnimation(curvePathRef, pathD, {
|
||||
...strokeAnimOpts,
|
||||
enabled: !isBandDragging,
|
||||
});
|
||||
useStrokeDrawAnimation(rawPathRef, rawPathD, {
|
||||
...strokeAnimOpts,
|
||||
enabled: !isBandDragging && !!rawPathD,
|
||||
});
|
||||
useStrokeDrawAnimation(equalizedPathRef, equalizedPathD, {
|
||||
...strokeAnimOpts,
|
||||
enabled: !isBandDragging && !!equalizedPathD,
|
||||
});
|
||||
|
||||
const targetMode: "A" | "B" = abMode === "A" ? "B" : "A";
|
||||
const copyButtonText = eqInterp(eqUi.chartCopyTo, { mode: targetMode });
|
||||
|
||||
const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000];
|
||||
const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];
|
||||
|
||||
return (
|
||||
<div className="ios-list-group p-3 mb-3">
|
||||
{/* Legend row */}
|
||||
<div className="flex items-center gap-3 mb-2 px-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 active:opacity-80 transition-opacity"
|
||||
onClick={() => toggleCurveVisibility("eq")}
|
||||
>
|
||||
<div className="w-3 h-[2px] rounded" style={{ background: "#FFED00" }} />
|
||||
<span className="text-[10px]" style={{ color: "#FFED00", opacity: showEq ? 1 : 0.35 }}>
|
||||
Equalizer
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => toggleCurveVisibility("raw")}
|
||||
disabled={!hasRawCurve}
|
||||
>
|
||||
<div className="w-3 h-[2px] rounded" style={{ background: "#ffffff" }} />
|
||||
<span className="text-[10px]" style={{ color: "#ffffff", opacity: showRaw ? 1 : 0.35 }}>
|
||||
Raw
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 active:opacity-80 transition-opacity disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => toggleCurveVisibility("equalized")}
|
||||
disabled={!hasEqualizedCurve}
|
||||
>
|
||||
<div className="w-3 h-[2px] rounded" style={{ background: "#23d2fe" }} />
|
||||
<span className="text-[10px]" style={{ color: "#23d2fe", opacity: showEqualized ? 1 : 0.35 }}>
|
||||
Equalized
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* A/B + DIFF controls */}
|
||||
<div className="flex w-full items-center gap-2 mb-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* A/B toggle pill */}
|
||||
<div
|
||||
className="flex items-center rounded-[8px] overflow-hidden"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
>
|
||||
{(["A", "B"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className="px-3 py-1 text-[12px] font-semibold transition-all duration-150"
|
||||
style={
|
||||
abMode === m
|
||||
? {
|
||||
background: "#00FFF6",
|
||||
color: "#000",
|
||||
borderRadius: 6,
|
||||
}
|
||||
: { color: "rgba(255,255,255,0.4)" }
|
||||
}
|
||||
onClick={() => onAbToggle(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => {
|
||||
onCopyAndSwitchTo(targetMode);
|
||||
setCurveVisibilityByMode((prev) => ({
|
||||
...prev,
|
||||
[targetMode]: { ...prev[abMode] },
|
||||
}));
|
||||
toast.success(eqInterp(eqUi.toastChartCopied, { mode: targetMode }));
|
||||
}}
|
||||
>
|
||||
{copyButtonText}
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => onApplyB()}
|
||||
>
|
||||
{eqUi.chartApplyB}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 rounded-[8px] text-[11px] text-white/50 active:text-white transition-colors"
|
||||
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
|
||||
onClick={() => onSaveB()}
|
||||
>
|
||||
{eqUi.chartSaveB}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG chart */}
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="rounded-[10px] overflow-hidden"
|
||||
style={{ background: "rgba(10,12,10,0.7)" }}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ height: H, touchAction: "none" }}
|
||||
onPointerMove={handleSvgPointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
onPointerLeave={stopDragging}
|
||||
>
|
||||
{/* dB grid */}
|
||||
{gainLabels.map((g) => (
|
||||
<g key={g}>
|
||||
<line x1="0" y1={gainToY(g)} x2={W} y2={gainToY(g)} stroke="rgba(255,255,255,0.05)" strokeWidth="1" />
|
||||
<text x="3" y={gainToY(g) - 2} fontSize="7" fill="rgba(255,255,255,0.2)">
|
||||
{g > 0 ? `+${g}` : g}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Freq grid */}
|
||||
{freqLabels.map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={freqToX(f)}
|
||||
y1="0"
|
||||
x2={freqToX(f)}
|
||||
y2={H - 12}
|
||||
stroke="rgba(255,255,255,0.05)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* Zero line */}
|
||||
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="rgba(255,255,255,0.15)" strokeWidth="1" />
|
||||
{/* Fill */}
|
||||
{showEq && <path d={fillD} fill="rgba(255, 237, 0, 0.08)" />}
|
||||
{/* EQ curve */}
|
||||
{showEq && (
|
||||
<path
|
||||
ref={curvePathRef}
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="#FFED00"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
{/* Raw */}
|
||||
{rawPathD && showRaw && (
|
||||
<path
|
||||
ref={rawPathRef}
|
||||
d={rawPathD}
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
)}
|
||||
{/* Equalized = EQ + Raw */}
|
||||
{equalizedPathD && showEqualized && (
|
||||
<path
|
||||
ref={equalizedPathRef}
|
||||
d={equalizedPathD}
|
||||
fill="none"
|
||||
stroke="#23d2fe"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
opacity="0.95"
|
||||
/>
|
||||
)}
|
||||
{/* Band nodes with index */}
|
||||
{showEq &&
|
||||
bands.map((band, i) => (
|
||||
<g key={i} style={{ cursor: "grab" }} onPointerDown={(e) => handleBandPointerDown(i, e)}>
|
||||
{(() => {
|
||||
const isSelected = i === selectedBand;
|
||||
const outerR = isSelected ? 8.8 : 7;
|
||||
const fillColor = isSelected ? "#FFED00" : "rgba(0,0,0,0.5)";
|
||||
const strokeColor = isSelected ? "#FFED00" : "#FFED00";
|
||||
const textColor = isSelected ? "#000000" : "#FFED00";
|
||||
const handleY = gainToY(combinedMagDbAtHandles[i] ?? 0);
|
||||
return (
|
||||
<>
|
||||
<circle cx={freqToX(band.freq)} cy={handleY} r="14" fill="transparent" />
|
||||
<circle
|
||||
cx={freqToX(band.freq)}
|
||||
cy={handleY}
|
||||
r={outerR}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={isSelected ? 1.8 : 1.5}
|
||||
/>
|
||||
<text
|
||||
x={freqToX(band.freq)}
|
||||
y={handleY + 3.5}
|
||||
textAnchor="middle"
|
||||
fontSize="7"
|
||||
fill={textColor}
|
||||
fontWeight="bold"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{i + 1}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</g>
|
||||
))}
|
||||
{/* Freq axis labels */}
|
||||
{freqLabels.map((f) => (
|
||||
<text key={f} x={freqToX(f)} y={H - 1} textAnchor="middle" fontSize="7" fill="rgba(255,255,255,0.25)">
|
||||
{f >= 1000 ? `${f / 1000}k` : f}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { PeqFilter } from "@/lib/luxsinApi";
|
||||
import type { CatalogTarget, EqBand } from "./types";
|
||||
|
||||
export const FILTER_TYPES = ["LPF", "HPF", "BPF", "NOTCH", "PEAK", "LSHELF", "HSHELF", "APF"];
|
||||
|
||||
export const BAND_FREQ_MIN = 20;
|
||||
export const BAND_FREQ_MAX = 20000;
|
||||
export const BAND_GAIN_MIN = -15;
|
||||
export const BAND_GAIN_MAX = 15;
|
||||
export const BAND_Q_MIN = 0.1;
|
||||
export const BAND_Q_MAX = 10;
|
||||
|
||||
export const BAND_PARAM_VALUE_BOX_STYLE: CSSProperties = {
|
||||
background: "rgba(44,44,46,0.9)",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
};
|
||||
|
||||
/** Default bands matching reference image */
|
||||
export const DEFAULT_BANDS: EqBand[] = [
|
||||
{ freq: 9500, gain: 0, q: 1.41, type: "LSHELF", enabled: true },
|
||||
{ freq: 9200, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 220, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 500, gain: -3, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 1200, gain: 0, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 13800, gain: -1, q: 1.41, type: "NOTCH", enabled: true },
|
||||
{ freq: 11000, gain: -2, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 7400, gain: 1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 8200, gain: -1, q: 1.41, type: "PEAK", enabled: true },
|
||||
{ freq: 10000, gain: 0, q: 1.41, type: "HSHELF", enabled: true },
|
||||
];
|
||||
|
||||
export const FLAT_PRESET_FILTERS: PeqFilter[] = [
|
||||
{ type: 4, fc: 80, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 150, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 350, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 750, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 1500, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 3000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 6000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 10000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 14000, gain: 0, q: 0.1 },
|
||||
{ type: 4, fc: 18000, gain: 0, q: 0.1 },
|
||||
];
|
||||
|
||||
export const CATALOG_TARGETS: CatalogTarget[] = [
|
||||
{ name: "Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "HMS II.3 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "crinacle EARS + 711 Harman over-ear 2018", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
|
||||
{ name: "HMS II.3 AutoEq in-ear", bassBoost: { fc: 105, q: 0.7, gain: 8 }, ear: "in" },
|
||||
{ name: "HMS II.3 Harman in-ear 2019", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "Diffuse Field 5128 (-1 dB/oct)", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "over" },
|
||||
{ name: "LMG 5128 0.6 without bass", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "JM-1 with Harman filters", bassBoost: { fc: 105, q: 0.7, gain: 6.5 }, ear: "all" },
|
||||
{ name: "oratory1990 in-ear", bassBoost: { fc: 105, q: 0.7, gain: 9.5 }, ear: "in" },
|
||||
{ name: "oratory1990 over-ear", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Harman over-ear 2013", bassBoost: { fc: 105, q: 0.7, gain: 6 }, ear: "over" },
|
||||
{ name: "Flat", bassBoost: { fc: 105, q: 0.7, gain: 0 }, ear: "all" },
|
||||
];
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getFilterShortName } from "@/lib/peqAudio";
|
||||
import {
|
||||
BAND_FREQ_MAX,
|
||||
BAND_FREQ_MIN,
|
||||
BAND_GAIN_MAX,
|
||||
BAND_GAIN_MIN,
|
||||
BAND_Q_MAX,
|
||||
BAND_Q_MIN,
|
||||
} from "./eqConstants";
|
||||
import type { BandParamKind } from "./types";
|
||||
|
||||
export function eqInterp(template: string | undefined, vars: Record<string, string | number>): string {
|
||||
if (!template) return "";
|
||||
let s = template;
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
s = s.split(`{{${k}}}`).join(String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function formatBandFreqDisplay(freq: number) {
|
||||
return freq >= 1000
|
||||
? `${(freq / 1000).toFixed(2).replace(/\.?0+$/, "")} kHz`
|
||||
: `${freq} Hz`;
|
||||
}
|
||||
|
||||
export function formatBandParamForInput(
|
||||
kind: BandParamKind,
|
||||
band: { freq: number; gain: number; q: number },
|
||||
) {
|
||||
switch (kind) {
|
||||
case "freq":
|
||||
return String(band.freq);
|
||||
case "gain":
|
||||
return band.gain.toFixed(1);
|
||||
case "q":
|
||||
return band.q.toFixed(2);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBandParamInput(
|
||||
kind: BandParamKind,
|
||||
raw: string,
|
||||
messages: { invalid: string; outOfRange: string },
|
||||
): { ok: true; value: number } | { ok: false; message: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { ok: false, message: messages.invalid };
|
||||
|
||||
if (kind === "freq") {
|
||||
let s = trimmed.replace(/\s+/g, "").toLowerCase().replace(/hz$/, "");
|
||||
const kHz = /k(hz)?$/.test(s);
|
||||
if (kHz) s = s.replace(/k(hz)?$/, "");
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const hz = Math.round(kHz ? num * 1000 : num);
|
||||
if (hz < BAND_FREQ_MIN || hz > BAND_FREQ_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: hz };
|
||||
}
|
||||
|
||||
if (kind === "gain") {
|
||||
const s = trimmed.replace(/\s*dB\s*$/i, "").trim();
|
||||
const num = Number.parseFloat(s);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const gain = Number(num.toFixed(1));
|
||||
if (gain < BAND_GAIN_MIN || gain > BAND_GAIN_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: gain };
|
||||
}
|
||||
|
||||
const num = Number.parseFloat(trimmed);
|
||||
if (!Number.isFinite(num)) return { ok: false, message: messages.invalid };
|
||||
const q = Number(num.toFixed(2));
|
||||
if (q < BAND_Q_MIN || q > BAND_Q_MAX) {
|
||||
return { ok: false, message: messages.outOfRange };
|
||||
}
|
||||
return { ok: true, value: q };
|
||||
}
|
||||
|
||||
export function normalizeFilterType(type: string | number | undefined): string {
|
||||
if (type === undefined || type === null) return "PEAK";
|
||||
if (typeof type === "number") {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return "LPF";
|
||||
case 1:
|
||||
return "HPF";
|
||||
case 2:
|
||||
return "BPF";
|
||||
case 3:
|
||||
return "NOTCH";
|
||||
case 4:
|
||||
return "PEAK";
|
||||
case 5:
|
||||
return "LSHELF";
|
||||
case 6:
|
||||
return "HSHELF";
|
||||
case 7:
|
||||
return "APF";
|
||||
default:
|
||||
return "PEAK";
|
||||
}
|
||||
}
|
||||
return getFilterShortName(type);
|
||||
}
|
||||
|
||||
export function freqLabel(f: number) {
|
||||
return f >= 1000 ? `${(f / 1000).toFixed(1)}K` : `${f}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getFilterType } from "@/lib/peqAudio";
|
||||
import type { PeqFilter, PeqState } from "@/lib/luxsinApi";
|
||||
import type { EqBand } from "./types";
|
||||
|
||||
export function cloneBands(source: EqBand[]) {
|
||||
return source.map((band) => ({ ...band }));
|
||||
}
|
||||
|
||||
export function buildPeqCatalogSyncKey(
|
||||
remote: Pick<PeqState, "peq" | "peqSelect"> | null | undefined,
|
||||
devicePeqSelect?: number,
|
||||
): string {
|
||||
const items = remote?.peq;
|
||||
if (!items?.length) return items ? "empty" : "";
|
||||
const select = devicePeqSelect ?? remote?.peqSelect ?? 0;
|
||||
return `${select}|${items.map((p) => p.name).join("\u0001")}`;
|
||||
}
|
||||
|
||||
export function getUniquePresetName(base: string, existingNames: string[]) {
|
||||
if (!existingNames.includes(base)) return base;
|
||||
let index = 1;
|
||||
while (existingNames.includes(`${base}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${base}_${index}`;
|
||||
}
|
||||
|
||||
/** UI band row → device `PeqFilter` (fc + numeric type), same as legacy `getFilterVal` mapping via `getFilterType`. */
|
||||
export function bandToPeqFilter(b: {
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
type: string | number;
|
||||
}): PeqFilter {
|
||||
return {
|
||||
fc: b.freq,
|
||||
gain: b.gain,
|
||||
q: b.q,
|
||||
type: getFilterType(b.type),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PeqState } from "@/lib/luxsinApi";
|
||||
import type localeZh from "@/locales/data-zh.json";
|
||||
|
||||
export type PeqEqUi = NonNullable<NonNullable<(typeof localeZh)["peq"]>["eqUi"]>;
|
||||
|
||||
export type BandParamKind = "freq" | "gain" | "q";
|
||||
|
||||
export type EqBand = {
|
||||
freq: number;
|
||||
gain: number;
|
||||
q: number;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PeqCatalogItem = NonNullable<PeqState["peq"]>[number];
|
||||
|
||||
export type CatalogTarget = {
|
||||
name: string;
|
||||
bassBoost: { fc: number; q: number; gain: number };
|
||||
ear: "in" | "over" | "all";
|
||||
};
|
||||
+4
-30
@@ -183,6 +183,7 @@ export default defineConfig({
|
||||
"@shared": path.resolve(import.meta.dirname, "shared"),
|
||||
"@assets": path.resolve(import.meta.dirname, "attached_assets"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
envDir: path.resolve(import.meta.dirname),
|
||||
root: path.resolve(import.meta.dirname, "client"),
|
||||
@@ -207,36 +208,9 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) return;
|
||||
|
||||
if (id.includes("node_modules/react/") || id.includes("node_modules/react-dom/")) {
|
||||
return "react-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/wouter/")) {
|
||||
return "router-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/echarts/")) return "echarts-vendor";
|
||||
if (id.includes("node_modules/recharts/")) return "recharts-vendor";
|
||||
if (id.includes("node_modules/framer-motion/")) return "motion-vendor";
|
||||
if (id.includes("node_modules/@radix-ui/")) return "radix-vendor";
|
||||
|
||||
// streamdown + shiki + mermaid (lazy-loaded with AIPage)
|
||||
if (
|
||||
id.includes("streamdown") ||
|
||||
id.includes("@shikijs/") ||
|
||||
id.includes("/shiki/") ||
|
||||
id.includes("mermaid")
|
||||
) {
|
||||
return "ai-markdown-vendor";
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("node_modules/lucide-react/") ||
|
||||
id.includes("node_modules/class-variance-authority/") ||
|
||||
id.includes("node_modules/clsx/") ||
|
||||
id.includes("node_modules/tailwind-merge/")
|
||||
) {
|
||||
return "ui-vendor";
|
||||
}
|
||||
// Only split echarts (lazy EQ page). Do not manually chunk react, radix, lucide,
|
||||
// or ai-markdown — forced splits hoist shared helpers and break React.forwardRef.
|
||||
if (id.includes("/echarts/")) return "echarts-vendor";
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user