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) {
; @@ -100,20 +107,22 @@ 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/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); } }; 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..bbe69d52 100644 --- a/apps/catune/src/components/spectrum/SpectrumPanel.tsx +++ b/apps/catune/src/components/spectrum/SpectrumPanel.tsx @@ -124,19 +124,36 @@ function filterBandPlugin( export function SpectrumPanel() { const [container, setContainer] = createSignal(); let uplotInstance: uPlot | undefined; + // 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; - // Rebuild chart when spectrum data or container changes. - // container is a signal so the effect re-fires when Show renders the div. createEffect( 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(); @@ -166,8 +183,8 @@ export function SpectrumPanel() { plugins: [ filterBandPlugin( filterEnabled, - () => data.highPassHz, - () => data.lowPassHz, + () => spectrumData()?.highPassHz ?? 0, + () => spectrumData()?.lowPassHz ?? Number.POSITIVE_INFINITY, theme, ), ], @@ -218,7 +235,8 @@ export function SpectrumPanel() { }), ); - // Redraw (not rebuild) when filter toggle changes — plugin reads filterEnabled() live + // Redraw when filter toggle changes — plugin reads filterEnabled() live. + // Cutoff changes are handled by the main effect above (redraw fast path). createEffect( on(filterEnabled, () => { if (uplotInstance) uplotInstance.redraw(); diff --git a/apps/catune/src/lib/__tests__/multi-cell-store.test.ts b/apps/catune/src/lib/__tests__/multi-cell-store.test.ts index 7dec80ee..21b50b90 100644 --- a/apps/catune/src/lib/__tests__/multi-cell-store.test.ts +++ b/apps/catune/src/lib/__tests__/multi-cell-store.test.ts @@ -72,7 +72,9 @@ function seedCellTraces(cellIndex: number): void { setMultiCellResults(cellIndex, { cellIndex, raw: new Float64Array(10), + rawStats: { mean: 0, std: 1, zMin: 0, zMax: 0 }, deconvolved: new Float32Array(10), + deconvMinMax: [0, 0], reconvolution: new Float32Array(10), }); } diff --git a/apps/catune/src/lib/cell-solve-manager.ts b/apps/catune/src/lib/cell-solve-manager.ts index 434ce256..b528a2dc 100644 --- a/apps/catune/src/lib/cell-solve-manager.ts +++ b/apps/catune/src/lib/cell-solve-manager.ts @@ -14,6 +14,7 @@ import { updateOneCellTraces, visibleCellIndices, hoveredCell, + computeRawStats, } from './multi-cell-store.ts'; import { extractCellTrace } from '@calab/io'; import { computePaddedWindow, computeSafeMargin, WarmStartCache } from '@calab/compute'; @@ -321,13 +322,17 @@ function ensureCellState( }; cellStates.set(cellIndex, state); - // Ensure the cell has an entry in multiCellResults for immediate card rendering + // Ensure the cell has an entry in multiCellResults for immediate card rendering. + // rawStats is computed once here since the raw trace is immutable per session; + // deconvMinMax starts at [0, 0] for the zeros and is refreshed on every solver tick. if (multiCellResults[cellIndex] === undefined) { const zeros = new Float32Array(rawTrace.length); setMultiCellResults(cellIndex, { cellIndex, raw: rawTrace, + rawStats: computeRawStats(rawTrace), deconvolved: zeros, + deconvMinMax: [0, 0], reconvolution: zeros, }); } diff --git a/apps/catune/src/lib/multi-cell-store.ts b/apps/catune/src/lib/multi-cell-store.ts index db131a60..88231e68 100644 --- a/apps/catune/src/lib/multi-cell-store.ts +++ b/apps/catune/src/lib/multi-cell-store.ts @@ -13,15 +13,60 @@ import { parsedData, effectiveShape, swapped } from './data-store.ts'; export type SelectionMode = 'top-active' | 'random' | 'manual'; +/** Z-score parameters derived from a raw trace. Computed once at ingest. */ +export interface RawTraceStats { + mean: number; + std: number; + zMin: number; + zMax: number; +} + export interface CellTraces { cellIndex: number; raw: Float64Array; + /** Precomputed raw-trace z-score stats. Constant per cell once set. */ + rawStats: RawTraceStats; deconvolved: Float32Array; + /** [min, max] of the deconvolved trace. Updated on every solver tick. */ + deconvMinMax: [number, number]; reconvolution: Float32Array; filteredTrace?: Float32Array; windowStartSample?: number; } +/** Compute z-score stats for a raw trace. Zero-length input yields identity stats. */ +export function computeRawStats(raw: Float64Array): RawTraceStats { + if (raw.length === 0) return { mean: 0, std: 1, zMin: 0, zMax: 0 }; + let sum = 0; + let sumSq = 0; + let min = Infinity; + let max = -Infinity; + for (let i = 0; i < raw.length; i++) { + const v = raw[i]; + sum += v; + sumSq += v * v; + if (v < min) min = v; + if (v > 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, 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 { 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 {