Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 14 additions & 28 deletions apps/catune/src/components/cards/CaTuneZoomWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 = (
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 */

Expand Down
3 changes: 3 additions & 0 deletions apps/catune/src/components/cards/CardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ export function CardGrid(props: CardGridProps) {
<CellCard
cellIndex={cellIndex}
rawTrace={t().raw}
rawStats={t().rawStats}
deconvolvedTrace={t().deconvolved}
deconvMinMax={t().deconvMinMax}
reconvolutionTrace={t().reconvolution}
filteredTrace={t().filteredTrace}
samplingRate={samplingRate() ?? 30}
Expand All @@ -113,6 +115,7 @@ export function CardGrid(props: CardGridProps) {
onZoomChange={reportCellZoom}
windowStartSample={t().windowStartSample}
pinnedDeconvolved={pinnedTraces()?.deconvolved}
pinnedDeconvMinMax={pinnedTraces()?.deconvMinMax}
pinnedReconvolution={pinnedTraces()?.reconvolution}
pinnedWindowStartSample={pinnedTraces()?.windowStartSample}
groundTruthSpikes={gt()?.spikes}
Expand Down
8 changes: 7 additions & 1 deletion apps/catune/src/components/cards/CellCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import { QualityBadge } from '../metrics/QualityBadge.tsx';
import type { CellSolverStatus } from '@calab/core';
import { computePeakSNR, snrToQuality } from '@calab/core';
import { Card } from '@calab/ui';
import { setHoveredCell } from '../../lib/multi-cell-store.ts';
import { setHoveredCell, type RawTraceStats } from '../../lib/multi-cell-store.ts';
import { cardHeight, setCardHeight, currentTau } from '../../lib/viz-store.ts';
import { CELL_CARD_ZOOM_WINDOW_S } from '../../lib/cell-solve-manager.ts';

export interface CellCardProps {
cellIndex: number;
rawTrace: Float64Array;
rawStats: RawTraceStats;
deconvolvedTrace?: Float32Array;
deconvMinMax: [number, number];
reconvolutionTrace?: Float32Array;
filteredTrace?: Float32Array;
samplingRate: number;
Expand All @@ -29,6 +31,7 @@ export interface CellCardProps {
onZoomChange?: (cellIndex: number, startS: number, endS: number) => void;
windowStartSample?: number;
pinnedDeconvolved?: Float32Array;
pinnedDeconvMinMax?: [number, number];
pinnedReconvolution?: Float32Array;
pinnedWindowStartSample?: number;
groundTruthSpikes?: Float64Array;
Expand Down Expand Up @@ -132,7 +135,9 @@ export function CellCard(props: CellCardProps) {
<div class="cell-card__zoom">
<CaTuneZoomWindow
rawTrace={props.rawTrace}
rawStats={props.rawStats}
deconvolvedTrace={props.deconvolvedTrace}
deconvMinMax={props.deconvMinMax}
reconvolutionTrace={props.reconvolutionTrace}
filteredTrace={props.filteredTrace}
samplingRate={props.samplingRate}
Expand All @@ -142,6 +147,7 @@ export function CellCard(props: CellCardProps) {
onZoomChange={handleZoomChange}
deconvWindowOffset={props.windowStartSample}
pinnedDeconvolved={props.pinnedDeconvolved}
pinnedDeconvMinMax={props.pinnedDeconvMinMax}
pinnedReconvolution={props.pinnedReconvolution}
pinnedWindowOffset={props.pinnedWindowStartSample}
data-tutorial={props.isActive ? 'zoom-window' : undefined}
Expand Down
31 changes: 20 additions & 11 deletions apps/catune/src/components/controls/ParameterSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>;
Expand Down Expand Up @@ -100,20 +107,22 @@ export function ParameterSlider(props: ParameterSliderProps) {
/>
<Show when={props.trueValue !== undefined}>
{(() => {
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 (
<div
class="param-slider__true-marker"
style={{ left: `${pct}%` }}
title={`True value: ${formattedValue}${props.unit ? ' ' + props.unit : ''}`}
style={{ left: `calc(${pct()}% + ${thumbOffsetPx()}px)` }}
title={`True value: ${formattedValue()}${props.unit ? ' ' + props.unit : ''}`}
/>
);
})()}
Expand Down
2 changes: 1 addition & 1 deletion apps/catune/src/components/import/TracePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};

Expand Down
2 changes: 2 additions & 0 deletions apps/catune/src/components/layout/CompactHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ export interface CaTuneHeaderProps {
export function CaTuneHeader(props: CaTuneHeaderProps): JSX.Element {
const handleChangeData = () => {
clearMultiCellState();
resetSpectrumStore();
resetImport();
};

Expand Down
36 changes: 27 additions & 9 deletions apps/catune/src/components/spectrum/SpectrumPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,19 +124,36 @@ function filterBandPlugin(
export function SpectrumPanel() {
const [container, setContainer] = createSignal<HTMLDivElement>();
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();

Expand Down Expand Up @@ -166,8 +183,8 @@ export function SpectrumPanel() {
plugins: [
filterBandPlugin(
filterEnabled,
() => data.highPassHz,
() => data.lowPassHz,
() => spectrumData()?.highPassHz ?? 0,
() => spectrumData()?.lowPassHz ?? Number.POSITIVE_INFINITY,
theme,
),
],
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions apps/catune/src/lib/__tests__/multi-cell-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down
7 changes: 6 additions & 1 deletion apps/catune/src/lib/cell-solve-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
}
Expand Down
Loading
Loading