diff --git a/client/src/lib/peqAudio.ts b/client/src/lib/peqAudio.ts index 5c3b1e3..5b96360 100644 --- a/client/src/lib/peqAudio.ts +++ b/client/src/lib/peqAudio.ts @@ -339,7 +339,7 @@ export type PeqBandForResponse = { gain: number; freq: number; q: number; - type: string; + type: string | number; }; /** @@ -359,6 +359,60 @@ export function computePeqMagnitudeDb(bands: PeqBandForResponse[], fs: number): return getFreqznList(list, fs, f); } +export type PeqSvgCurveData = { + points: Array<{ x: number; y: number; gainDb: number; freq: number }>; + pathD: string; + fillD: string; +}; + +/** + * Build reusable SVG curve data (`points`, `pathD`, `fillD`) for PEQ response rendering. + * Any page can call this and render with SVG ``. + */ +export function buildPeqSvgCurveData(args: { + bands: PeqBandForResponse[]; + width: number; + height: number; + fs?: number; + yDbMax?: number; + minFreq?: number; + maxFreq?: number; + paddingY?: number; +}): PeqSvgCurveData { + const { + bands, + width, + height, + fs = 48000, + yDbMax = 20, + minFreq = 20, + maxFreq = 20000, + paddingY = 10, + } = args; + + const safeW = Math.max(1, width); + const safeH = Math.max(1, height); + + const logMin = Math.log10(minFreq); + const logMax = Math.log10(maxFreq); + const freqToX = (f: number) => + ((Math.log10(Math.max(minFreq, Math.min(maxFreq, f))) - logMin) / (logMax - logMin)) * safeW; + const gainToY = (g: number) => safeH / 2 - (g / yDbMax) * (safeH / 2 - paddingY); + + const freqs = getPeqLogSpacedFreqs(); + const db = computePeqMagnitudeDb(bands, fs); + const points = freqs.map((f, i) => { + const gainDb = db[i] ?? 0; + return { x: freqToX(f), y: gainToY(gainDb), gainDb, freq: f }; + }); + + const pathD = points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`) + .join(" "); + const fillD = pathD ? `${pathD} L ${safeW} ${safeH / 2} L 0 ${safeH / 2} Z` : ""; + return { points, pathD, fillD }; +} + /** * Get ECharts options for frequency response chart */ diff --git a/client/src/lib/useStrokeDrawAnimation.ts b/client/src/lib/useStrokeDrawAnimation.ts new file mode 100644 index 0000000..03c5d7d --- /dev/null +++ b/client/src/lib/useStrokeDrawAnimation.ts @@ -0,0 +1,52 @@ +import { useEffect } from "react"; + +/** + * Reusable left-to-right SVG stroke draw animation. + * Pass `enabled=false` to disable and clear dash styles (e.g. while dragging). + */ +export function useStrokeDrawAnimation( + pathRef: React.RefObject, + pathD: string, + options?: { + enabled?: boolean; + durationMs?: number; + easing?: string; + }, +) { + const enabled = options?.enabled ?? true; + const durationMs = options?.durationMs ?? 1350; + const easing = options?.easing ?? "cubic-bezier(0.33, 1, 0.68, 1)"; + + useEffect(() => { + const path = pathRef.current; + if (!path || !pathD) return; + + if (!enabled) { + path.style.strokeDasharray = ""; + path.style.strokeDashoffset = ""; + path.style.transition = ""; + return; + } + + const length = path.getTotalLength(); + if (!Number.isFinite(length) || length <= 0) return; + + path.style.strokeDasharray = `${length}`; + path.style.strokeDashoffset = `${length}`; + path.style.transition = "none"; + + let cancelled = false; + const id = requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (cancelled) return; + path.style.transition = `stroke-dashoffset ${durationMs}ms ${easing}`; + path.style.strokeDashoffset = "0"; + }); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(id); + }; + }, [pathRef, pathD, enabled, durationMs, easing]); +} diff --git a/client/src/pages/EQPage.tsx b/client/src/pages/EQPage.tsx index b9a5f67..62ac563 100644 --- a/client/src/pages/EQPage.tsx +++ b/client/src/pages/EQPage.tsx @@ -15,6 +15,7 @@ import { ChevronLeft, ChevronDown, Minus, Plus, Edit3, Headphones, Search, X } f import { useLocation } from "wouter"; import { useState, useMemo, useEffect, useRef, useCallback } from "react"; import { cn } from "@/lib/utils"; +import { useStrokeDrawAnimation } from "@/lib/useStrokeDrawAnimation"; import BottomNav from "@/components/BottomNav"; import { toast } from "sonner"; import { @@ -36,8 +37,7 @@ import { getChartOps, getFilterType, getFilterShortName, - computePeqMagnitudeDb, - getPeqLogSpacedFreqs, + buildPeqSvgCurveData, } from "@/lib/peqAudio"; /* ── iOS Toggle ── */ @@ -178,53 +178,28 @@ function FreqChart({ setIsBandDragging(false); }; - const FS = 48000; + const curveData = useMemo( + () => + buildPeqSvgCurveData({ + bands, + width: W, + height: H, + fs: 48000, + yDbMax: Y_DB_MAX, + minFreq: 20, + maxFreq: 20000, + paddingY: 10, + }), + [bands, W], + ); + const pathD = curveData.pathD; + const fillD = curveData.fillD; - const curve = useMemo(() => { - const freqs = getPeqLogSpacedFreqs(); - const db = computePeqMagnitudeDb(bands, FS); - return freqs.map((f, i) => { - const g = db[i] ?? 0; - return { x: freqToX(f), y: gainToY(g), g }; - }); - }, [bands, W]); - - const pathD = curve.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" "); - const fillD = `${pathD} L ${W} ${H / 2} L 0 ${H / 2} Z`; - - // Left-to-right stroke draw animation (skip while dragging a band) - useEffect(() => { - const path = curvePathRef.current; - if (!path || !pathD) return; - - if (isBandDragging) { - path.style.strokeDasharray = ""; - path.style.strokeDashoffset = ""; - path.style.transition = ""; - return; - } - - const length = path.getTotalLength(); - if (!Number.isFinite(length) || length <= 0) return; - - path.style.strokeDasharray = `${length}`; - path.style.strokeDashoffset = `${length}`; - path.style.transition = "none"; - - let cancelled = false; - const id1 = requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (cancelled) return; - path.style.transition = "stroke-dashoffset 1.35s cubic-bezier(0.33, 1, 0.68, 1)"; - path.style.strokeDashoffset = "0"; - }); - }); - - return () => { - cancelled = true; - cancelAnimationFrame(id1); - }; - }, [pathD, W, isBandDragging]); + // Reusable left-to-right stroke draw animation (skip while dragging a band). + useStrokeDrawAnimation(curvePathRef, pathD, { + enabled: !isBandDragging, + durationMs: 1350, + }); const freqLabels = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]; const gainLabels = [20, 15, 10, 5, 0, -5, -10, -15, -20];