diff --git a/apps/catune/src/components/controls/DualRangeSlider.tsx b/apps/catune/src/components/controls/DualRangeSlider.tsx new file mode 100644 index 0000000..9a6ef9b --- /dev/null +++ b/apps/catune/src/components/controls/DualRangeSlider.tsx @@ -0,0 +1,180 @@ +// Dual-thumb range slider for rise/decay time constants on a shared log-scale track. +// Both thumbs share one time axis so their positions are directly comparable. + +import { Show } from 'solid-js'; +import type { JSX, Accessor } from 'solid-js'; +import { notifyTutorialAction, isTutorialActive } from '@calab/tutorials'; + +interface DualRangeSliderProps { + label: string; + lowLabel: string; + highLabel: string; + lowValue: Accessor; + highValue: Accessor; + setLowValue: (v: number) => void; + setHighValue: (v: number) => void; + /** Overall track range (min of the shared log axis). */ + min: number; + /** Overall track range (max of the shared log axis). */ + max: number; + /** Clamp bounds for the low thumb. */ + lowMin: number; + lowMax: number; + /** Clamp bounds for the high thumb. */ + highMin: number; + highMax: number; + format?: (v: number) => string; + unit?: string; + lowTrueValue?: number; + highTrueValue?: number; + 'data-tutorial-low'?: string; + 'data-tutorial-high'?: string; + onCommit?: () => void; +} + +const toLog = (v: number): number => Math.log(v); +const fromLog = (p: number): number => Math.exp(p); + +function parseInput(e: Event): number | null { + const raw = parseFloat((e.target as HTMLInputElement).value); + return isNaN(raw) ? null : raw; +} + +export function DualRangeSlider(props: DualRangeSliderProps): JSX.Element { + const fmt = (v: number) => (props.format ? props.format(v) : v.toString()); + + const sliderMin = () => toLog(props.min); + const sliderMax = () => toLog(props.max); + const sliderRange = () => sliderMax() - sliderMin(); + + const toPct = (v: number) => ((toLog(v) - sliderMin()) / sliderRange()) * 100; + + const lowPct = () => toPct(props.lowValue()); + const highPct = () => toPct(props.highValue()); + + // --- Range input handlers (log-space) --- + + function handleLowRange(e: Event): void { + const logVal = parseInput(e); + if (logVal === null) return; + const val = fromLog(logVal); + const clamped = Math.max(props.lowMin, Math.min(props.lowMax, val)); + props.setLowValue(clamped); + } + + function handleHighRange(e: Event): void { + const logVal = parseInput(e); + if (logVal === null) return; + const val = fromLog(logVal); + const clamped = Math.max(props.highMin, Math.min(props.highMax, val)); + props.setHighValue(clamped); + } + + function handleCommit(): void { + props.onCommit?.(); + if (isTutorialActive()) notifyTutorialAction(); + } + + // --- Numeric input handlers (display-space, ms) --- + + function handleLowNumeric(e: Event): void { + const raw = parseInput(e); + if (raw === null) return; + // Numeric input shows ms, convert back to seconds + const seconds = raw / 1000; + const clamped = Math.max(props.lowMin, Math.min(props.lowMax, seconds)); + props.setLowValue(clamped); + } + + function handleHighNumeric(e: Event): void { + const raw = parseInput(e); + if (raw === null) return; + const seconds = raw / 1000; + const clamped = Math.max(props.highMin, Math.min(props.highMax, seconds)); + props.setHighValue(clamped); + } + + const STEP_LOG = 0.01; + + return ( +
+
+ {props.label} +
+
+
+ +
+
+ +
+
+
+
+ + + +
+ + +
+ +
+
+ ); +} diff --git a/apps/catune/src/components/controls/ParameterPanel.tsx b/apps/catune/src/components/controls/ParameterPanel.tsx index 28ce1f2..5e68f4b 100644 --- a/apps/catune/src/components/controls/ParameterPanel.tsx +++ b/apps/catune/src/components/controls/ParameterPanel.tsx @@ -16,6 +16,7 @@ import { notifyTutorialAction } from '@calab/tutorials'; import { isDemo, demoPreset, groundTruthVisible } from '../../lib/data-store.ts'; import { PARAM_RANGES } from '@calab/core'; import { ParameterSlider } from './ParameterSlider.tsx'; +import { DualRangeSlider } from './DualRangeSlider.tsx'; import '../../styles/controls.css'; export function ParameterPanel() { @@ -43,29 +44,26 @@ export function ParameterPanel() { return (
- (v * 1000).toFixed(1)} - unit="ms" - data-tutorial="slider-rise" - trueValue={trueRise()} - /> - (v * 1000).toFixed(1)} unit="ms" - data-tutorial="slider-decay" - trueValue={trueDecay()} + data-tutorial-low="slider-rise" + data-tutorial-high="slider-decay" + lowTrueValue={trueRise()} + highTrueValue={trueDecay()} /> Residuals should resemble noise. Near-zero residuals = overfitting. Visible transient shapes = underfitting.', + 'The red residual trace reveals model mismatches. Systematic positive bumps after peaks: decay too short. Negative dips before peaks: rise too long. Low-frequency waves: residual baseline drift (the automatic baseline subtraction handles most drift, but very slow trends may remain). Residuals should resemble noise. Near-zero residuals = overfitting. Visible transient shapes = underfitting.', side: 'bottom', }, // Step 3: Parameter coupling @@ -47,7 +47,7 @@ export const advancedTutorial: Tutorial = { element: '[data-tutorial="card-grid"]', title: 'Artifacts & Challenging Signals', description: - 'Common artifacts that affect fitting: Motion artifacts: sharp, symmetric deflections (not calcium-shaped). Photobleaching: slow downward baseline trend. Neuropil contamination: broad, slow signals mixed with sharp events. These cannot be fixed by parameter tuning \u2014 they require preprocessing.

' + + 'Common artifacts that affect fitting: Motion artifacts: sharp, symmetric deflections (not calcium-shaped). Photobleaching: slow downward baseline trend (largely handled by the automatic rolling-percentile baseline subtraction, but extreme cases may still affect results). Neuropil contamination: broad, slow signals mixed with sharp events. Motion artifacts cannot be fixed by parameter tuning \u2014 they require preprocessing. Photobleaching and neuropil contamination are largely handled by CaTune\u2019s automatic baseline subtraction.

' + 'When neurons fire rapidly, calcium events overlap. The model handles this via superposition, but dense firing can make individual events hard to resolve. Under big fluorescence events, try increasing decay time to reduce dense deconvolved activity \u2014 increase as much as possible without making the fit too poor.', side: 'bottom', popoverClass: 'driver-popover--wide', diff --git a/apps/catune/src/lib/tutorial/content/05-theory.ts b/apps/catune/src/lib/tutorial/content/05-theory.ts index 4a991f6..b872422 100644 --- a/apps/catune/src/lib/tutorial/content/05-theory.ts +++ b/apps/catune/src/lib/tutorial/content/05-theory.ts @@ -39,7 +39,8 @@ export const theoryTutorial: Tutorial = { '3. Slow baseline drift — out-of-focus neuropil, diffuse calcium dynamics, photobleaching

' + 'Mathematically:
' + 'F(t) = (s \u2217 k)(t) + b(t) + \u03B5(t)

' + - 'where s is neural activity, k is the calcium kernel, b is baseline drift, and \u03B5 is noise. Deconvolution aims to recover s from F.', + 'where s is neural activity, k is the calcium kernel, b is baseline drift, and \u03B5 is noise. Deconvolution aims to recover s from F.

' + + 'CaTune automatically handles b(t) through rolling-percentile baseline subtraction before deconvolution, so you generally don\u2019t need to preprocess for baseline drift.', }, // Step 3: The calcium kernel { @@ -104,12 +105,13 @@ export const theoryTutorial: Tutorial = { }, // Step 9: The role of noise filtering { - title: 'The Role of Noise Filtering', + title: 'Baseline & Noise Handling', description: - 'High-frequency noise creates spurious transients in the deconvolved trace. The Noise Filter applies a bandpass derived from your kernel parameters:

' + - '\u2022 The high-pass removes slow drift (baseline)
' + - '\u2022 The low-pass removes noise above what your calcium dynamics can produce

' + - "Filtering is conservative — it won't change transient shapes — but it significantly cleans up the deconvolution.", + 'CaTune handles baseline in three layers:

' + + '1. Rolling-percentile baseline subtraction \u2014 always active. Before every solve, a moving-window 20th percentile is subtracted from the trace, bringing the fluorescence floor to ~0. The window size adapts automatically to your decay time.
' + + '2. Bandpass filter (Noise Filter toggle) \u2014 the high-pass removes slow oscillations the baseline subtraction may miss; the low-pass removes noise above what your calcium dynamics can produce.
' + + '3. Solver baseline estimate \u2014 if neither of the above fully removes the baseline, the solver estimates a scalar baseline offset during iteration.

' + + 'Together, these handle photobleaching, neuropil contamination, and slow drift without manual preprocessing.', }, // Step 10: What Deconvolved Activity IS (and IS NOT) (merged: what it is + what it is not) { diff --git a/apps/catune/src/styles/controls.css b/apps/catune/src/styles/controls.css index dcc9c3a..5be12af 100644 --- a/apps/catune/src/styles/controls.css +++ b/apps/catune/src/styles/controls.css @@ -178,6 +178,123 @@ z-index: 1; } +/* ======================== + Dual Range Slider + ======================== */ +.dual-range { + margin-bottom: var(--space-md); +} + +.dual-range:last-child { + margin-bottom: 0; +} + +.dual-range__header { + margin-bottom: var(--space-xs); +} + +.dual-range__values { + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-xs); +} + +.dual-range__field { + display: flex; + align-items: baseline; + gap: 2px; + flex: 1; +} + +.dual-range__field-label { + font-size: 0.65rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.dual-range__field .param-slider__number { + width: 56px; +} + +.dual-range__track { + position: relative; + height: 4px; + background: var(--border-default); + border-radius: 2px; +} + +.dual-range__fill { + position: absolute; + top: 0; + height: 100%; + background: var(--accent); + border-radius: 2px; + pointer-events: none; +} + +.dual-range__input { + -webkit-appearance: none; + appearance: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: transparent; + pointer-events: none; + margin: 0; + outline: none; +} + +.dual-range__input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-primary); + box-shadow: 0 0 0 1px var(--border-default); + pointer-events: auto; + transition: box-shadow var(--transition-fast); +} + +.dual-range__input::-webkit-slider-thumb:hover { + box-shadow: 0 0 0 1px var(--accent); +} + +.dual-range__input::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + border: 2px solid var(--bg-primary); + box-shadow: 0 0 0 1px var(--border-default); + pointer-events: auto; + transition: box-shadow var(--transition-fast); +} + +.dual-range__input::-moz-range-thumb:hover { + box-shadow: 0 0 0 1px var(--accent); +} + +.dual-range__input::-moz-range-track { + background: transparent; + height: 4px; +} + +.dual-range__input:focus-visible::-webkit-slider-thumb { + box-shadow: 0 0 0 3px var(--accent-muted); +} + +.dual-range__input:focus-visible::-moz-range-thumb { + box-shadow: 0 0 0 3px var(--accent-muted); +} + /* --- Filter Toggle --- */ .param-panel__toggle-group { padding-top: var(--space-md); diff --git a/packages/ui/src/styles/layout.css b/packages/ui/src/styles/layout.css index 6b64d94..5b1b1ad 100644 --- a/packages/ui/src/styles/layout.css +++ b/packages/ui/src/styles/layout.css @@ -264,6 +264,7 @@ html.dashboard-mode #root { --grid-cols: 2; display: grid; grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr)); + align-content: start; gap: var(--panel-gap); overflow-y: auto; overflow-x: hidden;