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();