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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Math Visualizer is an interactive collection of mathematical visualizations buil
> a cell to run/pause/restart it, or use the ▶ on a row/column header to run a whole
> group; the toolbar runs or pauses everything, generates new data, and adjusts speed
> and array size (10–300, default 50). Share a size with `#/sorting?n=<size>`.
> Click 🔊 for sound: every running panel hums the value it just touched (pitch
> rises with the value, writes ring brighter than compares) and chimes when it
> finishes; the slider beside it sets the volume.
>
> The midpoint-on-circle and ColorCycle rules remain in the codebase as alternative
> examples. See [`docs/superpowers/specs/`](docs/superpowers/specs/) for designs and
Expand Down
63 changes: 61 additions & 2 deletions crates/viz-core/src/rules/sorting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ impl Lane {
.and_then(|i| self.ops.get(i).copied())
}

/// What the last op touched, for the lab's sonification: the kind of op
/// (`"compare"` or `"write"` — a swap is a write) and the value it
/// landed on — the larger of a compared/swapped pair, or the value
/// written. `None` before the first tick.
pub fn last_touch(&self) -> Option<(&'static str, u16)> {
let at = |i: u16| self.values.get(usize::from(i)).copied().unwrap_or(0);
self.last_op().map(|op| match op {
Op::Compare(i, j) => ("compare", at(i).max(at(j))),
Op::Swap(i, j) => ("write", at(i).max(at(j))),
Op::Write(_, v) => ("write", v),
})
}

