From a4377def21741fb16c7d19837ad99db71ab1f2fc Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 20:51:56 -0700 Subject: [PATCH 1/6] fix(catune): align ground-truth markers with slider thumb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The true-value marker on Peak/FWHM sliders was drawn as `left: pct%` of the track container, but `flex: 1` on the range input was ineffective (parent wasn't flex) so the input kept its ~129px UA default. The marker floated well to the right of the actual thumb position — users saw a GCaMP6f simulated marker at ~300ms Peak / ~800ms FWHM instead of the true ~215ms / ~695ms. Make the track container `display: flex` so the input fills it, and add a half-thumb-width inset correction so edge values align exactly (the thumb center is at `+7px` at pct=0 and `-7px` at pct=100, not at the literal container edges). Thumb width is kept as a named constant mirroring the `14px` in `controls.css`. Also convert the marker's computed values from frozen `const`s inside the `` IIFE into reactive accessors, so the marker updates when `trueValue` / `toSlider` change (e.g., swapping simulation presets). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/controls/ParameterSlider.tsx | 32 ++++++++++++------- apps/catune/src/styles/controls.css | 2 ++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/catune/src/components/controls/ParameterSlider.tsx b/apps/catune/src/components/controls/ParameterSlider.tsx index 7589ae94..9b22265f 100644 --- a/apps/catune/src/components/controls/ParameterSlider.tsx +++ b/apps/catune/src/components/controls/ParameterSlider.tsx @@ -6,6 +6,13 @@ import { Show } from 'solid-js'; import type { Accessor } from 'solid-js'; import { notifyTutorialAction, isTutorialActive } from '@calab/tutorials'; +// Mirrors the range-input thumb size in controls.css — required for accurate +// true-value marker positioning (the thumb center insets from the track edges +// by half its own width). +const THUMB_WIDTH_PX = 14; +const THUMB_HALF_WIDTH_PX = THUMB_WIDTH_PX / 2; +const THUMB_OFFSET_SLOPE = THUMB_WIDTH_PX / 100; + export interface ParameterSliderProps { label: string; value: Accessor; @@ -100,20 +107,23 @@ export function ParameterSlider(props: ParameterSliderProps) { /> {(() => { - const sliderMin = props.toSlider ? 0 : props.min; - const sliderMax = props.toSlider ? 1 : props.max; - const mappedValue = props.toSlider - ? props.toSlider(props.trueValue!) - : props.trueValue!; - const pct = ((mappedValue - sliderMin) / (sliderMax - sliderMin)) * 100; - const formattedValue = props.format - ? props.format(props.trueValue!) - : props.trueValue!.toString(); + const sliderMin = () => (props.toSlider ? 0 : props.min); + const sliderMax = () => (props.toSlider ? 1 : props.max); + const mappedValue = () => + props.toSlider ? props.toSlider(props.trueValue!) : props.trueValue!; + const pct = () => + ((mappedValue() - sliderMin()) / (sliderMax() - sliderMin())) * 100; + // Thumb center insets by half-thumb-width at each end of the track, + // so at pct=0 the thumb is at +7px, at pct=100 it's at -7px. Offset + // the marker so it aligns with the thumb center at the same value. + const thumbOffsetPx = () => THUMB_HALF_WIDTH_PX - pct() * THUMB_OFFSET_SLOPE; + const formattedValue = () => + props.format ? props.format(props.trueValue!) : props.trueValue!.toString(); return (
); })()} diff --git a/apps/catune/src/styles/controls.css b/apps/catune/src/styles/controls.css index 5be12af4..e0cbab14 100644 --- a/apps/catune/src/styles/controls.css +++ b/apps/catune/src/styles/controls.css @@ -165,6 +165,8 @@ ======================== */ .param-slider__track-container { position: relative; + display: flex; + align-items: center; } .param-slider__true-marker { From 6504e1d76682d2e9b4c56ad0eebf1340a0703f52 Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 20:52:02 -0700 Subject: [PATCH 2/6] fix(catune): 1-index cell labels in import preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TracePreview rendered traces as "Cell 0, Cell 1, …" while the rest of the UI is 1-indexed (`CellCard` shows `Cell {cellIndex + 1}`, `CellSelector` labels its input "Cell indices (1-indexed)"). Align the import preview with the app-wide convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/catune/src/components/import/TracePreview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/catune/src/components/import/TracePreview.tsx b/apps/catune/src/components/import/TracePreview.tsx index 346d6e16..c367538d 100644 --- a/apps/catune/src/components/import/TracePreview.tsx +++ b/apps/catune/src/components/import/TracePreview.tsx @@ -106,7 +106,7 @@ export function TracePreview() { // Trace label ctx.fillStyle = TRACE_COLORS[t % TRACE_COLORS.length]; ctx.font = '11px system-ui, sans-serif'; - ctx.fillText(`Cell ${t}`, 4, yBase + 12); + ctx.fillText(`Cell ${t + 1}`, 4, yBase + 12); } }; From acbf864a48e5124ebc21d7d6fc49c86bd30db805 Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 20:52:15 -0700 Subject: [PATCH 3/6] perf(catune): redraw spectrum on cutoff change, not rebuild; plug leaks Two issues in the spectrum panel that showed up during parameter tuning and dataset re-imports: 1. The outer chart-build effect tracked the whole `spectrumData` signal. Cutoff-only updates (from tau changes) produced a new `spectrumData` object identity, so every tau tick tore down and rebuilt the uPlot chart instead of just repainting the two cutoff lines. Split into (a) a rebuild effect keyed on the `freqs` typed-array reference (stable across cutoff-only updates) and (b) a redraw effect keyed on cutoffs + filter toggle. The filter-band plugin now reads cutoffs live via accessors, so a plain `uplot.redraw()` picks up new values. 2. `initSpectrumStore()` registered effects and a debounce `setTimeout` on module-level state with no `onCleanup`. Re-importing a dataset left the previous timer pending (firing into stale state) and retained `psdCache` PSDs for cells that no longer existed. Add an `onCleanup` for the debounce timer, plus an exported `resetSpectrumStore()` that clears caches and last-seen refs; wire it into the reset path alongside `clearMultiCellState` / `resetImport`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/layout/CompactHeader.tsx | 2 ++ .../src/components/spectrum/SpectrumPanel.tsx | 27 ++++++++++++------- .../catune/src/lib/spectrum/spectrum-store.ts | 26 +++++++++++++++++- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/apps/catune/src/components/layout/CompactHeader.tsx b/apps/catune/src/components/layout/CompactHeader.tsx index b3bd86e2..c65c44e1 100644 --- a/apps/catune/src/components/layout/CompactHeader.tsx +++ b/apps/catune/src/components/layout/CompactHeader.tsx @@ -9,6 +9,7 @@ import { resetImport, } from '../../lib/data-store.ts'; import { clearMultiCellState } from '../../lib/multi-cell-store.ts'; +import { resetSpectrumStore } from '../../lib/spectrum/spectrum-store.ts'; import { TutorialLauncher } from '../tutorial/TutorialLauncher.tsx'; import { FeedbackMenu } from './FeedbackMenu.tsx'; import { AuthMenuWrapper } from './AuthMenuWrapper.tsx'; @@ -26,6 +27,7 @@ export interface CaTuneHeaderProps { export function CaTuneHeader(props: CaTuneHeaderProps): JSX.Element { const handleChangeData = () => { clearMultiCellState(); + resetSpectrumStore(); resetImport(); }; diff --git a/apps/catune/src/components/spectrum/SpectrumPanel.tsx b/apps/catune/src/components/spectrum/SpectrumPanel.tsx index 55dfc698..ba27ca99 100644 --- a/apps/catune/src/components/spectrum/SpectrumPanel.tsx +++ b/apps/catune/src/components/spectrum/SpectrumPanel.tsx @@ -3,7 +3,7 @@ * Uses uPlot following ScatterPlot.tsx patterns (ResizeObserver, dark theme, canvas plugins). */ -import { createEffect, createSignal, on, onCleanup, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, on, onCleanup, Show } from 'solid-js'; import { spectrumData } from '../../lib/spectrum/spectrum-store.ts'; import { filterEnabled } from '../../lib/viz-store.ts'; import { samplingRate } from '../../lib/data-store.ts'; @@ -125,10 +125,13 @@ export function SpectrumPanel() { const [container, setContainer] = createSignal(); let uplotInstance: uPlot | undefined; - // Rebuild chart when spectrum data or container changes. - // container is a signal so the effect re-fires when Show renders the div. + // Rebuild only when the series data identity changes (new FFT computed). + // Cutoff-only updates preserve the freqs reference, so they don't trigger + // a full rebuild — they get picked up by the redraw effect below. + const seriesKey = createMemo(() => spectrumData()?.freqs); + createEffect( - on([spectrumData, container], () => { + on([seriesKey, container], () => { if (uplotInstance) { uplotInstance.destroy(); uplotInstance = undefined; @@ -166,8 +169,8 @@ export function SpectrumPanel() { plugins: [ filterBandPlugin( filterEnabled, - () => data.highPassHz, - () => data.lowPassHz, + () => spectrumData()?.highPassHz ?? 0, + () => spectrumData()?.lowPassHz ?? Number.POSITIVE_INFINITY, theme, ), ], @@ -218,11 +221,15 @@ export function SpectrumPanel() { }), ); - // Redraw (not rebuild) when filter toggle changes — plugin reads filterEnabled() live + // Redraw (not rebuild) when filter toggle or cutoffs change — plugin reads + // filterEnabled() and cutoff accessors live from the store on each draw. createEffect( - on(filterEnabled, () => { - if (uplotInstance) uplotInstance.redraw(); - }), + on( + [filterEnabled, () => spectrumData()?.highPassHz, () => spectrumData()?.lowPassHz], + () => { + if (uplotInstance) uplotInstance.redraw(); + }, + ), ); // ResizeObserver for sidebar open/close reflow diff --git a/apps/catune/src/lib/spectrum/spectrum-store.ts b/apps/catune/src/lib/spectrum/spectrum-store.ts index 0e61b2e8..dc53e6c3 100644 --- a/apps/catune/src/lib/spectrum/spectrum-store.ts +++ b/apps/catune/src/lib/spectrum/spectrum-store.ts @@ -2,7 +2,7 @@ // Cutoffs update immediately on parameter change; expensive FFT only // recomputes when the underlying raw trace or selected cell changes. -import { createSignal, createEffect, on } from 'solid-js'; +import { createSignal, createEffect, on, onCleanup } from 'solid-js'; import { multiCellResults } from '../multi-cell-store.ts'; import { samplingRate } from '../data-store.ts'; import { currentTau, selectedCell } from '../viz-store.ts'; @@ -68,6 +68,30 @@ export function initSpectrumStore(): void { debounceTimer = setTimeout(computeSpectrum, 250); }), ); + + // Cancel pending FFT if the owning scope is disposed (re-import / unmount). + // Without this, a pending setTimeout fires into stale state after reset. + onCleanup(() => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + }); +} + +/** + * Clear all module-level caches so the next dataset starts clean. + * Called from the reset flow alongside clearMultiCellState / resetImport. + */ +export function resetSpectrumStore(): void { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + lastRaw = null; + lastCellIdx = -1; + psdCache.clear(); + setSpectrumData(null); } function computeSpectrum(): void { From ed0bc3fefa8ed8b97113a1d8f74aba9b48ef573d Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 20:52:31 -0700 Subject: [PATCH 4/6] perf(catune): cache raw stats + deconv min/max in the cell store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CaTuneZoomWindow` recomputed raw-trace mean/std and deconvolved min/max from typed-array scans inside `createMemo`s. Because props flow through the multi-cell store (which replaces the whole `CellTraces` object on every solver write), the memos saw identity churn per tick and re-ran on every visible card for every solver iteration — an O(n) scan × N cards × tick cadence that Peak/FWHM drags felt in the scripting cost. Move the work next to the data: - `CellTraces` gains a required `rawStats: RawTraceStats` (mean, std, zMin, zMax) and `deconvMinMax: [number, number]`. Helpers `computeRawStats` and `computeArrayMinMax` live in the store module. - `updateOneCellTraces` fuses a single min/max pass with every solver write, so deconv min/max is up-to-date in O(n) per write (as opposed to O(n × visible cards) per write). - `cell-solve-manager` computes `rawStats` once at initial cell insertion — raw traces never mutate during a session. - `CardGrid` / `CellCard` / `CaTuneZoomWindow` thread the two fields through. The per-card `rawStats` / `deconvMinMax` / `pinnedDeconvMinMax` memos are removed. `gtSpikesMinMax` memo stays — GT data is stable and infrequent. Test helper `seedCellTraces` updated to the new required fields. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/cards/CaTuneZoomWindow.tsx | 42 ++++++----------- apps/catune/src/components/cards/CardGrid.tsx | 3 ++ apps/catune/src/components/cards/CellCard.tsx | 8 +++- .../lib/__tests__/multi-cell-store.test.ts | 2 + apps/catune/src/lib/cell-solve-manager.ts | 7 ++- apps/catune/src/lib/multi-cell-store.ts | 46 +++++++++++++++++++ 6 files changed, 78 insertions(+), 30 deletions(-) diff --git a/apps/catune/src/components/cards/CaTuneZoomWindow.tsx b/apps/catune/src/components/cards/CaTuneZoomWindow.tsx index 71562939..f07dad7a 100644 --- a/apps/catune/src/components/cards/CaTuneZoomWindow.tsx +++ b/apps/catune/src/components/cards/CaTuneZoomWindow.tsx @@ -29,10 +29,15 @@ import { showGTSpikes, currentTau, } from '../../lib/viz-store.ts'; +import type { RawTraceStats } from '../../lib/multi-cell-store.ts'; export interface CaTuneZoomWindowProps { rawTrace: Float64Array; + /** Precomputed z-score stats for the raw trace — immutable per session. */ + rawStats: RawTraceStats; deconvolvedTrace?: Float32Array; + /** [min, max] of the deconvolved trace, precomputed on solver write. */ + deconvMinMax: [number, number]; reconvolutionTrace?: Float32Array; filteredTrace?: Float32Array; samplingRate: number; @@ -43,6 +48,8 @@ export interface CaTuneZoomWindowProps { onZoomChange?: (startTime: number, endTime: number) => void; deconvWindowOffset?: number; pinnedDeconvolved?: Float32Array; + /** [min, max] of the pinned deconvolved trace, snapshotted on pin. */ + pinnedDeconvMinMax?: [number, number]; pinnedReconvolution?: Float32Array; pinnedWindowOffset?: number; 'data-tutorial'?: string; @@ -98,31 +105,9 @@ export function CaTuneZoomWindow(props: CaTuneZoomWindowProps) { const bucketWidth = () => Math.max(MIN_BUCKET_WIDTH, Math.min(MAX_BUCKET_WIDTH, Math.round(chartWidth()))); - const rawStats = createMemo(() => { - const raw = props.rawTrace; - if (!raw || raw.length === 0) return { mean: 0, std: 1, zMin: 0, zMax: 0 }; - let sum = 0; - let sumSq = 0; - let rawMin = Infinity; - let rawMax = -Infinity; - for (let i = 0; i < raw.length; i++) { - const v = raw[i]; - sum += v; - sumSq += v * v; - if (v < rawMin) rawMin = v; - if (v > rawMax) rawMax = v; - } - const n = raw.length; - const mean = sum / n; - const std = Math.sqrt(sumSq / n - mean * mean) || 1; - const zMin = (rawMin - mean) / std; - const zMax = (rawMax - mean) / std; - return { mean, std, zMin, zMax }; - }); - const globalYRange = createMemo<[number, number]>(() => { const raw = props.rawTrace; - const { zMin, zMax } = rawStats(); + const { zMin, zMax } = props.rawStats; if (!raw || raw.length === 0) return [-4, 6]; const rawRange = zMax - zMin; const deconvHeight = rawRange * DECONV_SCALE; @@ -132,8 +117,9 @@ export function CaTuneZoomWindow(props: CaTuneZoomWindowProps) { return [residBottom, zMax + rawRange * 0.02]; }); - const deconvMinMax = createMemo(() => typedArrayMinMax(props.deconvolvedTrace)); - const pinnedDeconvMinMax = createMemo(() => typedArrayMinMax(props.pinnedDeconvolved)); + // Ground-truth spike min/max is recomputed here rather than in the store + // because GT is loaded once per session and swapping the reference via + // toggle/visibility is infrequent — memoization amortizes it. const gtSpikesMinMax = createMemo(() => typedArrayMinMax(props.groundTruthSpikes)); const sliceAndDownsample = ( @@ -228,7 +214,7 @@ export function CaTuneZoomWindow(props: CaTuneZoomWindowProps) { if (startSample >= endSample) return emptySeriesData(); const len = endSample - startSample; - const { mean, std, zMin, zMax } = rawStats(); + const { mean, std, zMin, zMax } = props.rawStats; const x = new Float64Array(len); const dt = 1 / fs; @@ -294,7 +280,7 @@ export function CaTuneZoomWindow(props: CaTuneZoomWindowProps) { offset, raw.length, dsX.length, - (vals) => scaleToDeconvBand(vals, deconvMinMax(), zMin, zMax), + (vals) => scaleToDeconvBand(vals, props.deconvMinMax, zMin, zMax), ); const dsResid = computeResiduals(dsRaw, dsReconv, zMin, zMax, dsX.length); @@ -318,7 +304,7 @@ export function CaTuneZoomWindow(props: CaTuneZoomWindowProps) { pinnedOffset, raw.length, dsX.length, - (vals) => scaleToDeconvBand(vals, pinnedDeconvMinMax(), zMin, zMax), + (vals) => scaleToDeconvBand(vals, props.pinnedDeconvMinMax ?? [0, 0], zMin, zMax), ); /* eslint-enable solid/reactivity */ diff --git a/apps/catune/src/components/cards/CardGrid.tsx b/apps/catune/src/components/cards/CardGrid.tsx index 0e553940..74f43d45 100644 --- a/apps/catune/src/components/cards/CardGrid.tsx +++ b/apps/catune/src/components/cards/CardGrid.tsx @@ -101,7 +101,9 @@ export function CardGrid(props: CardGridProps) { void; windowStartSample?: number; pinnedDeconvolved?: Float32Array; + pinnedDeconvMinMax?: [number, number]; pinnedReconvolution?: Float32Array; pinnedWindowStartSample?: number; groundTruthSpikes?: Float64Array; @@ -132,7 +135,9 @@ export function CellCard(props: CellCardProps) {
max) max = v; + } + const n = raw.length; + const mean = sum / n; + const std = Math.sqrt(sumSq / n - mean * mean) || 1; + return { mean, std, zMin: (min - mean) / std, zMax: (max - mean) / std }; +} + +/** [min, max] over a typed array. Empty input yields [0, 0]. */ +export function computeArrayMinMax(arr: ArrayLike): [number, number] { + if (arr.length === 0) return [0, 0]; + let lo = Infinity; + let hi = -Infinity; + for (let i = 0; i < arr.length; i++) { + const v = arr[i]; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + return [lo, hi]; +} + // Record types for store-backed per-cell data. // Using Record instead of Map enables SolidJS // to track each cell index as a separate reactive property. @@ -90,6 +135,7 @@ function updateOneCellTraces( setMultiCellResults(cellIndex, { ...existing, deconvolved, + deconvMinMax: computeArrayMinMax(deconvolved), reconvolution, filteredTrace, windowStartSample, From c8944a53b1ecdabfd93db2791195ba90e21fb767 Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 20:57:58 -0700 Subject: [PATCH 5/6] chore(catune): apply prettier formatting CI format check caught two lines that fit within the project's width after the earlier edits. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/catune/src/components/controls/ParameterSlider.tsx | 3 +-- apps/catune/src/components/spectrum/SpectrumPanel.tsx | 9 +++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/catune/src/components/controls/ParameterSlider.tsx b/apps/catune/src/components/controls/ParameterSlider.tsx index 9b22265f..26b3897a 100644 --- a/apps/catune/src/components/controls/ParameterSlider.tsx +++ b/apps/catune/src/components/controls/ParameterSlider.tsx @@ -111,8 +111,7 @@ export function ParameterSlider(props: ParameterSliderProps) { const sliderMax = () => (props.toSlider ? 1 : props.max); const mappedValue = () => props.toSlider ? props.toSlider(props.trueValue!) : props.trueValue!; - const pct = () => - ((mappedValue() - sliderMin()) / (sliderMax() - sliderMin())) * 100; + const pct = () => ((mappedValue() - sliderMin()) / (sliderMax() - sliderMin())) * 100; // Thumb center insets by half-thumb-width at each end of the track, // so at pct=0 the thumb is at +7px, at pct=100 it's at -7px. Offset // the marker so it aligns with the thumb center at the same value. diff --git a/apps/catune/src/components/spectrum/SpectrumPanel.tsx b/apps/catune/src/components/spectrum/SpectrumPanel.tsx index ba27ca99..832c916d 100644 --- a/apps/catune/src/components/spectrum/SpectrumPanel.tsx +++ b/apps/catune/src/components/spectrum/SpectrumPanel.tsx @@ -224,12 +224,9 @@ export function SpectrumPanel() { // Redraw (not rebuild) when filter toggle or cutoffs change — plugin reads // filterEnabled() and cutoff accessors live from the store on each draw. createEffect( - on( - [filterEnabled, () => spectrumData()?.highPassHz, () => spectrumData()?.lowPassHz], - () => { - if (uplotInstance) uplotInstance.redraw(); - }, - ), + on([filterEnabled, () => spectrumData()?.highPassHz, () => spectrumData()?.lowPassHz], () => { + if (uplotInstance) uplotInstance.redraw(); + }), ); // ResizeObserver for sidebar open/close reflow From 290364847e3283e92375f7ebbd85924610a646b8 Mon Sep 17 00:00:00 2001 From: daharoni Date: Tue, 21 Apr 2026 21:02:46 -0700 Subject: [PATCH 6/6] fix(catune): restore spectrum chart on initial data load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous refactor split the chart setup into a rebuild effect keyed on a `createMemo(() => spectrumData()?.freqs)` and a redraw effect keyed on cutoffs. In practice this left the chart blank on first load (likely an HMR/tracking interaction around the memo's undefined→Float64Array transition interleaving with `container` signal updates from the Show's ref). Fold both paths back into a single effect that tracks spectrumData and container, same shape as the pre-refactor version. Skip the destroy-and-rebuild path when the freqs typed-array reference matches the last build (cutoff-only updates): just call `uplot.redraw()` and let the filter-band plugin pick up the new cutoffs via its live accessors. Keep a smaller redraw effect for the filter-enabled toggle. Same perf win for cutoff scrubs, without the initial-load regression. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/spectrum/SpectrumPanel.tsx | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/apps/catune/src/components/spectrum/SpectrumPanel.tsx b/apps/catune/src/components/spectrum/SpectrumPanel.tsx index 832c916d..bbe69d52 100644 --- a/apps/catune/src/components/spectrum/SpectrumPanel.tsx +++ b/apps/catune/src/components/spectrum/SpectrumPanel.tsx @@ -3,7 +3,7 @@ * Uses uPlot following ScatterPlot.tsx patterns (ResizeObserver, dark theme, canvas plugins). */ -import { createEffect, createMemo, createSignal, on, onCleanup, Show } from 'solid-js'; +import { createEffect, createSignal, on, onCleanup, Show } from 'solid-js'; import { spectrumData } from '../../lib/spectrum/spectrum-store.ts'; import { filterEnabled } from '../../lib/viz-store.ts'; import { samplingRate } from '../../lib/data-store.ts'; @@ -124,22 +124,36 @@ function filterBandPlugin( export function SpectrumPanel() { const [container, setContainer] = createSignal(); let uplotInstance: uPlot | undefined; - - // Rebuild only when the series data identity changes (new FFT computed). - // Cutoff-only updates preserve the freqs reference, so they don't trigger - // a full rebuild — they get picked up by the redraw effect below. - const seriesKey = createMemo(() => spectrumData()?.freqs); + // Track the freqs typed-array reference across rebuilds. Cutoff-only updates + // from Effect 1 in spectrum-store.ts preserve the freqs reference, so we can + // skip the destroy+rebuild path and just redraw in that case. + let lastFreqs: Float64Array | null = null; createEffect( - on([seriesKey, container], () => { + on([spectrumData, container], () => { + const data = spectrumData(); + const el = container(); + if (!data || !el) { + if (uplotInstance) { + uplotInstance.destroy(); + uplotInstance = undefined; + lastFreqs = null; + } + return; + } + + // Same underlying freq grid → cutoff-only update. Redraw picks up the + // new cutoffs via the live accessors passed to filterBandPlugin. + if (uplotInstance && lastFreqs === data.freqs) { + uplotInstance.redraw(); + return; + } + if (uplotInstance) { uplotInstance.destroy(); uplotInstance = undefined; } - - const data = spectrumData(); - const el = container(); - if (!data || !el) return; + lastFreqs = data.freqs; const theme = getThemeColors(); @@ -221,10 +235,10 @@ export function SpectrumPanel() { }), ); - // Redraw (not rebuild) when filter toggle or cutoffs change — plugin reads - // filterEnabled() and cutoff accessors live from the store on each draw. + // Redraw when filter toggle changes — plugin reads filterEnabled() live. + // Cutoff changes are handled by the main effect above (redraw fast path). createEffect( - on([filterEnabled, () => spectrumData()?.highPassHz, () => spectrumData()?.lowPassHz], () => { + on(filterEnabled, () => { if (uplotInstance) uplotInstance.redraw(); }), );