From 685ac53fc9bada1e91f5ffd497676e1b5ec1a0f0 Mon Sep 17 00:00:00 2001 From: skratchdot Date: Tue, 8 Sep 2026 17:18:59 -0400 Subject: [PATCH] Render visualization SVGs with React and Tailwind Replace imperative D3 bar and trajectory renderers with React SVG components subscribed to player snapshots. Keep audio clocks outside React, preserve pointer capture editing and cancellation, and memoize trajectory geometry across playback updates. Render the envelope declaratively, remove the visualization stylesheet and d3-selection dependency, and retain D3 only for pure calculation helpers. Rename envelope utilities, type trajectory geometry and visualization modes, and add the required D3 type declarations. Update documentation and regression coverage for duplicate-value trajectories, finite geometry, hover styling, and cancelled pointer edits. Verified the full check pipeline with 362 unit tests and 38 browser tests. --- README.md | 2 +- docs/architecture.md | 19 +- docs/audio-dependencies.md | 2 +- docs/development.md | 4 +- docs/migration-handoff.md | 6 +- docs/modernization.md | 4 +- package.json | 3 +- pnpm-lock.yaml | 32 +++- src/audio/envelope-diagram.ts | 42 ----- src/audio/envelope.ts | 19 ++ .../playground/sorting-playground.tsx | 11 +- .../playground/waveform-controls.tsx | 18 +- .../visualizations/envelope-chart.tsx | 37 ++++ .../visualizations/player-chart.tsx | 127 +++++++++++++ src/controllers/create-player.mjs | 61 ++----- src/controllers/create-playground.mjs | 28 +-- src/styles/globals.css | 1 - src/styles/visualizations.css | 53 ------ src/visualizations/create-trajectories.ts | 41 +++++ src/visualizations/renderers/bar.mjs | 170 ------------------ src/visualizations/renderers/flat.mjs | 120 ------------- src/visualizations/visualization-registry.mjs | 4 - src/visualizations/visualization-types.ts | 3 + test/browser/react-charts.spec.mjs | 27 +++ test/browser/site.spec.mjs | 2 +- test/trajectories.test.mjs | 31 ++++ test/ui.test.mjs | 2 +- test/waveforms.test.mjs | 2 +- 28 files changed, 367 insertions(+), 504 deletions(-) delete mode 100644 src/audio/envelope-diagram.ts create mode 100644 src/audio/envelope.ts create mode 100644 src/components/visualizations/envelope-chart.tsx create mode 100644 src/components/visualizations/player-chart.tsx delete mode 100644 src/styles/visualizations.css create mode 100644 src/visualizations/create-trajectories.ts delete mode 100644 src/visualizations/renderers/bar.mjs delete mode 100644 src/visualizations/renderers/flat.mjs delete mode 100644 src/visualizations/visualization-registry.mjs create mode 100644 src/visualizations/visualization-types.ts create mode 100644 test/browser/react-charts.spec.mjs create mode 100644 test/trajectories.test.mjs diff --git a/README.md b/README.md index c4228c6..1ff46af 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ See [development documentation](docs/development.md) for browser tests and deplo ## Built With - [timbre.js](https://mohayonao.github.io/timbre.js/) — Synthesizes tones and plays soundfont instruments for sorting playback. -- [D3](https://d3js.org/) — Draws SVG bars, markers, and paths using modular selection, scale, color, shape, and array utilities. +- [D3](https://d3js.org/) — Provides scale, color, path geometry, and array utilities; React renders the SVG charts. - [Jotai](https://jotai.org/) — Stores selected audio and sorting settings independently of the UI. - [React](https://react.dev/) — Renders settings, playback controls, and editing/export dialogs. - [TanStack Start](https://tanstack.com/start) — Prerenders the React pages for static hosting. diff --git a/docs/architecture.md b/docs/architecture.md index 0e6f393..2470704 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,9 +26,9 @@ TypeScript is introduced incrementally alongside `.mjs` modules. Vite handles bundling; `pnpm run typecheck` checks `.ts` and `.tsx` application modules separately. Built-in algorithm source and the Ace editor remain JavaScript for now. -Generator implementations live in `generators/patterns/`; visualization -implementations live in `visualizations/renderers/`. Their named registries sit -one level above, separate from the implementations they register. +Generator implementations live in `generators/patterns/`. React charts live in +`components/visualizations/`; pure trajectory geometry and visualization names live +in `visualizations/`. Neither the registry nor geometry code touches the DOM. ## UI @@ -44,7 +44,7 @@ without JavaScript. `src/client.tsx` hydrates the document. vanilla Jotai store. React owns settings, tabs, transport controls, counters, sliders, and dialogs. Shared buttons, links, and option controls use Tailwind classes. `styles/globals.css` holds the shadcn theme and document defaults; -`styles/visualizations.css` styles D3-owned SVG children. There is no `site.css`. +Chart components use Tailwind fill/stroke classes. There is no separate visualization stylesheet. The light theme uses sky accents and neutral surfaces. Playground layout uses one threshold (`lg`, 1024px): stacked below it, side-by-side above it. Chart heights are fluid, bounded with `clamp()`, without height-specific media queries. @@ -55,10 +55,13 @@ React reads its playback snapshots through `useSyncExternalStore`. Settings and custom algorithms are read directly from Jotai; there is no mirrored settings cache. Audio clocks and nodes remain outside React and Jotai. -[`create-player.mjs`](../src/controllers/create-player.mjs) owns -the contents of its D3 SVG and delegates transport and synthesis to +[`create-player.mjs`](../src/controllers/create-player.mjs) publishes recorded frames, +position, and renderer selection and delegates transport and synthesis to `audio/create-transport.ts` and `audio/create-timbre-audio.mjs`. -React renders the surrounding controls and an empty SVG host, never chart children. +React subscribes to player snapshots and renders all SVG children, including markers +and the envelope diagram. Trajectory geometry is memoized by frame data; playback +only changes active colors. D3 remains for pure array, scale, color, and path helpers, +not selections or DOM mutations. The waveform preview canvas has the same explicit imperative ownership. Pointer capture supports dragging input values across data updates. @@ -80,7 +83,7 @@ Cached-page suspension disconnects runtime effects, pauses audio, cancels worker and pending resumes, and closes dialogs. Returning reconnects effects without automatically playing. React continues to represent the same Jotai store. Non-cached exits and Home effect cleanup unmount the playground, dispose owned resources, and -release native pointer listeners. Fresh runtime instances can reuse the store. +release chart pointer state. Fresh runtime instances can reuse the store. The shared AudioContext stays library-owned. All third-party JavaScript uses package imports. `audio/timbre.mjs` only re-exports the diff --git a/docs/audio-dependencies.md b/docs/audio-dependencies.md index 5a5098f..406df80 100644 --- a/docs/audio-dependencies.md +++ b/docs/audio-dependencies.md @@ -11,7 +11,7 @@ not start playback. `audio/create-timbre-audio.mjs` adapts the existing engine: interval creation, ADSHR and oscillator/pluck nodes, MIDI note triggers, gain, preview plotting, and node disposal. It accepts the engine and settings getters as dependencies. -The player factory now connects those modules to sliders, buttons, and D3. +The player factory connects those modules to React controls and chart snapshots. `audio/create-soundfont.ts` now owns a controller-scoped sample cache shared by both players. `audio/create-timbre-soundfont.mjs` decodes fetched MP3s with the existing AudioContext and feeds stereo buffers into the existing Timbre mixer. diff --git a/docs/development.md b/docs/development.md index 1b8dd60..1c06f7a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,8 +37,8 @@ to `dist/audio-sort/` without a `public/` URL prefix. Use `/img/...` in source C adjusts these URLs for the deployment path. Application modules and CSS stay in `src/`. CSS is bundled and minified by Vite. `src/styles/globals.css` contains Tailwind and -shadcn theme tokens; component classes own layout and control styling. D3-specific -styles live in `src/styles/visualizations.css`. +shadcn theme tokens; component classes own layout, controls, and SVG chart styling. +React chart components live in `src/components/visualizations/`. Add primitives with `pnpm dlx shadcn@latest add `. `components.json` selects Base UI, the Nova preset, neutral base colors, and Lucide icons. Keep shared diff --git a/docs/migration-handoff.md b/docs/migration-handoff.md index 74a209e..7c7cc38 100644 --- a/docs/migration-handoff.md +++ b/docs/migration-handoff.md @@ -16,8 +16,8 @@ PR #47 migrated the whole playground; PR #48 polished icons, hover states, and t - The playground uses a reusable vanilla Jotai store. - `components/playground/sorting-playground.tsx` assembles settings, playback controls, and dialogs. - `controllers/create-playground.mjs` owns worker/data coordination and audio subscriptions. -- `controllers/create-player.mjs` bridges the existing transport/audio modules and D3. -- React owns controls; D3 owns SVG children; audio draws the waveform canvas. +- `controllers/create-player.mjs` bridges transport/audio modules and publishes chart snapshots. +- React owns controls and SVG children; audio draws the waveform canvas. - Settings/algorithm overrides stay in Jotai. Playback snapshots use `useSyncExternalStore`. - Native ranges replace plugin sliders; native dialogs handle editing and MIDI export. - Lazy Ace loading, invalid-source errors, focus restoration, cached-page suspension, @@ -67,7 +67,7 @@ system font, and maps the light theme to Tailwind sky/neutral colors. - `src/components/ui/`: shared primitives; `components/layout/`: header/footer. - `src/components/playground/` and `src/components/dialogs/`: controls and dialogs; `src/controllers/`: runtime coordination. -- `src/styles/globals.css`: theme/document defaults; `visualizations.css`: D3 state styles. +- `src/styles/globals.css`: theme/document defaults; chart styling lives in React components. - `site.css` and the `tw:` prefix are removed. Buttons and links own their Tailwind classes. - Two playground layouts use one 1024px threshold; chart heights are fluid. - Tab panels stay mounted for Ace/canvas lifetime. Slider thumbs use center alignment diff --git a/docs/modernization.md b/docs/modernization.md index 9e61512..734b152 100644 --- a/docs/modernization.md +++ b/docs/modernization.md @@ -106,7 +106,9 @@ editor runtime. Client navigation unmounts the playground when leaving Home. The current UI refinement adds shadcn Base UI (Nova preset) before phase 8. Shared primitives live in `components/ui/`; playground components, dialogs, and runtime modules live in `components/playground/`, `components/dialogs/`, and `controllers/`, respectively. Tailwind component classes replace -`site.css`, with sky/neutral theme tokens and a small D3 stylesheet. One `lg` +`site.css`, with sky/neutral theme tokens. React now renders chart SVG children +and the envelope diagram with Tailwind classes; D3 only supplies pure utilities. +The intermediate D3 stylesheet and DOM renderer implementations are removed. One `lg` threshold separates stacked and side-by-side layouts; chart heights use `clamp()`. Review the larger controls, dialog behavior, and laptop/mobile layouts before merging. diff --git a/package.json b/package.json index a673555..9b5308a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "d3-array": "^3.2.4", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", - "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", "file-saver": "^2.0.5", "jotai": "^2.20.3", @@ -59,6 +58,8 @@ "devDependencies": { "@playwright/test": "^1.63.0", "@tailwindcss/vite": "^4.3.3", + "@types/d3-color": "^3.1.3", + "@types/d3-shape": "^3.2.0", "@types/node": "^26.4.1", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d5aa13..7378339 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,9 +140,6 @@ importers: d3-scale: specifier: ^4.0.2 version: 4.0.2 - d3-selection: - specifier: ^3.0.0 - version: 3.0.0 d3-shape: specifier: ^3.2.0 version: 3.2.0 @@ -180,6 +177,12 @@ importers: '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)(yaml@2.9.0)) + '@types/d3-color': + specifier: ^3.1.3 + version: 3.1.3 + '@types/d3-shape': + specifier: ^3.2.0 + version: 3.2.0 '@types/node': specifier: ^26.4.1 version: 26.4.1 @@ -1293,6 +1296,15 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1758,10 +1770,6 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} - d3-selection@3.0.0: - resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} - engines: {node: '>=12'} - d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -4310,6 +4318,14 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-color@3.1.3': {} + + '@types/d3-path@3.1.1': {} + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.9': {} @@ -4715,8 +4731,6 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 - d3-selection@3.0.0: {} - d3-shape@3.2.0: dependencies: d3-path: 3.1.0 diff --git a/src/audio/envelope-diagram.ts b/src/audio/envelope-diagram.ts deleted file mode 100644 index be4acd6..0000000 --- a/src/audio/envelope-diagram.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Envelope, EnvelopeKey } from "../state/envelope.ts"; - -export function formatEnvelopeValue(key: EnvelopeKey, value: number): string { - if (key === "s") return `${Math.round(value * 100)}%`; - return value < 1000 ? `${value} ms` : `${Number((value / 1000).toFixed(3))} s`; -} - -// Verified against timbre@14.11.25/timbre.dev.js, register("adshr"): -// [0, [1,a], [s,d], [s,h], [0,r]]. Hold is at sustain level AFTER decay, -// unlike an AHDSR peak hold before decay. -export function getEnvelopePoints(e: Envelope) { - return [ - [0, 0], - [e.a, 1], - [e.a + e.d, e.s], - [e.a + e.d + e.h, e.s], - [e.a + e.d + e.h + e.r, 0], - ] as const; -} - -export function drawEnvelopeDiagram(svg: SVGElement, e: Envelope) { - const points = getEnvelopePoints(e); - const total = e.a + e.d + e.h + e.r; - const coords = points.map(([time, level]) => `${40 + (time / total) * 340},${155 - level * 125}`); - const sustainY = 155 - e.s * 125; - svg.setAttribute( - "aria-label", - `Amplitude envelope: attack ${formatEnvelopeValue("a", e.a)}, decay ${formatEnvelopeValue("d", e.d)}, sustain ${formatEnvelopeValue("s", e.s)} for ${formatEnvelopeValue("h", e.h)}, release ${formatEnvelopeValue("r", e.r)}.`, - ); - svg.innerHTML = ` - - - - 100% - 0% - - 0 - ${formatEnvelopeValue("r", total)} - Level over time · sustain ${formatEnvelopeValue("s", e.s)} - ${(["a", "d", "h", "r"] as const).map((key, index) => `${{ a: "Attack", d: "Decay", h: "Hold", r: "Release" }[key]}${formatEnvelopeValue(key, e[key])}`).join("")} - `; -} diff --git a/src/audio/envelope.ts b/src/audio/envelope.ts new file mode 100644 index 0000000..fcb1899 --- /dev/null +++ b/src/audio/envelope.ts @@ -0,0 +1,19 @@ +import type { Envelope, EnvelopeKey } from "../state/envelope.ts"; + +export function formatEnvelopeValue(key: EnvelopeKey, value: number): string { + if (key === "s") return `${Math.round(value * 100)}%`; + return value < 1000 ? `${value} ms` : `${Number((value / 1000).toFixed(3))} s`; +} + +// Verified against timbre@14.11.25/timbre.dev.js, register("adshr"): +// [0, [1,a], [s,d], [s,h], [0,r]]. Hold is at sustain level AFTER decay, +// unlike an AHDSR peak hold before decay. +export function getEnvelopePoints(e: Envelope) { + return [ + [0, 0], + [e.a, 1], + [e.a + e.d, e.s], + [e.a + e.d + e.h, e.s], + [e.a + e.d + e.h + e.r, 0], + ] as const; +} diff --git a/src/components/playground/sorting-playground.tsx b/src/components/playground/sorting-playground.tsx index b36bebc..d7ddd4a 100644 --- a/src/components/playground/sorting-playground.tsx +++ b/src/components/playground/sorting-playground.tsx @@ -1,7 +1,8 @@ +import { PlayerChart } from "../visualizations/player-chart"; import { PlayerSection } from "./player-section"; import { SortSidebar } from "./sort-sidebar"; import { Button } from "@/components/ui/button"; -import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react"; import { Settings } from "./settings-controls.tsx"; import { Transport, Scrubber } from "./playback-controls.tsx"; import { AlgorithmDialog } from "../dialogs/algorithm-dialog.tsx"; @@ -11,8 +12,6 @@ type Modal = "sort" | "add-algorithm" | "midi-export" | null; export function SortingPlayground({ runtime }: Props) { const error = useSyncExternalStore(runtime.subscribe, () => runtime.getSnapshot().error); const suspended = useSyncExternalStore(runtime.subscribe, () => runtime.getSnapshot().suspended); - const base = useRef(null); - const sort = useRef(null); const [modal, setModal] = useState(null); const [exportId, setExportId] = useState("base"); useEffect( @@ -24,8 +23,6 @@ export function SortingPlayground({ runtime }: Props) { ); useLayoutEffect(() => { runtime.mount({ - base: base.current, - sort: sort.current, canvas: document.getElementById("waveform-canvas"), }); return () => runtime.destroy(); @@ -47,7 +44,7 @@ export function SortingPlayground({ runtime }: Props) {
- +
@@ -88,7 +85,7 @@ export function SortingPlayground({ runtime }: Props) {
- +
diff --git a/src/components/playground/waveform-controls.tsx b/src/components/playground/waveform-controls.tsx index bd8becf..11487da 100644 --- a/src/components/playground/waveform-controls.tsx +++ b/src/components/playground/waveform-controls.tsx @@ -1,13 +1,13 @@ +import { EnvelopeChart } from "../visualizations/envelope-chart"; import { Button } from "@/components/ui/button"; import { ValueSlider } from "@/components/value-slider"; -import { useLayoutEffect, useRef } from "react"; import { Field } from "@base-ui/react/field"; import { useAtomValue, useSetAtom } from "jotai"; import type { createStore } from "jotai/vanilla"; import { settingsAtom, updateSettingAtom } from "../../state/settings.ts"; import { envelopeAtom, updateEnvelopeAtom, type EnvelopeKey } from "../../state/envelope.ts"; import { waveformDefaults, type WaveformId } from "../../state/waveforms.ts"; -import { drawEnvelopeDiagram, formatEnvelopeValue } from "../../audio/envelope-diagram.ts"; +import { formatEnvelopeValue } from "../../audio/envelope.ts"; type Store = ReturnType; const controls: ReadonlyArray<{ @@ -63,23 +63,11 @@ export function WaveformControls({ store }: { store: Store }) { const envelope = useAtomValue(envelopeAtom, { store }); const updateSetting = useSetAtom(updateSettingAtom, { store }); const updateEnvelope = useSetAtom(updateEnvelopeAtom, { store }); - const diagram = useRef(null); - useLayoutEffect(() => { - if (diagram.current) drawEnvelopeDiagram(diagram.current, envelope); - }, [envelope]); return (
- + {/* The audio adapter alone draws this canvas; React never owns its pixels. */} `${40 + (time / Math.max(1, total)) * 340},${155 - level * 125}`) + .join(" "); + const sustainY = 155 - e.s * 125; + return ( + + + + + + + ); +} diff --git a/src/components/visualizations/player-chart.tsx b/src/components/visualizations/player-chart.tsx new file mode 100644 index 0000000..b25053c --- /dev/null +++ b/src/components/visualizations/player-chart.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { PointerEvent } from "react"; +import type { Props, PlayerId } from "../../controllers/playground-types"; +import { createTrajectories } from "../../visualizations/create-trajectories.ts"; +import { cn } from "../../utilities/cn"; + +const markers = [ + ["highlight", "fill-purple-600"], + ["justSwapped", "fill-green-500"], + ["swap", "fill-yellow-300"], + ["compare", "fill-amber-400"], + ["mark", "fill-neutral-50"], +] as const; + +export function PlayerChart({ runtime, id }: Props & { id: PlayerId }) { + const { frames, position, renderer } = useSyncExternalStore( + runtime.subscribe, + () => runtime.getSnapshot()[id], + ); + const suspended = useSyncExternalStore(runtime.subscribe, () => runtime.getSnapshot().suspended); + const [hover, setHover] = useState(-1); + const drag = useRef<{ pointer: number; index: number; value: number } | null>(null); + useEffect(() => { + if (suspended) { + drag.current = null; + setHover(-1); + } + }, [suspended]); + const editable = id === "base" && !suspended; + const flat = renderer === "flat"; + const items = frames[position]?.arr ?? []; + const size = items.length; + const paths = useMemo(() => (flat ? createTrajectories(frames) : []), [flat, frames]); + const point = (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const clamp = (value: number) => + Math.max(0, Math.min(size - 1, Number.isFinite(value) ? value : 0)); + return { + index: clamp(Math.floor(((event.clientX - bounds.left) / bounds.width) * size)), + value: size - 1 - clamp(Math.floor(((event.clientY - bounds.top) / bounds.height) * size)), + }; + }; + const end = (event: PointerEvent) => { + if (drag.current?.pointer !== event.pointerId) return; + drag.current = null; + if (event.currentTarget.hasPointerCapture(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId); + }; + return ( + { + if (!editable || !size || flat || event.button !== 0 || drag.current) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + const value = point(event); + drag.current = { pointer: event.pointerId, ...value }; + setHover(value.index); + runtime.edit(value.index, value.value); + }} + onPointerMove={(event) => { + if (!editable || !size || flat) return; + if (drag.current && drag.current.pointer !== event.pointerId) return; + const value = point(event); + setHover(value.index); + if ( + drag.current && + (drag.current.index !== value.index || drag.current.value !== value.value) + ) { + drag.current = { pointer: event.pointerId, ...value }; + runtime.edit(value.index, value.value); + } + }} + onPointerLeave={() => setHover(-1)} + onPointerUp={end} + onPointerCancel={end} + onLostPointerCapture={end} + > + {flat ? ( + paths.map((path) => ( + + )) + ) : ( + <> + {items.map((item, index) => ( + + ))} + {id === "sort" && + markers.map(([marker, color], level) => + items.map((item, index) => ( + + )), + )} + + )} + + ); +} diff --git a/src/controllers/create-player.mjs b/src/controllers/create-player.mjs index 86ca5e9..1206751 100644 --- a/src/controllers/create-player.mjs +++ b/src/controllers/create-player.mjs @@ -1,23 +1,14 @@ import { createTimbreAudio } from "../audio/create-timbre-audio.mjs"; import { createTransport } from "../audio/create-transport.ts"; import { timbre } from "../audio/timbre.mjs"; -import { select } from "d3-selection"; -import { visualizations } from "../visualizations/visualization-registry.mjs"; +import { visualizations } from "../visualizations/visualization-types.ts"; import { createMidiBytes } from "../midi/create-midi-bytes.mjs"; import { drawStringPreview } from "../audio/string-preview.ts"; -// Owns only the D3 SVG contents and audio resources. React owns all controls. -export function createPlayer({ - svg, - settings, - getMidiNumber, - soundfont, - isLooping, - onUpdate, - onEdit, -}) { +// Owns transport and audio resources; React renders the published chart data. +export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, onUpdate }) { let data = []; - let visualization; + /** @type {import('../visualizations/visualization-types.ts').VisualizationType} */ let renderer = "bar"; let disposed = false; const audio = createTimbreAudio( @@ -29,9 +20,11 @@ export function createPlayer({ ); const draw = () => { const position = transport.getPosition(); - const frame = visualization?.draw(position); + const frame = data[position]; onUpdate({ position, + frames: data, + renderer, length: data.length, compare: frame?.compareCount || 0, compareMax: data.at(-1)?.compareCount || 0, @@ -51,38 +44,10 @@ export function createPlayer({ }, }); audio.refresh(); - const events = new AbortController(); - if (onEdit) { - for (const [event, method] of [ - ["pointermove", "onMouseMove"], - ["pointerleave", "onMouseOut"], - ["pointerdown", "onMouseDown"], - ["pointerup", "onMouseUp"], - ["pointercancel", "onMouseUp"], - ]) { - svg.addEventListener( - event, - (e) => { - if (event === "pointerdown") svg.setPointerCapture(e.pointerId); - visualization?.[method]?.(e); - }, - { signal: events.signal }, - ); - } - } - const setVisualization = (name, reset = false) => { + /** @param {import('../visualizations/visualization-types.ts').VisualizationType} name */ + const setVisualization = (name) => { if (!Object.hasOwn(visualizations, name)) return; - if (renderer === name && visualization?.setData && reset) visualization.setData(data); - else if (renderer !== name || reset || !visualization) { - renderer = name; - svg.replaceChildren(); - visualization = visualizations[name]({ - data, - svg: select(svg), - hasMarkers: !onEdit, - onClick: onEdit, - }); - } + renderer = name; draw(); }; return { @@ -90,7 +55,7 @@ export function createPlayer({ if (disposed) return; data = value; transport.setLength(data.length); - setVisualization(renderer, true); + draw(); }, setVisualization, seek(value) { @@ -112,7 +77,6 @@ export function createPlayer({ }, suspend() { transport.suspend(); - visualization?.onMouseUp?.(); draw(); }, setTempo: transport.setTempo, @@ -129,12 +93,9 @@ export function createPlayer({ destroy() { if (disposed) return; disposed = true; - events.abort(); transport.dispose(); audio.dispose(); - svg.replaceChildren(); data = []; - visualization = null; }, }; } diff --git a/src/controllers/create-playground.mjs b/src/controllers/create-playground.mjs index efec220..18a84f9 100644 --- a/src/controllers/create-playground.mjs +++ b/src/controllers/create-playground.mjs @@ -17,6 +17,10 @@ import { playbackPreferencesAtom, toggleLoopAtom } from "../state/playback-prefe import { createSortRequest, runSortRequest } from "../sorting/sort-requests.ts"; const emptyPlayer = { + frames: /** @type {import('../sorting/sort-types.ts').SortFrame[]} */ ([]), + renderer: /** @type {import('../visualizations/visualization-types.ts').VisualizationType} */ ( + "bar" + ), position: 0, length: 0, compare: 0, @@ -55,6 +59,7 @@ export function createPlayground(store = createStore()) { const frames = () => baseData.map((_, index) => ({ arr: baseData.map((value, i) => ({ + id: i, value, play: i === index, mark: false, @@ -212,24 +217,11 @@ export function createPlayground(store = createStore()) { try { for (const id of ["base", "sort"]) { players[id] = createPlayer({ - svg: elements[id], settings, getMidiNumber: helper.getMidiNumber, soundfont, isLooping: () => store.get(playbackPreferencesAtom).loop[id], onUpdate: (value) => publish({ [id]: value }), - onEdit: - id === "base" - ? (index, value) => { - baseData[index] = value; - maxData[index] = scaleLinear() - .domain([0, baseData.length - 1]) - .range([0, maxData.length - 1])(value); - players.base.setData(frames()); - clearTimeout(timer); - timer = setTimeout(sort, 250); - } - : null, }); } generate("randomUnique"); @@ -239,6 +231,16 @@ export function createPlayground(store = createStore()) { throw error; } }, + edit(index, value) { + if (destroyed || suspended || !players || index < 0 || index >= baseData.length) return; + baseData[index] = value; + maxData[index] = scaleLinear() + .domain([0, baseData.length - 1]) + .range([0, maxData.length - 1])(value); + players.base.setData(frames()); + clearTimeout(timer); + timer = setTimeout(sort, 250); + }, action, seek(id, value) { if (!suspended && !destroyed) players[id].seek(value); diff --git a/src/styles/globals.css b/src/styles/globals.css index 420f4ce..aae2d90 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,7 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; -@import "./visualizations.css"; @custom-variant dark (&:is(.dark *)); @source ".."; diff --git a/src/styles/visualizations.css b/src/styles/visualizations.css deleted file mode 100644 index d2dd4bc..0000000 --- a/src/styles/visualizations.css +++ /dev/null @@ -1,53 +0,0 @@ -/* D3 owns these SVG children; their state classes are set outside React. */ -#base-svg, -#sort-svg { - width: 100%; - height: 100%; - border: 1px solid var(--color-neutral-300); - border-radius: var(--radius-lg); - background: var(--color-neutral-100) url(/img/gradient_squares.png) repeat center; -} -#base-svg { - cursor: pointer; - touch-action: none; -} -#base-svg rect, -#sort-svg rect { - fill: var(--color-sky-700); - stroke: white; -} -#base-svg rect.play, -#sort-svg rect.play { - fill: var(--color-red-700); -} -#sort-svg circle { - stroke: var(--color-neutral-700); -} -#sort-svg circle.mark { - fill: var(--color-neutral-50); -} -#sort-svg circle.compare { - fill: var(--color-amber-400); -} -#sort-svg circle.swap { - fill: var(--color-yellow-300); -} -#sort-svg circle.justSwapped { - fill: var(--color-green-500); -} -#sort-svg circle.highlight { - fill: var(--color-purple-600); -} -.envelope-grid { - stroke: var(--color-neutral-200); -} -.envelope-sustain { - stroke: var(--color-neutral-400); - stroke-dasharray: 4 4; -} -.envelope-curve { - fill: none; - stroke: var(--color-sky-700); - stroke-width: 2.5; - stroke-linejoin: round; -} diff --git a/src/visualizations/create-trajectories.ts b/src/visualizations/create-trajectories.ts new file mode 100644 index 0000000..1659dde --- /dev/null +++ b/src/visualizations/create-trajectories.ts @@ -0,0 +1,41 @@ +import { rgb } from "d3-color"; +import { line } from "d3-shape"; +import type { SortFrame, SortItem } from "../sorting/sort-types"; + +type Point = [number, number]; +export type Trajectory = { + id: SortItem["id"]; + dataColor: string; + playColor: string; + playIndexes: Set; + d: string; +}; +type TrajectoryPoints = Omit & { points: Point[] }; + +// Pure geometry: no DOM access. Calculate only when the recorded frames change. +export function createTrajectories(frames: readonly SortFrame[]): Trajectory[] { + const items = frames.at(-1)?.arr ?? []; + const half = Math.max(1, Math.floor(items.length / 2)); + const paths: TrajectoryPoints[] = items.map((item, index) => ({ + id: item.id, + dataColor: rgb("steelblue") + .darker((index - half) / half) + .toString(), + playColor: rgb("#c80000") + .darker((index - half) / half) + .toString(), + playIndexes: new Set(), + points: [], + })); + const byId = new Map(paths.map((path) => [path.id, path])); + frames.forEach((frame, time) => { + frame.arr.forEach((item, index) => { + const path = byId.get(item.id); + if (!path) return; + path.points.push([time, index + 0.5]); + if (item.play) path.playIndexes.add(time); + }); + }); + const toPath = line(); + return paths.reverse().map(({ points, ...path }) => ({ ...path, d: toPath(points) ?? "" })); +} diff --git a/src/visualizations/renderers/bar.mjs b/src/visualizations/renderers/bar.mjs deleted file mode 100644 index 262c53c..0000000 --- a/src/visualizations/renderers/bar.mjs +++ /dev/null @@ -1,170 +0,0 @@ -export default function bar(settings) { - let hoverIndex = -1; - let hoverValue = -1; - let clickIndex = -1; - let clickValue = -1; - let isClicking = false; - const bar = {}; - // settings - let data; - let svg; - let hasMarkers; - let onClick; - // Keep pointer state when the base editor updates data during a drag. - bar.setData = (nextData) => { - data = nextData; - }; - - const _init = function (settings) { - data = settings.data; - svg = settings.svg; - svg.attr("preserveAspectRatio", null).attr("viewBox", null); - hasMarkers = settings.hasMarkers; - onClick = settings.onClick; - }; - - const drawMarkers = function (info, level, property) { - if (hasMarkers) { - // select some items - const circle = svg - .selectAll("circle." + property) - .data(info.arr) - .join("circle"); - const len = info.arr.length; - - // determine our radius and our y position - const radius = 100 / (Math.max(len, 20) * 4); - const cy = 100 - level * 10 + "%"; - - // update - circle - .attr("cy", cy) - .attr("cx", function (d, i) { - const width = 100 / (len * 2); - return (i / len) * 100 + width + "%"; - }) - .attr("r", function () { - return radius + "%"; - }) - .attr("class", property) - .attr("style", function (d) { - return d[property] ? "" : "display:none"; - }); - } - }; - - const getIndexAndValueFromMouse = function (e) { - let n = 0; - let min = 0; - - // set relative positions - const bounds = svg.node().getBoundingClientRect(); - const relX = e.clientX - bounds.left; - const relY = e.clientY - bounds.top; - const w = bounds.width; - const h = bounds.height; - - // get datasize - if (data.length > 0) { - n = data[0].arr.length; - min = n - 1; - } - - // set index/value - let index = Math.floor((relX / w) * n); - let value = Math.floor((relY / h) * n); - - // account for div/0 - index = isFinite(index) ? index : 0; - value = isFinite(value) ? value : 0; - - // handle offset errors - index = Math.max(0, Math.min(min, index)); - value = Math.max(0, Math.min(min, value)); - value = min - value; - - return { - index: index, - value: value, - }; - }; - - bar.onMouseMove = function (e) { - const result = getIndexAndValueFromMouse(e); - - if (hoverIndex !== result.index) { - hoverIndex = result.index; - svg.selectAll("rect").attr("opacity", (_d, i) => (i === hoverIndex ? 0.5 : 1)); - } - hoverValue = result.value; - if (isClicking && (hoverIndex !== clickIndex || hoverValue !== clickValue)) { - clickIndex = hoverIndex; - clickValue = hoverValue; - onClick(clickIndex, clickValue); - } - }; - - bar.onMouseOut = function () { - hoverIndex = -1; - svg.selectAll("rect").attr("opacity", 1); - }; - - bar.onMouseDown = function (e) { - const result = getIndexAndValueFromMouse(e); - e.preventDefault(); - isClicking = true; - clickIndex = result.index; - clickValue = result.value; - onClick(clickIndex, clickValue); - }; - - bar.onMouseUp = function () { - isClicking = false; - clickIndex = -1; - clickValue = -1; - }; - - bar.draw = function (index) { - let info; - - // draw it - if (data.length > 0) { - info = data[index]; - - // select some items - const rect = svg.selectAll("rect").data(info.arr).join("rect"); - const len = info.arr.length; - - // update - rect - .attr("width", function () { - return 100 / len + "%"; - }) - .attr("height", function (d) { - return (100 / len) * (d.value + 1) + "%"; - }) - .attr("x", function (d, i) { - return (i / len) * 100 + "%"; - }) - .attr("y", function (d) { - return 100 - (100 / len) * (d.value + 1) + "%"; - }) - .attr("class", function (d) { - return d.play ? "play" : ""; - }); - - // draw our markers - drawMarkers(info, 1, "highlight"); - drawMarkers(info, 2, "justSwapped"); - drawMarkers(info, 3, "swap"); - drawMarkers(info, 4, "compare"); - drawMarkers(info, 5, "mark"); - //drawMarkers(info, 5, 'play'); - } - - return info; - }; - - _init(settings); - return bar; -} diff --git a/src/visualizations/renderers/flat.mjs b/src/visualizations/renderers/flat.mjs deleted file mode 100644 index 496e016..0000000 --- a/src/visualizations/renderers/flat.mjs +++ /dev/null @@ -1,120 +0,0 @@ -import { rgb } from "d3-color"; -import { line as createLine } from "d3-shape"; - -export default function flat(settings) { - const flat = {}; - // settings - let data = []; - let svg; - let flattenedLines = []; - let numFlattenedLines = 0; - let frameLength = 0; - const dataColor = "steelblue"; - const playColor = "#c80000"; - let lines; - - const _init = function (settings) { - data = settings.data; - svg = settings.svg; - // setup lengths - if (data.length) { - numFlattenedLines = data[0].arr.length; - frameLength = data.length; - initFlattenedLines(); - } else { - numFlattenedLines = 0; - frameLength = 0; - flattenedLines = []; - } - svg.selectAll("*").remove(); - svg.attr("viewBox", "0 0 0 0"); - svg.attr("preserveAspectRatio", "none"); - svg.attr("viewBox", "0 0 " + (frameLength - 1) + " " + numFlattenedLines); - drawFlattenedLines(); - }; - - const initFlattenedLines = function () { - const ids = []; - - flattenedLines = []; - if (data.length) { - const lastFrameArray = data[data.length - 1].arr; - const half = Math.floor(numFlattenedLines / 2); - // build base arrays - for (let i = 0; i < numFlattenedLines; i++) { - const id = lastFrameArray[i].id; - ids.push(id); - flattenedLines[i] = { - id: id, - dataColor: rgb(dataColor) - .darker((i - half) / half) - .toString(), - playColor: rgb(playColor) - .darker((i - half) / half) - .toString(), - playIndexes: [], - lineData: [], - }; - } - // build line data - for (let i = 0; i < frameLength; i++) { - const currentArray = data[i].arr; - for (let j = 0; j < currentArray.length; j++) { - const item = currentArray[j]; - const index = ids.indexOf(item.id); - flattenedLines[index].lineData.push({ - x: i, - y: j + 0.5, - }); - if (item.play) { - flattenedLines[index].playIndexes.push(i); - } - } - } - // darker items should be drawn first - flattenedLines.reverse(); - } - }; - - const drawFlattenedLines = function (index) { - // create our line function - const line = createLine() - .x(function (d) { - return d.x; - }) - .y(function (d) { - return d.y; - }); - - // select our lines - lines = svg.selectAll(".line").data(flattenedLines).join("path"); - - // update - lines - .attr("class", "line") - .attr("data-id", function (d) { - return d.id; - }) - .attr("stroke", function (d) { - return d.playIndexes.indexOf(index) >= 0 ? d.playColor : d.dataColor; - }) - .attr("fill", "none") - .attr("stroke-width", 0.5) - .attr("d", function (d) { - return line(d.lineData); - }); - }; - - flat.draw = function (index) { - lines.attr("stroke", function (d) { - return d.playIndexes.indexOf(index) >= 0 ? d.playColor : d.dataColor; - }); - - if (data.length > 0) { - return data[index]; - } - }; - - _init(settings); - return flat; -} diff --git a/src/visualizations/visualization-registry.mjs b/src/visualizations/visualization-registry.mjs deleted file mode 100644 index e1ddb84..0000000 --- a/src/visualizations/visualization-registry.mjs +++ /dev/null @@ -1,4 +0,0 @@ -import bar from "./renderers/bar.mjs"; -import flat from "./renderers/flat.mjs"; - -export const visualizations = Object.freeze({ bar, flat }); diff --git a/src/visualizations/visualization-types.ts b/src/visualizations/visualization-types.ts new file mode 100644 index 0000000..de34525 --- /dev/null +++ b/src/visualizations/visualization-types.ts @@ -0,0 +1,3 @@ +export const visualizations = Object.freeze({ bar: "Bars", flat: "Trajectories" } as const); + +export type VisualizationType = keyof typeof visualizations; diff --git a/test/browser/react-charts.spec.mjs b/test/browser/react-charts.spec.mjs new file mode 100644 index 0000000..7c7cd27 --- /dev/null +++ b/test/browser/react-charts.spec.mjs @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +test("React bar editing keeps hover feedback and stops on pointer cancellation", async ({ + page, +}) => { + await page.goto("./"); + const svg = page.locator("#base-svg"); + const bars = svg.locator("rect"); + await expect(bars).toHaveCount(12); + const box = await svg.boundingBox(); + await page.mouse.move(box.x + box.width / 24, box.y + box.height / 2); + await expect(bars.first()).toHaveAttribute("opacity", "0.5"); + await expect(bars.first()).toHaveClass(/fill-red-700/); + await expect(bars.nth(1)).toHaveClass(/fill-sky-700/); + await page.mouse.down(); + await svg.dispatchEvent("pointercancel", { pointerId: 1 }); + const before = await bars.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("height")), + ); + await page.mouse.move(box.x + box.width * 0.9, box.y + box.height * 0.1); + await page.mouse.up(); + expect( + await bars.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("height"))), + ).toEqual(before); + await page.mouse.move(0, 0); + await expect(bars.first()).toHaveAttribute("opacity", "1"); +}); diff --git a/test/browser/site.spec.mjs b/test/browser/site.spec.mjs index 3ce62f8..4c8f43c 100644 --- a/test/browser/site.spec.mjs +++ b/test/browser/site.spec.mjs @@ -555,7 +555,7 @@ for (const fallback of [false, true]) { }); } -test("D3 joins resize bars, markers, and paths without stale elements", async ({ page }) => { +test("React charts resize bars, markers, and paths without stale elements", async ({ page }) => { const errors = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto("./"); diff --git a/test/trajectories.test.mjs b/test/trajectories.test.mjs new file mode 100644 index 0000000..814c0db --- /dev/null +++ b/test/trajectories.test.mjs @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { createTrajectories } from "../src/visualizations/create-trajectories.ts"; + +const frame = (arr) => ({ arr, compareCount: 0, swapCount: 0 }); +test("trajectories track item identity across positions and duplicate values", () => { + const frames = [ + frame([ + { id: "a", value: 0, play: true }, + { id: "b", value: 0 }, + ]), + frame([ + { id: "b", value: 0, play: true }, + { id: "a", value: 0 }, + ]), + ]; + const before = structuredClone(frames); + const paths = createTrajectories(frames); + expect(paths.map((path) => path.id)).toEqual(["a", "b"]); + expect(paths[0].d).toBe("M0,0.5L1,1.5"); + expect(paths[1].d).toBe("M0,1.5L1,0.5"); + expect([...paths[0].playIndexes]).toEqual([0]); + expect([...paths[1].playIndexes]).toEqual([1]); + expect(frames).toEqual(before); +}); + +test("empty and single-item trajectories have finite geometry and colors", () => { + expect(createTrajectories([])).toEqual([]); + const [path] = createTrajectories([frame([{ id: 0, value: 0 }])]); + expect(path.d).toMatch(/^M0,0.5/); + expect(path.d + path.dataColor + path.playColor).not.toMatch(/NaN|Infinity/); +}); diff --git a/test/ui.test.mjs b/test/ui.test.mjs index b2095bd..6e441cd 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -2,7 +2,7 @@ import { expect, test } from "vitest"; import { createPlayground } from "../src/controllers/create-playground.mjs"; import { createHelpers } from "../src/controllers/create-helpers.mjs"; import { createPlayer } from "../src/controllers/create-player.mjs"; -import { visualizations } from "../src/visualizations/visualization-registry.mjs"; +import { visualizations } from "../src/visualizations/visualization-types.ts"; test("UI modules import without DOM initialization or first-party globals", () => { for (const name of ["A", "visualization"]) expect(Object.hasOwn(globalThis, name)).toBe(false); diff --git a/test/waveforms.test.mjs b/test/waveforms.test.mjs index 27146e5..f5dd75f 100644 --- a/test/waveforms.test.mjs +++ b/test/waveforms.test.mjs @@ -4,7 +4,7 @@ import { waveformDefaults, selectedWaveformAtom } from "../src/state/waveforms.t import { envelopeDefaults, envelopeAtom, updateEnvelopeAtom } from "../src/state/envelope.ts"; import { updateSettingAtom } from "../src/state/settings.ts"; import { createPlayground } from "../src/controllers/create-playground.mjs"; -import { getEnvelopePoints, formatEnvelopeValue } from "../src/audio/envelope-diagram.ts"; +import { getEnvelopePoints, formatEnvelopeValue } from "../src/audio/envelope.ts"; test("all eight generators use the shared defaults, including string", () => { const store = createStore();