/// Rewind to the initial array with the counters zeroed. Leaves
/// `running` alone — the callers decide that.
pub fn reset(&mut self) {
Expand Down Expand Up @@ -277,13 +290,19 @@ impl Rule for SortingRace {
}

/// `{ rows, cols, tick, all_done, lanes: [{algorithm, dataset, compares,
/// writes, cursor, total, running, done}, …] }`. The lab reads this every
/// frame, so the op traces deliberately stay out of it.
/// writes, cursor, total, running, done, size, last_kind, last_value}, …] }`.
/// The lab reads this every frame, so the op traces deliberately stay out
/// of it; `last_kind`/`last_value` (null before the first tick) describe
/// only the most recent op, enough for the lab to voice each lane.
fn summary(&self, state: &Self::State) -> serde_json::Value {
let lanes: Vec<serde_json::Value> = state
.lanes
.iter()
.map(|l| {
let (last_kind, last_value) = match l.last_touch() {
Some((kind, value)) => (Some(kind), Some(value)),
None => (None, None),
};
serde_json::json!({
"algorithm": l.algorithm,
"dataset": l.dataset,
Expand All @@ -293,6 +312,9 @@ impl Rule for SortingRace {
"total": l.ops.len(),
"running": l.running,
"done": l.done(),
"size": l.values.len(),
"last_kind": last_kind,
"last_value": last_value,
})
})
.collect();
Expand Down Expand Up @@ -804,6 +826,9 @@ mod tests {
assert_eq!(lanes[0]["writes"], 0);
assert_eq!(lanes[0]["running"], json!(false));
assert_eq!(lanes[0]["done"], json!(false));
assert_eq!(lanes[0]["size"], st.lanes[0].values.len());
assert_eq!(lanes[0]["last_kind"], json!(null), "no op yet");
assert_eq!(lanes[0]["last_value"], json!(null), "no op yet");
assert!(lanes[0].get("ops").is_none(), "traces stay out of summary");

run_all(&rule, &mut st, &c);
Expand All @@ -813,6 +838,40 @@ mod tests {
assert!(s["lanes"][0]["compares"].as_u64().unwrap() > 0);
assert_eq!(s["lanes"][0]["running"], json!(false));
assert_eq!(s["lanes"][0]["done"], json!(true));
assert!(
["compare", "write"].contains(&s["lanes"][0]["last_kind"].as_str().unwrap()),
"a finished lane still reports its final op"
);
assert!(s["lanes"][0]["last_value"].is_u64());
}

#[test]
fn last_touch_reports_the_value_the_last_op_landed_on() {
let rule = SortingRace;
let c = cfg();
let mut st = rule.init(&c, 6);
assert!(st.lanes[0].last_touch().is_none(), "nothing applied yet");

// Walk lane 0 one op at a time and check every step against its trace.
st.lanes[0].running = true;
let total = st.lanes[0].ops.len();
for k in 1..=total {
rule.advance_to(&mut st, &c, 6, k as u32);
let lane = &st.lanes[0];
let op = lane.ops[k - 1];
let expected = match op {
Op::Compare(i, j) => (
"compare",
lane.values[i as usize].max(lane.values[j as usize]),
),
Op::Swap(i, j) => (
"write",
lane.values[i as usize].max(lane.values[j as usize]),
),
Op::Write(_, v) => ("write", v),
};
assert_eq!(lane.last_touch(), Some(expected), "op {k}: {op:?}");
}
}

#[test]
Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ npm test
npm run coverage
```

The Rust suite currently contains 177 passing tests. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 24 passing tests and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README.
The Rust suite currently contains 178 passing tests. It exercises playback reduction, configuration schemas, deterministic seeded rules, geometric invariants, 2D and 3D camera behavior, serialization, type-erased dispatch, input handling, and visualization state. The separate browser-targeted WASM smoke suite currently contains 24 passing tests and is run with `wasm-pack test --chrome --headless crates/viz-core` as documented in the root README.

As measured on September 15, 2026, the 105 web tests (across 13 test files) cover 84.42% of statements, 84.98% of branches, 76.11% of functions, and 84.42% of lines. They exercise the single-flight WASM loader and representative Svelte application mount and interaction paths across the Fourier and sorting labs.
As measured on September 23, 2026, the 130 web tests (across 14 test files) cover 86.75% of statements, 86.45% of branches, 79.48% of functions, and 86.75% of lines. They exercise the single-flight WASM loader, the sorting lab's summary parsing and sonification planner (against a stub AudioContext), and representative Svelte application mount and interaction paths across the Fourier and sorting labs.

Instrumentation-based Rust source coverage is currently 0% because the Cargo test gate does not configure a Rust coverage reporter. The web report's principal gaps are the Svelte application's rendering and animation paths and the browser entry point. Real WebGL behavior remains outside the DOM-based unit-test environment.
5 changes: 2 additions & 3 deletions web/src/lib/components/LabShell.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,9 @@
border-bottom: 1px solid var(--border);
padding: 0.5rem 1rem;
display: flex;
flex-wrap: wrap; /* a lab's extra controls wrap rather than widen the page */
align-items: center;
gap: 0.75rem;
gap: 0.5rem 0.75rem;
font-size: 0.9rem;
}
.playback-bar button {
Expand Down Expand Up @@ -564,9 +565,7 @@
/* Let the playback bar wrap to multiple rows; align center so it
balances vertically when items wrap. */
.playback-bar {
flex-wrap: wrap;
justify-content: center;
row-gap: 0.5rem;
}
.speed {
margin-left: 0; /* no more push-to-right with wrapping */
Expand Down
29 changes: 29 additions & 0 deletions web/src/lib/components/__tests__/SortingLab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,35 @@ describe('SortingLab.svelte', () => {
expect(input.value).toBe('300');
});

it('starts with sound off, the volume slider disabled, and no AudioContext needed', async () => {
const { getByLabelText } = await renderLab();
const mute = getByLabelText('Sound') as HTMLButtonElement;
expect(mute.getAttribute('aria-pressed')).toBe('false');
expect(mute.textContent).toBe('🔇');
expect(mute.title).toBe('Turn sound on');
expect((getByLabelText('Volume') as HTMLInputElement).disabled).toBe(true);
});

it('the speaker button toggles sound on and off and enables the volume slider', async () => {
const { getByLabelText } = await renderLab();
const mute = getByLabelText('Sound') as HTMLButtonElement;
await fireEvent.click(mute);
expect(mute.getAttribute('aria-pressed')).toBe('true');
expect(mute.textContent).toBe('🔊');
expect(mute.title).toBe('Turn sound off');
const volume = getByLabelText('Volume') as HTMLInputElement;
expect(volume.disabled).toBe(false);
expect(volume.value).toBe('0.5');

await fireEvent.input(volume, { target: { value: '0.8' } });
expect(volume.value).toBe('0.8');

await fireEvent.click(mute);
expect(mute.getAttribute('aria-pressed')).toBe('false');
expect(mute.textContent).toBe('🔇');
expect(volume.disabled).toBe(true);
});

it('rewrites an out-of-range ?n= link to the clamped value', async () => {
const { getByLabelText } = await renderLab('n=5');
expect((getByLabelText('Array size') as HTMLInputElement).value).toBe('10');
Expand Down
47 changes: 44 additions & 3 deletions web/src/lib/components/labs/SortingLab.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { cellRects } from '../../sorting/layout';
import { readSummary, laneIndex, ALGORITHMS, DATASETS, type SortingSummary } from '../../sorting/summary';
import { allLanes, rowLanes, colLanes, laneState, shouldRun, LANE_GLYPH } from '../../sorting/lanes';
import { SortingAudio } from '../../sorting/audio';
import { route, replaceQuery } from '../../router.svelte';
import { buildQuery } from '../../router';

Expand All @@ -33,6 +34,10 @@

let size = $state(clamp(paramsOf(route.query).size ?? DEFAULT_SIZE));
let speed = $state(DEFAULT_SPEED);
/** Sonification: one voice per lane, off until the speaker button is clicked. */
const audio = new SortingAudio();
let muted = $state(audio.muted);
let volume = $state(audio.volume);
/** The engine's lane grid, re-read every frame (null until the engine is up). */
let summary = $state<SortingSummary | null>(null);
/** Query we last wrote (or consumed), so our own replaceQuery() doesn't re-trigger a push. */
Expand Down Expand Up @@ -62,12 +67,17 @@
});
});

// The shell replaces `snapshot` every frame; re-read the lane grid off that clock.
// The shell replaces `snapshot` every frame; re-read the lane grid off that
// clock and voice whatever moved.
$effect(() => {
const a = api;
if (!a) return;
void a.snapshot;
summary = readSummary(a.readSummary());
// Hand the audio the raw value, not the `summary` state: reading state
// this effect just wrote would make it re-run on its own write.
const next = readSummary(a.readSummary());
summary = next;
audio.update(next);
});

/** Measure the cell buttons and hand the viz their device-pixel rects. */
Expand Down Expand Up @@ -118,6 +128,7 @@
ro?.disconnect();
ro = null;
window.removeEventListener('resize', pushCells);
audio.destroy();
});

const laneAt = (i: number) => summary?.lanes[i];
Expand Down Expand Up @@ -171,6 +182,17 @@
speed = Number((e.target as HTMLInputElement).value);
api?.dispatch(cmd.setSpeed(speed));
}

/** The click is the user gesture that lets the browser start audio. */
function toggleMute() {
audio.setMuted(!muted);
muted = audio.muted;
}

function onVolume(e: Event) {
audio.setVolume(Number((e.target as HTMLInputElement).value));
volume = audio.volume;
}
</script>

<LabShell labId="sorting" playback={false} zoom={false} {onReady}>
Expand Down Expand Up @@ -218,6 +240,16 @@
<input type="range" min="1" max={MAX_SPEED} step="1" value={speed} oninput={onSpeed} aria-label="Speed" />
<span class="value">{speed} ops/s</span>
</label>
<div class="sound">
<button
class="mute"
onclick={toggleMute}
aria-pressed={!muted}
aria-label="Sound"
title={muted ? 'Turn sound on' : 'Turn sound off'}
>{muted ? '🔇' : '🔊'}</button>
<input type="range" min="0" max="1" step="0.01" value={volume} oninput={onVolume} disabled={muted} aria-label="Volume" />
</div>
{/snippet}

{#snippet info()}
Expand Down Expand Up @@ -263,6 +295,11 @@
<em>Size</em> changes the array length (10–300) and is shareable: it
lands in the link as <em>?n=</em>.
</p>
<p class="tip">
<em>🔊</em> turns on sound: every running panel hums the value it just
touched — low for small, high for large, brighter on a write than on a
compare — and rings a chime when it finishes. The slider sets the volume.
</p>
{/snippet}
</LabShell>

Expand Down Expand Up @@ -332,9 +369,12 @@
width: 5rem;
font-variant-numeric: tabular-nums;
}
.size, .speed { display: flex; align-items: center; gap: 0.5rem; color: #bbb; }
.size, .speed, .sound { display: flex; align-items: center; gap: 0.5rem; color: #bbb; }
.speed { margin-left: auto; }
.speed .value { font-variant-numeric: tabular-nums; width: 5rem; text-align: right; }
.sound input { width: 6rem; }
.sound input:disabled { opacity: 0.4; }
.mute[aria-pressed="true"] { border-color: #6f8fc9; }

/* Legend swatches — the shell supplies the base dot; `span` outranks it. */
span.swatch.bar { background: #8c99bf; }
Expand All @@ -348,5 +388,6 @@
.hdr.row { font-size: 0.62rem; }
.hdr small, .badge { display: none; }
.speed { margin-left: 0; }
.sound input { width: 5rem; }
}
</style>
Loading
Loading