Files
controller-v2/client/src/pages/eq/components/FreqChart.tsx
T

539 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useMemo } from "react";
import { toast } from "sonner";
import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation";
import {
buildPeqSvgCurveData,
sampleCombinedPeqMagnitudeDb,
} from "@/lib/peqAudio";
import type { PeqEqUi } from "../constants";
import { eqInterp } from "../utils";
import { PeqUndoButton } from "./PeqUndoButton";
/* ── Y 轴刻度范围(±dB)可选项与持久化 ── */
const Y_RANGE_OPTIONS = [6, 12, 15, 20] as const;
type YRangeDb = (typeof Y_RANGE_OPTIONS)[number];
const DEFAULT_Y_RANGE_DB: YRangeDb = 12;
const Y_RANGE_STORAGE_KEY = "x9-peq-chart-y-db-max";
/** 每档范围的网格整数步长(均可整除边界,确保标签为整数) */
const Y_RANGE_STEP: Record<YRangeDb, number> = { 6: 2, 12: 3, 15: 5, 20: 5 };
function loadYRangeDb(): YRangeDb {
try {
const raw = Number(localStorage.getItem(Y_RANGE_STORAGE_KEY));
if (Y_RANGE_OPTIONS.includes(raw as YRangeDb)) return raw as YRangeDb;
} catch {
/* localStorage 不可用时回退默认值 */
}
return DEFAULT_Y_RANGE_DB;
}
/* ── Legend indicator ── */
function LegendCurveIndicator({
loading,
color,
}: {
loading: boolean;
color: string;
}) {
if (loading) {
return (
<span
className="inline-block w-3 h-3 rounded-full border-2 animate-spin flex-shrink-0"
style={{ borderColor: `${color}40`, borderTopColor: color }}
aria-hidden="true"
/>
);
}
return <div className="w-3 h-[2px] rounded flex-shrink-0" style={{ background: color }} />;
}
/* ── Frequency Response Chart ── */
export function FreqChart({
bands,
rawCurve,
rawCurveLoading = false,
selectedBand,
abMode,
onAbToggle,
onCopyAndSwitchTo,
canUndo,
onUndo,
onApplyB,
onSaveB,
onBandDrag,
onBandDragStart,
onBandDragEnd,
onBandSelect,
eqUi,
}: {
bands: Array<{
freq: number;
gain: number;
q: number;
type: string;
enabled: boolean;
/** 旁通的带贡献 0 dB,曲线与手柄纵坐标自动排除它 */
bypassed?: boolean;
}>;
rawCurve: number[] | null;
rawCurveLoading?: boolean;
selectedBand: number;
abMode: "A" | "B";
onAbToggle: (m: "A" | "B") => void;
onCopyAndSwitchTo: (to: "A" | "B") => void;
canUndo: boolean;
onUndo: () => void;
onApplyB: () => void;
onSaveB: () => void;
onBandDrag: (idx: number, patch: Partial<{ freq: number; gain: number }>) => void;
onBandDragStart?: () => void;
onBandDragEnd?: () => void;
onBandSelect: (idx: number) => void;
eqUi: PeqEqUi;
}) {
/** 频响图 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);
const [yDbMax, setYDbMax] = useState<YRangeDb>(loadYRangeDb);
const handleYRangeChange = (range: YRangeDb) => {
setYDbMax(range);
try {
localStorage.setItem(Y_RANGE_STORAGE_KEY, String(range));
} catch {
/* 忽略持久化失败,仅影响下次进入页面的默认值 */
}
};
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 = yDbMax;
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);
onBandDragStart?.();
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 = () => {
if (draggingBandRef.current === null) return;
draggingBandRef.current = null;
setIsBandDragging(false);
onBandDragEnd?.();
};
const curveData = useMemo(
() =>
buildPeqSvgCurveData({
bands,
width: W,
height: H,
fs: 48000,
yDbMax: Y_DB_MAX,
minFreq: 20,
maxFreq: 20000,
paddingY: 10,
}),
[bands, W, H, Y_DB_MAX],
);
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];
// 按当前范围的整数步长生成网格标签(如 ±12 → 12,9,6,3,0,-3,-6,-9,-12)
const gainLabels = useMemo(() => {
const step = Y_RANGE_STEP[Y_DB_MAX];
const intervals = Math.round((2 * Y_DB_MAX) / step);
const labels: number[] = [];
for (let i = 0; i <= intervals; i++) {
labels.push(Number((Y_DB_MAX - i * step).toFixed(2)));
}
return labels;
}, [Y_DB_MAX]);
const formatGainLabel = (g: number) => {
const v = Number(g.toFixed(2));
return v > 0 ? `+${v}` : String(v);
};
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={rawCurveLoading || !hasRawCurve}
>
<LegendCurveIndicator loading={rawCurveLoading} color="#ffffff" />
<span
className="text-[10px]"
style={{ color: "#ffffff", opacity: rawCurveLoading || 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={rawCurveLoading || !hasEqualizedCurve}
>
<LegendCurveIndicator loading={rawCurveLoading} color="#23d2fe" />
<span
className="text-[10px]"
style={{ color: "#23d2fe", opacity: rawCurveLoading || showEqualized ? 1 : 0.35 }}
>
Equalized
</span>
</button>
{/* Y 轴刻度范围切换 */}
<div
className="ml-auto flex shrink-0 items-center overflow-hidden rounded-[8px]"
style={{ background: "rgba(44,44,46,0.7)", border: "1px solid rgba(255,255,255,0.08)" }}
>
{Y_RANGE_OPTIONS.map((r) => (
<button
key={r}
type="button"
className="px-2 py-1 text-[10px] transition-all duration-150"
style={
yDbMax === r
? { background: "#00FFF6", color: "#000", fontWeight: 600 }
: { color: "rgba(255,255,255,0.4)" }
}
onClick={() => handleYRangeChange(r)}
>
±{r}
</button>
))}
</div>
</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>
<PeqUndoButton
disabled={!canUndo}
label={eqUi.undo ?? "Undo"}
onClick={onUndo}
/>
</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}
>
<defs>
{/* 绘图区裁剪:超出当前 Y 轴刻度范围的曲线部分直接截断不显示 */}
<clipPath id="freq-chart-plot-clip">
<rect x="0" y="10" width={W} height={H - 20} />
</clipPath>
</defs>
{/* dB grid */}
{gainLabels.map((g) => (
<g key={`gain-${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)">
{formatGainLabel(g)}
</text>
</g>
))}
{/* Freq grid */}
{freqLabels.map((f) => (
<line key={`freq-line-${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" />
<g clipPath="url(#freq-chart-plot-clip)">
{/* 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"
/>
)}
</g>
{/* Band nodes with index */}
{showEq && bands.map((band, i) => (
<g
key={`band-${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 = Math.max(10, Math.min(H - 10, 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={`freq-text-${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>
);
}