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
180 changes: 180 additions & 0 deletions apps/catune/src/components/controls/DualRangeSlider.tsx
Original file line number Diff line number Diff line change
@@ -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<number>;
highValue: Accessor<number>;
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 (
<div class="dual-range">
<div class="dual-range__header">
<span class="param-slider__label">{props.label}</span>
</div>
<div class="dual-range__values">
<div data-tutorial={props['data-tutorial-low']}>
<label class="dual-range__field">
<span class="dual-range__field-label">{props.lowLabel}</span>
<input
type="number"
class="param-slider__number"
value={fmt(props.lowValue())}
min={props.format ? props.format(props.lowMin) : props.lowMin}
max={props.format ? props.format(props.lowMax) : props.lowMax}
step={1}
onInput={handleLowNumeric}
onChange={handleCommit}
/>
<span class="param-slider__unit">{props.unit ?? ''}</span>
</label>
</div>
<div data-tutorial={props['data-tutorial-high']}>
<label class="dual-range__field">
<span class="dual-range__field-label">{props.highLabel}</span>
<input
type="number"
class="param-slider__number"
value={fmt(props.highValue())}
min={props.format ? props.format(props.highMin) : props.highMin}
max={props.format ? props.format(props.highMax) : props.highMax}
step={1}
onInput={handleHighNumeric}
onChange={handleCommit}
/>
<span class="param-slider__unit">{props.unit ?? ''}</span>
</label>
</div>
</div>
<div class="dual-range__track">
<div
class="dual-range__fill"
style={{ left: `${lowPct()}%`, width: `${highPct() - lowPct()}%` }}
/>
<input
type="range"
class="dual-range__input dual-range__input--low"
min={sliderMin()}
max={sliderMax()}
step={STEP_LOG}
value={toLog(props.lowValue())}
onInput={handleLowRange}
onChange={handleCommit}
/>
<input
type="range"
class="dual-range__input dual-range__input--high"
min={sliderMin()}
max={sliderMax()}
step={STEP_LOG}
value={toLog(props.highValue())}
onInput={handleHighRange}
onChange={handleCommit}
/>
<Show when={props.lowTrueValue !== undefined}>
<div
class="param-slider__true-marker"
style={{ left: `${toPct(props.lowTrueValue!)}%` }}
title={`True rise: ${fmt(props.lowTrueValue!)} ${props.unit ?? ''}`}
/>
</Show>
<Show when={props.highTrueValue !== undefined}>
<div
class="param-slider__true-marker"
style={{ left: `${toPct(props.highTrueValue!)}%` }}
title={`True decay: ${fmt(props.highTrueValue!)} ${props.unit ?? ''}`}
/>
</Show>
</div>
</div>
);
}
36 changes: 17 additions & 19 deletions apps/catune/src/components/controls/ParameterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -43,29 +44,26 @@ export function ParameterPanel() {
return (
<div class="param-panel" data-tutorial="param-panel">
<div class="param-panel__sliders">
<ParameterSlider
label="Rise Time"
value={tauRise}
setValue={clampedSetTauRise}
<DualRangeSlider
label="Time Constants"
lowLabel="Rise"
highLabel="Decay"
lowValue={tauRise}
highValue={tauDecay}
setLowValue={clampedSetTauRise}
setHighValue={clampedSetTauDecay}
min={PARAM_RANGES.tauRise.min}
max={PARAM_RANGES.tauRise.max}
step={PARAM_RANGES.tauRise.step}
format={(v) => (v * 1000).toFixed(1)}
unit="ms"
data-tutorial="slider-rise"
trueValue={trueRise()}
/>
<ParameterSlider
label="Decay Time"
value={tauDecay}
setValue={clampedSetTauDecay}
min={PARAM_RANGES.tauDecay.min}
max={PARAM_RANGES.tauDecay.max}
step={PARAM_RANGES.tauDecay.step}
lowMin={PARAM_RANGES.tauRise.min}
lowMax={PARAM_RANGES.tauRise.max}
highMin={PARAM_RANGES.tauDecay.min}
highMax={PARAM_RANGES.tauDecay.max}
format={(v) => (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()}
/>
<ParameterSlider
label="Sparsity"
Expand Down
4 changes: 2 additions & 2 deletions apps/catune/src/lib/tutorial/content/03-advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const advancedTutorial: Tutorial = {
element: '[data-tutorial="card-grid"]',
title: 'Residual Pattern Analysis',
description:
'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: baseline drift (not a parameter issue). <b>Residuals should resemble noise. Near-zero residuals = overfitting. Visible transient shapes = underfitting.</b>',
'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). <b>Residuals should resemble noise. Near-zero residuals = overfitting. Visible transient shapes = underfitting.</b>',
side: 'bottom',
},
// Step 3: Parameter coupling
Expand All @@ -47,7 +47,7 @@ export const advancedTutorial: Tutorial = {
element: '[data-tutorial="card-grid"]',
title: 'Artifacts & Challenging Signals',
description:
'Common artifacts that affect fitting: <b>Motion artifacts:</b> sharp, symmetric deflections (not calcium-shaped). <b>Photobleaching:</b> slow downward baseline trend. <b>Neuropil contamination:</b> broad, slow signals mixed with sharp events. These cannot be fixed by parameter tuning \u2014 they require preprocessing.<br><br>' +
'Common artifacts that affect fitting: <b>Motion artifacts:</b> sharp, symmetric deflections (not calcium-shaped). <b>Photobleaching:</b> slow downward baseline trend (largely handled by the automatic rolling-percentile baseline subtraction, but extreme cases may still affect results). <b>Neuropil contamination:</b> 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.<br><br>' +
'When neurons fire rapidly, calcium events overlap. The model handles this via superposition, but dense firing can make individual events hard to resolve. <b>Under big fluorescence events, try increasing decay time to reduce dense deconvolved activity</b> \u2014 increase as much as possible without making the fit too poor.',
side: 'bottom',
popoverClass: 'driver-popover--wide',
Expand Down
14 changes: 8 additions & 6 deletions apps/catune/src/lib/tutorial/content/05-theory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export const theoryTutorial: Tutorial = {
'<b>3. Slow baseline drift</b> — out-of-focus neuropil, diffuse calcium dynamics, photobleaching<br><br>' +
'Mathematically:<br>' +
'<b>F(t) = (s \u2217 k)(t) + b(t) + \u03B5(t)</b><br><br>' +
'where <b>s</b> is neural activity, <b>k</b> is the calcium kernel, <b>b</b> is baseline drift, and <b>\u03B5</b> is noise. Deconvolution aims to recover <b>s</b> from <b>F</b>.',
'where <b>s</b> is neural activity, <b>k</b> is the calcium kernel, <b>b</b> is baseline drift, and <b>\u03B5</b> is noise. Deconvolution aims to recover <b>s</b> from <b>F</b>.<br><br>' +
'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
{
Expand Down Expand Up @@ -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 <b>spurious transients</b> in the deconvolved trace. The <b>Noise Filter</b> applies a bandpass derived from your kernel parameters:<br><br>' +
'\u2022 The <b>high-pass</b> removes slow drift (baseline)<br>' +
'\u2022 The <b>low-pass</b> removes noise above what your calcium dynamics can produce<br><br>' +
"Filtering is conservative — it won't change transient shapes — but it significantly cleans up the deconvolution.",
'CaTune handles baseline in three layers:<br><br>' +
'<b>1. Rolling-percentile baseline subtraction</b> \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.<br>' +
'<b>2. Bandpass filter</b> (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.<br>' +
'<b>3. Solver baseline estimate</b> \u2014 if neither of the above fully removes the baseline, the solver estimates a scalar baseline offset during iteration.<br><br>' +
'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)
{
Expand Down
Loading
Loading