From 81baf06f381c364c6d24b1408247cffa17dd3855 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 05:43:58 +0000 Subject: [PATCH 1/5] feat: add Phosphor Score component for React and Svelte Vertical CRT sheet music that falls toward a playhead, blooms, then exits in a flare. Live props for color, glow, rotation, speed, and sway. Co-authored-by: Jay Sharma --- README.md | 23 +- content/docs/components/meta.json | 1 + content/docs/components/phosphor-score.mdx | 97 ++ content/docs/getting-started.mdx | 18 +- content/docs/index.mdx | 1 + public/r/phosphor-score-svelte.json | 15 + public/r/phosphor-score.json | 15 + public/r/registry.json | 26 + registry.json | 1 + .../phosphor-score/phosphor-score-demo.tsx | 125 +++ .../phosphor-score/phosphor-score-vanilla.ts | 872 ++++++++++++++++++ registry/phosphor-score/phosphor-score.svelte | 81 ++ registry/phosphor-score/phosphor-score.tsx | 100 ++ registry/phosphor-score/registry.json | 31 + 14 files changed, 1386 insertions(+), 20 deletions(-) create mode 100644 content/docs/components/phosphor-score.mdx create mode 100644 public/r/phosphor-score-svelte.json create mode 100644 public/r/phosphor-score.json create mode 100644 registry/phosphor-score/phosphor-score-demo.tsx create mode 100644 registry/phosphor-score/phosphor-score-vanilla.ts create mode 100644 registry/phosphor-score/phosphor-score.svelte create mode 100644 registry/phosphor-score/phosphor-score.tsx create mode 100644 registry/phosphor-score/registry.json diff --git a/README.md b/README.md index a94910b..39dfc09 100644 --- a/README.md +++ b/README.md @@ -72,17 +72,18 @@ pnpm dlx shadcn@latest add radiumcoders/23rd.dev/gooey-color-picker ## Components -| Component | Install | Description | -| ------------------------------------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- | -| [ASCII Fluid](https://23rd.dev/docs/components/ascii-fluid) | `@23rd/ascii-fluid` | Mouse-trail WebGL fluid quantized to a clean ASCII brightness ramp | -| [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark — hover shove, then click to scatter, drop, and gather | -| [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex — joined by an SVG gooey filter | -| [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from the center, then breathe — light and dark | -| [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll | -| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Sparse 2D fire wash — tongues rise from the bottom behind a landing hero | -| [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections | -| [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | -| [Tangle Footer](https://23rd.dev/docs/components/tangle-footer) | `@23rd/tangle-footer` | Nested SVG text ribbons as a footer | +| Component | Install | Description | +| ------------------------------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------- | +| [ASCII Fluid](https://23rd.dev/docs/components/ascii-fluid) | `@23rd/ascii-fluid` | Mouse-trail WebGL fluid quantized to a clean ASCII brightness ramp | +| [ASCII Logo](https://23rd.dev/docs/components/ascii-logo) | `@23rd/ascii-logo` | Interactive ASCII wordmark — hover shove, then click to scatter, drop, and gather | +| [Gooey Color Picker](https://23rd.dev/docs/components/gooey-color-picker) | `@23rd/gooey-color-picker` | Floating swatch → hue wheel, alpha, hex — joined by an SVG gooey filter | +| [Logo Burst](https://23rd.dev/docs/components/logo-burst) | `@23rd/logo-burst` | Hair-line tentacles explode from the center, then breathe — light and dark | +| [Phosphor Score](https://23rd.dev/docs/components/phosphor-score) | `@23rd/phosphor-score` | Vertical CRT sheet music — notes fall, bloom at the playhead, then exit in a flare | +| [Radiant Lines](https://23rd.dev/docs/components/radiant-lines) | `@23rd/radiant-lines` | Hyperspace starfield background; warp speed driven by scroll | +| [Shader Fire](https://23rd.dev/docs/components/shader-fire) | `@23rd/shader-fire` | Sparse 2D fire wash — tongues rise from the bottom behind a landing hero | +| [Shader Gradient](https://23rd.dev/docs/components/shader-gradient) | `@23rd/shader-gradient` | Quiet WebGL wash behind landing heroes, empty states, and marketing sections | +| [Stretchy Footer](https://23rd.dev/docs/components/stretchy-footer) | `@23rd/stretchy-footer` | Dia-style rubber overscroll; aurora stretches past the bottom, then snaps back | +| [Tangle Footer](https://23rd.dev/docs/components/tangle-footer) | `@23rd/tangle-footer` | Nested SVG text ribbons as a footer | ```tsx import { GooeyColorPicker } from "@/components/ui/gooey-color-picker" diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index 8cb6412..ad28742 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -3,6 +3,7 @@ "pages": [ "---Background---", "logo-burst", + "phosphor-score", "radiant-lines", "ascii-fluid", "---Shaders---", diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx new file mode 100644 index 0000000..965dab9 --- /dev/null +++ b/content/docs/components/phosphor-score.mdx @@ -0,0 +1,97 @@ +--- +title: Phosphor Score +description: Vertical CRT sheet music — notes slide down the staves, bloom at the playhead, then exit in a flare. +--- + +import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo" + +A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, go white-hot as they play, then flare out. `rotateX` / `rotateY` tilt the plane; `glow` is the bloom. `sway` keeps a slow camera drift. Press `d` if you want the docs chrome in the other theme — the canvas stays black. + + + +## Installation + + + +Or install straight from the public GitHub source registry: + + + +## Usage + +Give the parent a size. The canvas is opaque black. + + + +```tsx +"use client" + +import { PhosphorScore } from "@/components/ui/phosphor-score" + +export function HeroScore() { + return ( +
+ +
+ ) +} +``` +
+ +```svelte + + +
+ +
+``` +
+
+ +Tilt it, push the bloom, pick a phosphor. + + + + ```tsx + + ``` + + + ```svelte + + ``` + + + +`prefers-reduced-motion: reduce` freezes the scroll and the camera drift, and keeps a still frame of the score. + +## Props + +| Prop | Type | Default | +| ----------- | --------- | ------------------------------------------------- | +| `color` | `string` | `"#4DFF6A"` | +| `glow` | `number` | `1` — bloom at the playhead and on the exit flare | +| `rotateX` | `number` | `16` — tilt back, degrees | +| `rotateY` | `number` | `-12` — yaw, degrees | +| `speed` | `number` | `1.35` — beats per second | +| `density` | `number` | `1` — how packed the notation is | +| `sway` | `boolean` | `true` — slow camera drift | +| `seed` | `number` | `23` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | diff --git a/content/docs/getting-started.mdx b/content/docs/getting-started.mdx index b71a7e1..c680e44 100644 --- a/content/docs/getting-started.mdx +++ b/content/docs/getting-started.mdx @@ -13,7 +13,7 @@ Or pull straight from the public GitHub source registry: -Swap `shader-gradient` for any item name — `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. +Swap `shader-gradient` for any item name — `shader-fire`, `live-orb`, `ascii-logo`, `logo-burst`, `phosphor-score`, `radiant-lines`, `dithered-404`, `tangle-footer`, and so on. Svelte installs as `@23rd/-svelte` and lands in `src/lib/components/ui/` so `$lib` imports resolve. ## Use a background or shader @@ -54,11 +54,11 @@ Shaders such as [Shader Gradient](/docs/components/shader-gradient) and [Shader ## Browse -| Category | Components | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| Background | [Logo Burst](/docs/components/logo-burst), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) | -| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire) | -| Footers | [Tangle Footer](/docs/components/tangle-footer), [Stretchy Footer](/docs/components/stretchy-footer) | -| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | -| Pages | [Dithered 404](/docs/components/dithered-404) | -| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | +| Category | Components | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Background | [Logo Burst](/docs/components/logo-burst), [Phosphor Score](/docs/components/phosphor-score), [Radiant Lines](/docs/components/radiant-lines), [ASCII Fluid](/docs/components/ascii-fluid) | +| Shaders | [Shader Gradient](/docs/components/shader-gradient), [Shader Fire](/docs/components/shader-fire) | +| Footers | [Tangle Footer](/docs/components/tangle-footer), [Stretchy Footer](/docs/components/stretchy-footer) | +| Characters | [Live Orb](/docs/components/live-orb), [ASCII Logo](/docs/components/ascii-logo) | +| Pages | [Dithered 404](/docs/components/dithered-404) | +| Components | [Gooey Color Picker](/docs/components/gooey-color-picker) | diff --git a/content/docs/index.mdx b/content/docs/index.mdx index a697362..b481c89 100644 --- a/content/docs/index.mdx +++ b/content/docs/index.mdx @@ -22,5 +22,6 @@ Most component kits hand you every option. 23rd picks a direction: spacing, radi - [Shader Gradient](/docs/components/shader-gradient) — quiet wash behind a landing hero - [Shader Fire](/docs/components/shader-fire) — sparse fire tongues under a landing hero - [Logo Burst](/docs/components/logo-burst) — hair-line tentacles explode from the center, then breathe +- [Phosphor Score](/docs/components/phosphor-score) — vertical CRT sheet music that falls and flares - [Radiant Lines](/docs/components/radiant-lines) — hyperspace starfield background - [Dithered 404](/docs/components/dithered-404) — Bayer-pixel 404 that burns under a fireball cursor diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json new file mode 100644 index 0000000..6a1fa36 --- /dev/null +++ b/public/r/phosphor-score-svelte.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "phosphor-score-svelte", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "registry/phosphor-score/phosphor-score.svelte", + "content": "\n\n\n\n\n \n\n", + "type": "registry:file", + "target": "src/lib/components/ui/phosphor-score.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json new file mode 100644 index 0000000..e4b1246 --- /dev/null +++ b/public/r/phosphor-score.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "phosphor-score", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "registry/phosphor-score/phosphor-score.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreOptions = {\n /** Ink / phosphor color. Default `#4DFF6A`. */\n color?: string\n /**\n * Bloom strength at the playhead and on the exit flash.\n * Default `1`.\n */\n glow?: number\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#4DFF6A\"\nexport const DEFAULT_GLOW = 1\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction mixWhite(rgb: Rgb, t: number): Rgb {\n const k = clamp(t, 0, 1)\n return [\n Math.round(lerp(rgb[0], 255, k)),\n Math.round(lerp(rgb[1], 255, k)),\n Math.round(lerp(rgb[2], 255, k)),\n ]\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 2.4)\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -55, 55)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: Required<\n Pick<\n PhosphorScoreOptions,\n | \"color\"\n | \"glow\"\n | \"rotateX\"\n | \"rotateY\"\n | \"speed\"\n | \"density\"\n | \"sway\"\n | \"seed\"\n >\n > = {\n color: DEFAULT_COLOR,\n glow: DEFAULT_GLOW,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let rgb = parseColor(options.color)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(options.color)\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const project = (x: number, y: number, rotX: number, rotY: number): Pt => {\n const cosX = Math.cos(rotX)\n const sinX = Math.sin(rotX)\n const cosY = Math.cos(rotY)\n const sinY = Math.sin(rotY)\n const y1 = y * cosX\n const z1 = y * sinX\n const x2 = x * cosY - z1 * sinY\n const z2 = x * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 1.15\n const persp = fov / (fov + z2)\n return {\n x: size.w * 0.5 + x2 * persp,\n y: size.h * 0.5 + y1 * persp,\n s: persp,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, color: string) => {\n const r = radius * pt.s\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, color)\n g.addColorStop(1, \"rgba(0,0,0,0)\")\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.016, 8, 15)\n const gap = clamp(w * 0.2, 64, 118)\n const ppb = clamp(h * 0.09, 34, 78)\n const playY = h * 0.2\n const glow = glowOf(options.glow)\n const sway = options.sway !== false && !reduce\n const rotX =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (sway ? Math.sin(clock * 0.33) * 0.11 : 0)\n const rotY =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (sway ? Math.cos(clock * 0.21) * 0.16 : 0)\n const color = rgb\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = \"#050505\"\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(mixWhite(color, 0.35), d.a)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -2.2\n const visMax = h / ppb + 2.2\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 36\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY), rotX, rotY)\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * glow)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y, rotX, rotY)\n const b = project(pitchX(staff, 8, gap, sp), y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY),\n rotX,\n rotY\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0, rotX, rotY)\n const b = project(xOuter1, y1, rotX, rotY)\n const c = project(xMid, y0, rotX, rotY)\n const e = project(xMid, y1, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY),\n rotX,\n rotY\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY),\n rotX,\n rotY\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY),\n rotX,\n rotY\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y, rotX, rotY)\n const b = project(pitchX(1, link.p1, gap, sp), y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y, rotX, rotY),\n tip: project(x1, y, rotX, rotY),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 12; i++) {\n const x = lerp(-gap - sp * 8, gap + sp * 8, i / 12)\n playheadPts.push(project(x, playY, rotX, rotY))\n }\n ctx.strokeStyle = rgba(mixWhite(color, 0.4), 0.18 * glow)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n ctx.globalCompositeOperation = \"lighter\"\n const scan = playheadPts[Math.floor(playheadPts.length / 2)]!\n glowBlob(scan, 90 * glow, rgba(color, 0.07 * glow))\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y, rotX, rotY)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const fillAmt = mixWhite(color, intens * 0.85)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9, rotX, rotY)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y, rotX, rotY)\n const lb = project(lx1, y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.05) {\n glowBlob(\n pt,\n (18 + intens * 42) * glow,\n rgba(fillAmt, (0.12 + intens * 0.38) * glow)\n )\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n drawOval(pt, headR, rgba(fillAmt, clamp(alpha, 0.2, 1)))\n ctx.globalCompositeOperation = \"lighter\"\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.42 + glow * 0.12,\n size: 22 + glow * 18,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y, rotX, rotY)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(\n pt,\n flare.size * (0.8 + t * 1.6) * glow,\n rgba(mixWhite(color, 0.9), 0.55 * fade * glow)\n )\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(0)\n const flareW = 70 * glow * (1 + t) * pt.s\n const flareH = 2.2 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, \"rgba(255,255,255,0)\")\n fg.addColorStop(0.5, rgba(mixWhite(color, 1), 0.7 * fade))\n fg.addColorStop(1, \"rgba(255,255,255,0)\")\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n const vig = ctx.createRadialGradient(\n w * 0.5,\n h * 0.52,\n Math.min(w, h) * 0.2,\n w * 0.5,\n h * 0.52,\n Math.max(w, h) * 0.72\n )\n vig.addColorStop(0, \"rgba(0,0,0,0)\")\n vig.addColorStop(1, \"rgba(0,0,0,0.55)\")\n ctx.fillStyle = vig\n ctx.fillRect(0, 0, w, h)\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n }\n rgb = parseColor(options.color)\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type PhosphorScoreProps = PhosphorScoreOptions & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Transparent to layout; fills the parent.\n */\nexport function PhosphorScore({\n className,\n color = DEFAULT_COLOR,\n glow = DEFAULT_GLOW,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n rotateX,\n rotateY,\n speed,\n density,\n sway,\n seed,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n rotateX,\n rotateY,\n speed,\n density,\n sway,\n seed,\n })\n }, [color, glow, rotateX, rotateY, speed, density, sway, seed])\n\n return (\n \n \n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/phosphor-score.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/registry.json b/public/r/registry.json index 0a03c64..055e75d 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -163,6 +163,32 @@ ], "type": "registry:ui" }, + { + "name": "phosphor-score", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "registry/phosphor-score/phosphor-score.tsx", + "type": "registry:ui", + "target": "components/ui/phosphor-score.tsx" + } + ], + "type": "registry:ui" + }, + { + "name": "phosphor-score-svelte", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "registry/phosphor-score/phosphor-score.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/phosphor-score.svelte" + } + ], + "type": "registry:ui" + }, { "name": "radiant-lines", "title": "Radiant Lines", diff --git a/registry.json b/registry.json index 772b587..d865091 100644 --- a/registry.json +++ b/registry.json @@ -9,6 +9,7 @@ "registry/gooey-color-picker/registry.json", "registry/live-orb/registry.json", "registry/logo-burst/registry.json", + "registry/phosphor-score/registry.json", "registry/radiant-lines/registry.json", "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx new file mode 100644 index 0000000..cfb622b --- /dev/null +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -0,0 +1,125 @@ +"use client" + +import { + ComponentControls, + ControlColor, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { + DEFAULT_COLOR, + DEFAULT_DENSITY, + DEFAULT_GLOW, + DEFAULT_ROTATE_X, + DEFAULT_ROTATE_Y, + DEFAULT_SPEED, + PhosphorScore, +} from "@/registry/phosphor-score/phosphor-score" + +const DEFAULTS = { + color: DEFAULT_COLOR, + glow: DEFAULT_GLOW, + rotateX: DEFAULT_ROTATE_X, + rotateY: DEFAULT_ROTATE_Y, + speed: DEFAULT_SPEED, + density: DEFAULT_DENSITY, + sway: true, +} + +export function PhosphorScoreDemo() { + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(DEFAULTS) + + return ( + <> + +
+ +
+
+ + + updateProp("color", v)} + /> + updateProp("glow", v)} + /> + updateProp("rotateX", v)} + /> + updateProp("rotateY", v)} + /> + updateProp("speed", v)} + /> + updateProp("density", v)} + /> + updateProp("sway", v)} + /> + + + ) +} diff --git a/registry/phosphor-score/phosphor-score-vanilla.ts b/registry/phosphor-score/phosphor-score-vanilla.ts new file mode 100644 index 0000000..3e9fd28 --- /dev/null +++ b/registry/phosphor-score/phosphor-score-vanilla.ts @@ -0,0 +1,872 @@ +export type PhosphorScoreOptions = { + /** Ink / phosphor color. Default `#4DFF6A`. */ + color?: string + /** + * Bloom strength at the playhead and on the exit flash. + * Default `1`. + */ + glow?: number + /** + * Tilt the score plane back, in degrees. Default `16`. + */ + rotateX?: number + /** + * Yaw the score plane, in degrees. Default `-12`. + */ + rotateY?: number + /** Scroll speed in beats per second. Default `1.35`. */ + speed?: number + /** How packed the notation is. Default `1`. */ + density?: number + /** Slow camera drift. Default `true`. */ + sway?: boolean + /** Deterministic score. Default `23`. */ + seed?: number +} + +export type PhosphorScoreInstance = { + setOptions: (options: Partial) => void + destroy: () => void +} + +export const DEFAULT_COLOR = "#4DFF6A" +export const DEFAULT_GLOW = 1 +export const DEFAULT_ROTATE_X = 16 +export const DEFAULT_ROTATE_Y = -12 +export const DEFAULT_SPEED = 1.35 +export const DEFAULT_DENSITY = 1 +export const DEFAULT_SEED = 23 + +const LOOP_BEATS = 48 +const DYNAMICS = ["pp", "p", "mp", "mf", "f", "ff"] as const + +type Note = { + t: number + pitch: number + dur: number + staff: 0 | 1 + beam: number + acc: -1 | 0 | 1 +} + +type Mark = { + t: number + staff: 0 | 1 + text: string + pitch: number +} + +type Hairpin = { + t: number + dur: number + staff: 0 | 1 + open: boolean +} + +type Slur = { + t0: number + t1: number + p0: number + p1: number + staff: 0 | 1 +} + +type Link = { + t: number + p0: number + p1: number +} + +type Dust = { + x: number + y: number + z: number + s: number + a: number +} + +type Flare = { + x: number + y: number + age: number + life: number + size: number +} + +type Rgb = [number, number, number] + +function mulberry32(seed: number) { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * t +} + +function wrapDelta(t: number, now: number, loop: number) { + let d = t - now + d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2 + return d +} + +function parseColor(input?: string): Rgb { + const raw = (input ?? DEFAULT_COLOR).trim() + if (/^#[0-9a-fA-F]{6}$/.test(raw)) { + return [ + parseInt(raw.slice(1, 3), 16), + parseInt(raw.slice(3, 5), 16), + parseInt(raw.slice(5, 7), 16), + ] + } + if (/^#[0-9a-fA-F]{3}$/.test(raw)) { + return [ + parseInt(raw[1]! + raw[1], 16), + parseInt(raw[2]! + raw[2], 16), + parseInt(raw[3]! + raw[3], 16), + ] + } + return [77, 255, 106] +} + +function rgba(rgb: Rgb, a: number) { + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})` +} + +function mixWhite(rgb: Rgb, t: number): Rgb { + const k = clamp(t, 0, 1) + return [ + Math.round(lerp(rgb[0], 255, k)), + Math.round(lerp(rgb[1], 255, k)), + Math.round(lerp(rgb[2], 255, k)), + ] +} + +function glowOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GLOW + return clamp(value, 0, 2.4) +} + +function rotateOf(value: number | undefined, fallback: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, -55, 55) +} + +function speedOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SPEED + return clamp(value, 0.15, 4) +} + +function densityOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_DENSITY + } + return clamp(value, 0.35, 1.85) +} + +function seedOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SEED + return Math.round(value) >>> 0 +} + +type Score = { + notes: Note[] + marks: Mark[] + hairpins: Hairpin[] + slurs: Slur[] + links: Link[] +} + +function generateScore(seed: number, density: number): Score { + const rng = mulberry32(seed) + const notes: Note[] = [] + const marks: Mark[] = [] + const hairpins: Hairpin[] = [] + const slurs: Slur[] = [] + const links: Link[] = [] + let group = 1 + const dens = densityOf(density) + + const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]! + + const pushNote = ( + t: number, + pitch: number, + dur: number, + staff: 0 | 1, + beam: number + ) => { + notes.push({ + t, + pitch: clamp(pitch, -3, 11), + dur, + staff, + beam, + acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0, + }) + } + + const addRun = ( + staff: 0 | 1, + start: number, + pitch: number, + count: number, + step: number, + dir: number + ) => { + const beam = group++ + let p = pitch + for (let i = 0; i < count; i++) { + if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam) + const hop = rng() < 0.18 ? 2 : 1 + p += dir * hop + if (p > 10 || p < -2) dir *= -1 + } + if (count >= 4 && rng() < 0.45) { + slurs.push({ + t0: start, + t1: start + (count - 1) * step, + p0: pitch, + p1: p, + staff, + }) + } + } + + const addArp = (staff: 0 | 1, start: number, root: number, step: number) => { + const pattern = [0, 2, 4, 7, 4, 2, 0, -1] + const beam = group++ + for (let i = 0; i < pattern.length; i++) { + pushNote(start + i * step, root + pattern[i]!, step, staff, beam) + } + } + + const addChord = (staff: 0 | 1, t: number, root: number) => { + const beam = -group++ + pushNote(t, root, 1, staff, beam) + pushNote(t, root + 2, 1, staff, beam) + if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam) + } + + let t = 0 + while (t < LOOP_BEATS - 1) { + const kind = rng() + const leftBusy = rng() < 0.55 + dens * 0.15 + const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4 + const step = rng() < 0.28 * dens ? 0.125 : 0.25 + const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0) + + if (kind < 0.5) { + addRun( + leftBusy ? 0 : 1, + t, + startPitch, + runLen, + step, + rng() < 0.5 ? 1 : -1 + ) + if (rng() < 0.55 * dens) { + addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4)) + } + t += runLen * step + } else if (kind < 0.72) { + addArp(0, t, 1 + Math.floor(rng() * 5), 0.25) + if (rng() < 0.7 * dens) { + addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1) + } + t += 2 + } else if (kind < 0.86) { + addChord(0, t, 3 + Math.floor(rng() * 4)) + addChord(1, t, Math.floor(rng() * 4)) + if (rng() < 0.5) { + links.push({ + t, + p0: 4, + p1: 4, + }) + } + t += rng() < 0.5 ? 1 : 2 + } else { + t += rng() < 0.5 ? 0.5 : 1 + } + + if (rng() < 0.18) { + marks.push({ + t, + staff: rng() < 0.5 ? 0 : 1, + text: pick(DYNAMICS), + pitch: rng() < 0.5 ? -3.6 : 11.4, + }) + } + if (rng() < 0.12) { + hairpins.push({ + t, + dur: 2 + Math.floor(rng() * 3), + staff: rng() < 0.5 ? 0 : 1, + open: rng() < 0.5, + }) + } + } + + return { notes, marks, hairpins, slurs, links } +} + +function makeDust(seed: number): Dust[] { + const rng = mulberry32(seed ^ 0x9e3779b9) + const dust: Dust[] = [] + for (let i = 0; i < 90; i++) { + dust.push({ + x: (rng() - 0.5) * 2, + y: (rng() - 0.5) * 2, + z: rng(), + s: 0.4 + rng() * 1.4, + a: 0.08 + rng() * 0.22, + }) + } + return dust +} + +/** + * Vertical phosphor sheet music — notation falls toward a playhead, + * blooms, then exits in a flare. Canvas 2D, CRT-green by default. + */ +export function createPhosphorScore( + canvas: HTMLCanvasElement, + initial: PhosphorScoreOptions = {} +): PhosphorScoreInstance | null { + let options: Required< + Pick< + PhosphorScoreOptions, + | "color" + | "glow" + | "rotateX" + | "rotateY" + | "speed" + | "density" + | "sway" + | "seed" + > + > = { + color: DEFAULT_COLOR, + glow: DEFAULT_GLOW, + rotateX: DEFAULT_ROTATE_X, + rotateY: DEFAULT_ROTATE_Y, + speed: DEFAULT_SPEED, + density: DEFAULT_DENSITY, + seed: DEFAULT_SEED, + ...initial, + sway: initial.sway ?? true, + } + + const ctx = canvas.getContext("2d", { alpha: false }) + if (!ctx) return null + + const size = { w: 0, h: 0 } + let score = generateScore(seedOf(options.seed), densityOf(options.density)) + let dust = makeDust(seedOf(options.seed)) + let rgb = parseColor(options.color) + const flares: Flare[] = [] + const hit = new Set() + let reduce = false + let raf = 0 + let running = true + let lastFrame = performance.now() + let elapsed = 8 + let clock = 0 + + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const dpr = Math.min(window.devicePixelRatio || 1, 2) + const w = parent.clientWidth + const h = parent.clientHeight + size.w = w + size.h = h + canvas.width = Math.floor(w * dpr) + canvas.height = Math.floor(h * dpr) + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + + resize() + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + const onReduce = () => { + reduce = mqReduce.matches + } + onReduce() + mqReduce.addEventListener("change", onReduce) + + const rebuild = () => { + score = generateScore(seedOf(options.seed), densityOf(options.density)) + dust = makeDust(seedOf(options.seed)) + rgb = parseColor(options.color) + hit.clear() + flares.length = 0 + } + + type Pt = { x: number; y: number; s: number; z: number } + + const project = (x: number, y: number, rotX: number, rotY: number): Pt => { + const cosX = Math.cos(rotX) + const sinX = Math.sin(rotX) + const cosY = Math.cos(rotY) + const sinY = Math.sin(rotY) + const y1 = y * cosX + const z1 = y * sinX + const x2 = x * cosY - z1 * sinY + const z2 = x * sinY + z1 * cosY + const fov = Math.max(size.h, 420) * 1.15 + const persp = fov / (fov + z2) + return { + x: size.w * 0.5 + x2 * persp, + y: size.h * 0.5 + y1 * persp, + s: persp, + z: z2, + } + } + + const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap) + + const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) => + staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5) + + const timeY = (delta: number, ppb: number, playY: number) => + playY - delta * ppb + + const nearPlay = (delta: number) => { + if (delta > 0.55) return 0 + if (delta > 0) return 1 - delta / 0.55 + if (delta > -0.28) return 1 + if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07 + return 0 + } + + const drawOval = (pt: Pt, r: number, fill: string) => { + ctx.save() + ctx.translate(pt.x, pt.y) + ctx.rotate(-0.38) + ctx.scale(1, 0.7) + ctx.beginPath() + ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2) + ctx.fillStyle = fill + ctx.fill() + ctx.restore() + } + + const glowBlob = (pt: Pt, radius: number, color: string) => { + const r = radius * pt.s + const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r) + g.addColorStop(0, color) + g.addColorStop(1, "rgba(0,0,0,0)") + ctx.fillStyle = g + ctx.beginPath() + ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2) + ctx.fill() + } + + const frame = (now: number) => { + if (!running) return + const dt = Math.min(0.05, (now - lastFrame) / 1000) + lastFrame = now + clock += dt + if (!reduce) elapsed += dt * speedOf(options.speed) + const beat = elapsed % LOOP_BEATS + + const w = size.w + const h = size.h + const sp = clamp(Math.min(w, h) * 0.016, 8, 15) + const gap = clamp(w * 0.2, 64, 118) + const ppb = clamp(h * 0.09, 34, 78) + const playY = h * 0.2 + const glow = glowOf(options.glow) + const sway = options.sway !== false && !reduce + const rotX = + (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 + + (sway ? Math.sin(clock * 0.33) * 0.11 : 0) + const rotY = + (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 + + (sway ? Math.cos(clock * 0.21) * 0.16 : 0) + const color = rgb + + ctx.globalCompositeOperation = "source-over" + ctx.fillStyle = "#050505" + ctx.fillRect(0, 0, w, h) + + ctx.save() + ctx.globalAlpha = 1 + for (const d of dust) { + const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1 + const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1 + ctx.fillStyle = rgba(mixWhite(color, 0.35), d.a) + ctx.fillRect(px * w, py * h, d.s, d.s) + } + ctx.restore() + + const visMin = -2.2 + const visMax = h / ppb + 2.2 + + const inView = (t: number) => { + const d = wrapDelta(t, beat, LOOP_BEATS) + return d < visMax && d > visMin + } + + ctx.lineCap = "round" + ctx.lineJoin = "round" + + for (const staff of [0, 1] as const) { + for (let line = 0; line < 5; line++) { + const pitch = line * 2 + const x = pitchX(staff, pitch, gap, sp) + ctx.beginPath() + let started = false + const steps = 36 + for (let i = 0; i <= steps; i++) { + const d = lerp(visMax, visMin, i / steps) + const hitAmt = nearPlay(d) + const pt = project(x, timeY(d, ppb, playY), rotX, rotY) + if (!started) { + ctx.moveTo(pt.x, pt.y) + started = true + } else ctx.lineTo(pt.x, pt.y) + if (i === Math.floor(steps * 0.62)) { + ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * glow) + ctx.lineWidth = 1 + ctx.stroke() + ctx.beginPath() + ctx.moveTo(pt.x, pt.y) + } + } + ctx.strokeStyle = rgba(color, 0.22) + ctx.lineWidth = 1 + ctx.stroke() + } + } + + for (let bar = 0; bar < LOOP_BEATS; bar += 4) { + if (!inView(bar)) continue + const d = wrapDelta(bar, beat, LOOP_BEATS) + const y = timeY(d, ppb, playY) + for (const staff of [0, 1] as const) { + const a = project(pitchX(staff, 0, gap, sp), y, rotX, rotY) + const b = project(pitchX(staff, 8, gap, sp), y, rotX, rotY) + ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + } + + ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, "Times New Roman", serif` + ctx.textBaseline = "middle" + for (const mark of score.marks) { + if (!inView(mark.t)) continue + const d = wrapDelta(mark.t, beat, LOOP_BEATS) + const pt = project( + pitchX(mark.staff, mark.pitch, gap, sp), + timeY(d, ppb, playY), + rotX, + rotY + ) + ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45) + ctx.fillText(mark.text, pt.x, pt.y) + } + + for (const pin of score.hairpins) { + const t1 = pin.t + pin.dur + if (!inView(pin.t) && !inView(t1)) continue + const d0 = wrapDelta(pin.t, beat, LOOP_BEATS) + const d1 = wrapDelta(t1, beat, LOOP_BEATS) + const staff = pin.staff + const outer = staff === 0 ? -4.6 : 12.6 + const inner = staff === 0 ? -3.4 : 11.4 + const y0 = timeY(d0, ppb, playY) + const y1 = timeY(d1, ppb, playY) + const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp) + const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp) + const xMid = pitchX(staff, (outer + inner) / 2, gap, sp) + const a = project(xOuter0, y0, rotX, rotY) + const b = project(xOuter1, y1, rotX, rotY) + const c = project(xMid, y0, rotX, rotY) + const e = project(xMid, y1, rotX, rotY) + ctx.strokeStyle = rgba(color, 0.22) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.moveTo(c.x, c.y) + ctx.lineTo(e.x, e.y) + ctx.stroke() + } + + for (const slur of score.slurs) { + if (!inView(slur.t0) && !inView(slur.t1)) continue + const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS) + const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS) + const side = slur.staff === 0 ? -1.8 : 1.8 + const a = project( + pitchX(slur.staff, slur.p0 + side, gap, sp), + timeY(d0, ppb, playY), + rotX, + rotY + ) + const b = project( + pitchX(slur.staff, slur.p1 + side, gap, sp), + timeY(d1, ppb, playY), + rotX, + rotY + ) + const midT = (slur.t0 + slur.t1) / 2 + const midP = (slur.p0 + slur.p1) / 2 + side * 1.6 + const c = project( + pitchX(slur.staff, midP, gap, sp), + timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY), + rotX, + rotY + ) + ctx.strokeStyle = rgba(color, 0.2) + ctx.lineWidth = 1.1 + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.quadraticCurveTo(c.x, c.y, b.x, b.y) + ctx.stroke() + } + + for (const link of score.links) { + if (!inView(link.t)) continue + const d = wrapDelta(link.t, beat, LOOP_BEATS) + const y = timeY(d, ppb, playY) + const a = project(pitchX(0, link.p0, gap, sp), y, rotX, rotY) + const b = project(pitchX(1, link.p1, gap, sp), y, rotX, rotY) + ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + + const groups = new Map() + for (const note of score.notes) { + if (note.beam <= 0) continue + if (!inView(note.t)) continue + const list = groups.get(note.beam) + if (list) list.push(note) + else groups.set(note.beam, [note]) + } + + const stemTip = (note: Note, d: number) => { + const dir = note.staff === 0 ? -1 : 1 + const y = timeY(d, ppb, playY) + const x0 = pitchX(note.staff, note.pitch, gap, sp) + const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp) + return { + head: project(x0, y, rotX, rotY), + tip: project(x1, y, rotX, rotY), + d, + } + } + + for (const note of score.notes) { + if (!inView(note.t)) continue + const d = wrapDelta(note.t, beat, LOOP_BEATS) + const { head, tip } = stemTip(note, d) + const intens = nearPlay(d) + ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45) + ctx.lineWidth = 1 * head.s + ctx.beginPath() + ctx.moveTo(head.x, head.y) + ctx.lineTo(tip.x, tip.y) + ctx.stroke() + } + + for (const [, groupNotes] of groups) { + if (groupNotes.length < 2) continue + groupNotes.sort((a, b) => a.t - b.t) + const first = groupNotes[0]! + const last = groupNotes[groupNotes.length - 1]! + const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS)) + const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS)) + const intens = Math.max(nearPlay(a.d), nearPlay(b.d)) + ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5) + ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5) + ctx.beginPath() + ctx.moveTo(a.tip.x, a.tip.y) + ctx.lineTo(b.tip.x, b.tip.y) + ctx.stroke() + if (first.dur <= 0.13) { + const oy = (first.staff === 0 ? -1 : 1) * 3.2 + ctx.beginPath() + ctx.moveTo(a.tip.x, a.tip.y + oy) + ctx.lineTo(b.tip.x, b.tip.y + oy) + ctx.stroke() + } + } + + const playheadPts: Pt[] = [] + for (let i = 0; i <= 12; i++) { + const x = lerp(-gap - sp * 8, gap + sp * 8, i / 12) + playheadPts.push(project(x, playY, rotX, rotY)) + } + ctx.strokeStyle = rgba(mixWhite(color, 0.4), 0.18 * glow) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y) + for (let i = 1; i < playheadPts.length; i++) { + ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y) + } + ctx.stroke() + + ctx.globalCompositeOperation = "lighter" + const scan = playheadPts[Math.floor(playheadPts.length / 2)]! + glowBlob(scan, 90 * glow, rgba(color, 0.07 * glow)) + + for (const note of score.notes) { + const d = wrapDelta(note.t, beat, LOOP_BEATS) + if (d > visMax || d < visMin) continue + const y = timeY(d, ppb, playY) + const x = pitchX(note.staff, note.pitch, gap, sp) + const pt = project(x, y, rotX, rotY) + const intens = nearPlay(d) + const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2 + const headR = Math.max(2.4, sp * 0.38) + const fillAmt = mixWhite(color, intens * 0.85) + const alpha = upcoming + intens * 0.8 + + if (note.acc !== 0 && d > -0.4) { + const accPt = project(x, y - sp * 0.9, rotX, rotY) + ctx.fillStyle = rgba(color, 0.25 + intens * 0.5) + ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif` + ctx.fillText(note.acc > 0 ? "#" : "b", accPt.x - 4, accPt.y) + } + + if (note.pitch < 0 || note.pitch > 8) { + const ledgerPitch = note.pitch < 0 ? -2 : 10 + const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp) + const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp) + const la = project(lx0, y, rotX, rotY) + const lb = project(lx1, y, rotX, rotY) + ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3) + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(la.x, la.y) + ctx.lineTo(lb.x, lb.y) + ctx.stroke() + } + + if (intens > 0.05) { + glowBlob( + pt, + (18 + intens * 42) * glow, + rgba(fillAmt, (0.12 + intens * 0.38) * glow) + ) + } + + ctx.globalCompositeOperation = "source-over" + drawOval(pt, headR, rgba(fillAmt, clamp(alpha, 0.2, 1))) + ctx.globalCompositeOperation = "lighter" + + if (d <= 0 && d > -0.08 && intens > 0.7) { + const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0 + if (!hit.has(id)) { + hit.add(id) + flares.push({ + x, + y: playY, + age: 0, + life: 0.42 + glow * 0.12, + size: 22 + glow * 18, + }) + if (hit.size > 400) hit.clear() + } + } + } + + for (let i = flares.length - 1; i >= 0; i--) { + const flare = flares[i]! + flare.age += dt + const t = flare.age / flare.life + if (t >= 1) { + flares.splice(i, 1) + continue + } + const pt = project(flare.x, flare.y, rotX, rotY) + const fade = Math.sin((1 - t) * Math.PI) + glowBlob( + pt, + flare.size * (0.8 + t * 1.6) * glow, + rgba(mixWhite(color, 0.9), 0.55 * fade * glow) + ) + ctx.save() + ctx.translate(pt.x, pt.y) + ctx.rotate(0) + const flareW = 70 * glow * (1 + t) * pt.s + const flareH = 2.2 * pt.s + const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0) + fg.addColorStop(0, "rgba(255,255,255,0)") + fg.addColorStop(0.5, rgba(mixWhite(color, 1), 0.7 * fade)) + fg.addColorStop(1, "rgba(255,255,255,0)") + ctx.fillStyle = fg + ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) + ctx.restore() + } + + ctx.globalCompositeOperation = "source-over" + const vig = ctx.createRadialGradient( + w * 0.5, + h * 0.52, + Math.min(w, h) * 0.2, + w * 0.5, + h * 0.52, + Math.max(w, h) * 0.72 + ) + vig.addColorStop(0, "rgba(0,0,0,0)") + vig.addColorStop(1, "rgba(0,0,0,0.55)") + ctx.fillStyle = vig + ctx.fillRect(0, 0, w, h) + + raf = requestAnimationFrame(frame) + } + + raf = requestAnimationFrame(frame) + + return { + setOptions(next) { + const prevSeed = seedOf(options.seed) + const prevDens = densityOf(options.density) + options = { + ...options, + ...next, + sway: next.sway ?? options.sway, + } + rgb = parseColor(options.color) + if ( + seedOf(options.seed) !== prevSeed || + densityOf(options.density) !== prevDens + ) { + rebuild() + } + }, + destroy() { + running = false + cancelAnimationFrame(raf) + ro.disconnect() + mqReduce.removeEventListener("change", onReduce) + }, + } +} diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte new file mode 100644 index 0000000..c1a8a54 --- /dev/null +++ b/registry/phosphor-score/phosphor-score.svelte @@ -0,0 +1,81 @@ + + + + + diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx new file mode 100644 index 0000000..913f6aa --- /dev/null +++ b/registry/phosphor-score/phosphor-score.tsx @@ -0,0 +1,100 @@ +"use client" + +import { useEffect, useRef } from "react" + +import { cn } from "@/lib/utils" + +import { + createPhosphorScore, + DEFAULT_COLOR, + DEFAULT_DENSITY, + DEFAULT_GLOW, + DEFAULT_ROTATE_X, + DEFAULT_ROTATE_Y, + DEFAULT_SEED, + DEFAULT_SPEED, + type PhosphorScoreInstance, + type PhosphorScoreOptions, +} from "./phosphor-score-vanilla" + +export { + DEFAULT_COLOR, + DEFAULT_DENSITY, + DEFAULT_GLOW, + DEFAULT_ROTATE_X, + DEFAULT_ROTATE_Y, + DEFAULT_SEED, + DEFAULT_SPEED, +} from "./phosphor-score-vanilla" +export type { + PhosphorScoreInstance, + PhosphorScoreOptions, +} from "./phosphor-score-vanilla" + +export type PhosphorScoreProps = PhosphorScoreOptions & { + className?: string +} + +/** + * Vertical phosphor sheet music — notes fall toward a playhead, bloom, + * then exit in a flare. Transparent to layout; fills the parent. + */ +export function PhosphorScore({ + className, + color = DEFAULT_COLOR, + glow = DEFAULT_GLOW, + rotateX = DEFAULT_ROTATE_X, + rotateY = DEFAULT_ROTATE_Y, + speed = DEFAULT_SPEED, + density = DEFAULT_DENSITY, + sway = true, + seed = DEFAULT_SEED, +}: PhosphorScoreProps) { + const canvasRef = useRef(null) + const instanceRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createPhosphorScore(canvas, { + color, + glow, + rotateX, + rotateY, + speed, + density, + sway, + seed, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions({ + color, + glow, + rotateX, + rotateY, + speed, + density, + sway, + seed, + }) + }, [color, glow, rotateX, rotateY, speed, density, sway, seed]) + + return ( +
+ +
+ ) +} diff --git a/registry/phosphor-score/registry.json b/registry/phosphor-score/registry.json new file mode 100644 index 0000000..6dcd757 --- /dev/null +++ b/registry/phosphor-score/registry.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "phosphor-score", + "type": "registry:ui", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "phosphor-score.tsx", + "type": "registry:ui", + "target": "components/ui/phosphor-score.tsx" + } + ] + }, + { + "name": "phosphor-score-svelte", + "type": "registry:ui", + "title": "Phosphor Score", + "description": "Vertical sheet music on a CRT phosphor — notes fall, bloom at the playhead, then exit in a flare.", + "files": [ + { + "path": "phosphor-score.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/phosphor-score.svelte" + } + ] + } + ] +} From 06814e14ca3a144ef1d2d5b2ef00e2a93c2a2926 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 05:54:38 +0000 Subject: [PATCH 2/5] fix: drop glow prop, add rotateZ, keep bloom on the note color Scale the score with tilt so rotation no longer clips the staves, and match playhead bloom and exit flares to the phosphor color. Co-authored-by: Jay Sharma --- content/docs/components/phosphor-score.mdx | 32 +-- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-demo.tsx | 25 +-- .../phosphor-score/phosphor-score-vanilla.ts | 195 ++++++++---------- registry/phosphor-score/phosphor-score.svelte | 8 +- registry/phosphor-score/phosphor-score.tsx | 12 +- 7 files changed, 126 insertions(+), 150 deletions(-) diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index 965dab9..bbe8126 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -5,7 +5,7 @@ description: Vertical CRT sheet music — notes slide down the staves, bloom at import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo" -A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, go white-hot as they play, then flare out. `rotateX` / `rotateY` tilt the plane; `glow` is the bloom. `sway` keeps a slow camera drift. Press `d` if you want the docs chrome in the other theme — the canvas stays black. +A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. `rotateX` / `rotateY` / `rotateZ` tilt the plane; bloom stays the same color as the notes. `sway` keeps a slow camera drift. Press `d` if you want the docs chrome in the other theme — the canvas stays black. @@ -50,16 +50,16 @@ export function HeroScore() { -Tilt it, push the bloom, pick a phosphor. +Tilt it and pick a phosphor. Bloom follows `color`. ```tsx @@ -69,9 +69,9 @@ Tilt it, push the bloom, pick a phosphor. ```svelte @@ -83,15 +83,15 @@ Tilt it, push the bloom, pick a phosphor. ## Props -| Prop | Type | Default | -| ----------- | --------- | ------------------------------------------------- | -| `color` | `string` | `"#4DFF6A"` | -| `glow` | `number` | `1` — bloom at the playhead and on the exit flare | -| `rotateX` | `number` | `16` — tilt back, degrees | -| `rotateY` | `number` | `-12` — yaw, degrees | -| `speed` | `number` | `1.35` — beats per second | -| `density` | `number` | `1` — how packed the notation is | -| `sway` | `boolean` | `true` — slow camera drift | -| `seed` | `number` | `23` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| Prop | Type | Default | +| ----------- | --------- | -------------------------------- | +| `color` | `string` | `"#4DFF6A"` — notes and bloom | +| `rotateX` | `number` | `16` — tilt back, degrees | +| `rotateY` | `number` | `-12` — yaw, degrees | +| `rotateZ` | `number` | `0` — roll, degrees | +| `speed` | `number` | `1.35` — beats per second | +| `density` | `number` | `1` — how packed the notation is | +| `sway` | `boolean` | `true` — slow camera drift | +| `seed` | `number` | `23` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index 6a1fa36..6efa069 100644 --- a/public/r/phosphor-score-svelte.json +++ b/public/r/phosphor-score-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n \n\n", "type": "registry:file", "target": "src/lib/components/ui/phosphor-score.svelte" } diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json index e4b1246..f089cbb 100644 --- a/public/r/phosphor-score.json +++ b/public/r/phosphor-score.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreOptions = {\n /** Ink / phosphor color. Default `#4DFF6A`. */\n color?: string\n /**\n * Bloom strength at the playhead and on the exit flash.\n * Default `1`.\n */\n glow?: number\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#4DFF6A\"\nexport const DEFAULT_GLOW = 1\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction mixWhite(rgb: Rgb, t: number): Rgb {\n const k = clamp(t, 0, 1)\n return [\n Math.round(lerp(rgb[0], 255, k)),\n Math.round(lerp(rgb[1], 255, k)),\n Math.round(lerp(rgb[2], 255, k)),\n ]\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 2.4)\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -55, 55)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: Required<\n Pick<\n PhosphorScoreOptions,\n | \"color\"\n | \"glow\"\n | \"rotateX\"\n | \"rotateY\"\n | \"speed\"\n | \"density\"\n | \"sway\"\n | \"seed\"\n >\n > = {\n color: DEFAULT_COLOR,\n glow: DEFAULT_GLOW,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let rgb = parseColor(options.color)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(options.color)\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const project = (x: number, y: number, rotX: number, rotY: number): Pt => {\n const cosX = Math.cos(rotX)\n const sinX = Math.sin(rotX)\n const cosY = Math.cos(rotY)\n const sinY = Math.sin(rotY)\n const y1 = y * cosX\n const z1 = y * sinX\n const x2 = x * cosY - z1 * sinY\n const z2 = x * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 1.15\n const persp = fov / (fov + z2)\n return {\n x: size.w * 0.5 + x2 * persp,\n y: size.h * 0.5 + y1 * persp,\n s: persp,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, color: string) => {\n const r = radius * pt.s\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, color)\n g.addColorStop(1, \"rgba(0,0,0,0)\")\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.016, 8, 15)\n const gap = clamp(w * 0.2, 64, 118)\n const ppb = clamp(h * 0.09, 34, 78)\n const playY = h * 0.2\n const glow = glowOf(options.glow)\n const sway = options.sway !== false && !reduce\n const rotX =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (sway ? Math.sin(clock * 0.33) * 0.11 : 0)\n const rotY =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (sway ? Math.cos(clock * 0.21) * 0.16 : 0)\n const color = rgb\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = \"#050505\"\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(mixWhite(color, 0.35), d.a)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -2.2\n const visMax = h / ppb + 2.2\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 36\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY), rotX, rotY)\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * glow)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y, rotX, rotY)\n const b = project(pitchX(staff, 8, gap, sp), y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY),\n rotX,\n rotY\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0, rotX, rotY)\n const b = project(xOuter1, y1, rotX, rotY)\n const c = project(xMid, y0, rotX, rotY)\n const e = project(xMid, y1, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY),\n rotX,\n rotY\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY),\n rotX,\n rotY\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY),\n rotX,\n rotY\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y, rotX, rotY)\n const b = project(pitchX(1, link.p1, gap, sp), y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y, rotX, rotY),\n tip: project(x1, y, rotX, rotY),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 12; i++) {\n const x = lerp(-gap - sp * 8, gap + sp * 8, i / 12)\n playheadPts.push(project(x, playY, rotX, rotY))\n }\n ctx.strokeStyle = rgba(mixWhite(color, 0.4), 0.18 * glow)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n ctx.globalCompositeOperation = \"lighter\"\n const scan = playheadPts[Math.floor(playheadPts.length / 2)]!\n glowBlob(scan, 90 * glow, rgba(color, 0.07 * glow))\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y, rotX, rotY)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const fillAmt = mixWhite(color, intens * 0.85)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9, rotX, rotY)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y, rotX, rotY)\n const lb = project(lx1, y, rotX, rotY)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.05) {\n glowBlob(\n pt,\n (18 + intens * 42) * glow,\n rgba(fillAmt, (0.12 + intens * 0.38) * glow)\n )\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n drawOval(pt, headR, rgba(fillAmt, clamp(alpha, 0.2, 1)))\n ctx.globalCompositeOperation = \"lighter\"\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.42 + glow * 0.12,\n size: 22 + glow * 18,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y, rotX, rotY)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(\n pt,\n flare.size * (0.8 + t * 1.6) * glow,\n rgba(mixWhite(color, 0.9), 0.55 * fade * glow)\n )\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(0)\n const flareW = 70 * glow * (1 + t) * pt.s\n const flareH = 2.2 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, \"rgba(255,255,255,0)\")\n fg.addColorStop(0.5, rgba(mixWhite(color, 1), 0.7 * fade))\n fg.addColorStop(1, \"rgba(255,255,255,0)\")\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n const vig = ctx.createRadialGradient(\n w * 0.5,\n h * 0.52,\n Math.min(w, h) * 0.2,\n w * 0.5,\n h * 0.52,\n Math.max(w, h) * 0.72\n )\n vig.addColorStop(0, \"rgba(0,0,0,0)\")\n vig.addColorStop(1, \"rgba(0,0,0,0.55)\")\n ctx.fillStyle = vig\n ctx.fillRect(0, 0, w, h)\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n }\n rgb = parseColor(options.color)\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type PhosphorScoreProps = PhosphorScoreOptions & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Transparent to layout; fills the parent.\n */\nexport function PhosphorScore({\n className,\n color = DEFAULT_COLOR,\n glow = DEFAULT_GLOW,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n rotateX,\n rotateY,\n speed,\n density,\n sway,\n seed,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n rotateX,\n rotateY,\n speed,\n density,\n sway,\n seed,\n })\n }, [color, glow, rotateX, rotateY, speed, density, sway, seed])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreOptions = {\n /** Ink / phosphor color. Default `#4DFF6A`. */\n color?: string\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /**\n * Roll the score plane, in degrees. Default `0`.\n */\n rotateZ?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#4DFF6A\"\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_ROTATE_Z = 0\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\nconst BLOOM = 1\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -80, 80)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: Required<\n Pick<\n PhosphorScoreOptions,\n | \"color\"\n | \"rotateX\"\n | \"rotateY\"\n | \"rotateZ\"\n | \"speed\"\n | \"density\"\n | \"sway\"\n | \"seed\"\n >\n > = {\n color: DEFAULT_COLOR,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n rotateZ: DEFAULT_ROTATE_Z,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let rgb = parseColor(options.color)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(options.color)\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n\n const project = (x: number, y: number): Pt => {\n const cosX = Math.cos(cam.x)\n const sinX = Math.sin(cam.x)\n const cosY = Math.cos(cam.y)\n const sinY = Math.sin(cam.y)\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const xz = x * cosZ - y * sinZ\n const yz = x * sinZ + y * cosZ\n const y1 = yz * cosX\n const z1 = yz * sinX\n const x2 = xz * cosY - z1 * sinY\n const z2 = xz * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 2.4\n const persp = fov / (fov + z2)\n const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z)\n const fit = 0.78 / (1 + tilt * 0.35)\n return {\n x: size.w * 0.5 + x2 * persp * fit,\n y: size.h * 0.5 + y1 * persp * fit,\n s: persp * fit,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, color: string) => {\n const r = radius * pt.s\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, color)\n g.addColorStop(1, \"rgba(0,0,0,0)\")\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.014, 7, 13)\n const gap = clamp(w * 0.16, 52, 96)\n const ppb = clamp(h * 0.075, 28, 64)\n const playY = h * 0.12\n const swayOn = options.sway !== false && !reduce\n cam.x =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0)\n cam.y =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0)\n cam.z =\n (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0)\n const color = rgb\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = \"#050505\"\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, d.a)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -8\n const visMax = h / ppb + 8\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * BLOOM)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 18; i++) {\n const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18)\n playheadPts.push(project(x, playY))\n }\n ctx.strokeStyle = rgba(color, 0.22 * BLOOM)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n ctx.globalCompositeOperation = \"lighter\"\n const scan = playheadPts[Math.floor(playheadPts.length / 2)]!\n glowBlob(scan, 110 * BLOOM, rgba(color, 0.1 * BLOOM))\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.05) {\n glowBlob(\n pt,\n (22 + intens * 52) * BLOOM,\n rgba(color, (0.16 + intens * 0.42) * BLOOM)\n )\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.globalCompositeOperation = \"lighter\"\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.48,\n size: 36,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(\n pt,\n flare.size * (0.8 + t * 1.6) * BLOOM,\n rgba(color, 0.5 * fade * BLOOM)\n )\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 90 * BLOOM * (1 + t) * pt.s\n const flareH = 2.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, 0.75 * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n }\n rgb = parseColor(options.color)\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type PhosphorScoreProps = PhosphorScoreOptions & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Transparent to layout; fills the parent.\n */\nexport function PhosphorScore({\n className,\n color = DEFAULT_COLOR,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n rotateZ = DEFAULT_ROTATE_Z,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n })\n }, [color, rotateX, rotateY, rotateZ, speed, density, sway, seed])\n\n return (\n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index cfb622b..71cf3c7 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -11,18 +11,18 @@ import { usePreviewProps } from "@/hooks/use-preview-props" import { DEFAULT_COLOR, DEFAULT_DENSITY, - DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, + DEFAULT_ROTATE_Z, DEFAULT_SPEED, PhosphorScore, } from "@/registry/phosphor-score/phosphor-score" const DEFAULTS = { color: DEFAULT_COLOR, - glow: DEFAULT_GLOW, rotateX: DEFAULT_ROTATE_X, rotateY: DEFAULT_ROTATE_Y, + rotateZ: DEFAULT_ROTATE_Z, speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, sway: true, @@ -41,9 +41,9 @@ export function PhosphorScoreDemo() {
updateProp("color", v)} /> - updateProp("glow", v)} - /> updateProp("rotateY", v)} /> + updateProp("rotateZ", v)} + /> > = { color: DEFAULT_COLOR, - glow: DEFAULT_GLOW, rotateX: DEFAULT_ROTATE_X, rotateY: DEFAULT_ROTATE_Y, + rotateZ: DEFAULT_ROTATE_Z, speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, seed: DEFAULT_SEED, @@ -420,21 +406,29 @@ export function createPhosphorScore( type Pt = { x: number; y: number; s: number; z: number } - const project = (x: number, y: number, rotX: number, rotY: number): Pt => { - const cosX = Math.cos(rotX) - const sinX = Math.sin(rotX) - const cosY = Math.cos(rotY) - const sinY = Math.sin(rotY) - const y1 = y * cosX - const z1 = y * sinX - const x2 = x * cosY - z1 * sinY - const z2 = x * sinY + z1 * cosY - const fov = Math.max(size.h, 420) * 1.15 + const cam = { x: 0, y: 0, z: 0 } + + const project = (x: number, y: number): Pt => { + const cosX = Math.cos(cam.x) + const sinX = Math.sin(cam.x) + const cosY = Math.cos(cam.y) + const sinY = Math.sin(cam.y) + const cosZ = Math.cos(cam.z) + const sinZ = Math.sin(cam.z) + const xz = x * cosZ - y * sinZ + const yz = x * sinZ + y * cosZ + const y1 = yz * cosX + const z1 = yz * sinX + const x2 = xz * cosY - z1 * sinY + const z2 = xz * sinY + z1 * cosY + const fov = Math.max(size.h, 420) * 2.4 const persp = fov / (fov + z2) + const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z) + const fit = 0.78 / (1 + tilt * 0.35) return { - x: size.w * 0.5 + x2 * persp, - y: size.h * 0.5 + y1 * persp, - s: persp, + x: size.w * 0.5 + x2 * persp * fit, + y: size.h * 0.5 + y1 * persp * fit, + s: persp * fit, z: z2, } } @@ -488,18 +482,20 @@ export function createPhosphorScore( const w = size.w const h = size.h - const sp = clamp(Math.min(w, h) * 0.016, 8, 15) - const gap = clamp(w * 0.2, 64, 118) - const ppb = clamp(h * 0.09, 34, 78) - const playY = h * 0.2 - const glow = glowOf(options.glow) - const sway = options.sway !== false && !reduce - const rotX = + const sp = clamp(Math.min(w, h) * 0.014, 7, 13) + const gap = clamp(w * 0.16, 52, 96) + const ppb = clamp(h * 0.075, 28, 64) + const playY = h * 0.12 + const swayOn = options.sway !== false && !reduce + cam.x = (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 + - (sway ? Math.sin(clock * 0.33) * 0.11 : 0) - const rotY = + (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0) + cam.y = (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 + - (sway ? Math.cos(clock * 0.21) * 0.16 : 0) + (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0) + cam.z = + (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 + + (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0) const color = rgb ctx.globalCompositeOperation = "source-over" @@ -511,13 +507,13 @@ export function createPhosphorScore( for (const d of dust) { const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1 const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1 - ctx.fillStyle = rgba(mixWhite(color, 0.35), d.a) + ctx.fillStyle = rgba(color, d.a) ctx.fillRect(px * w, py * h, d.s, d.s) } ctx.restore() - const visMin = -2.2 - const visMax = h / ppb + 2.2 + const visMin = -8 + const visMax = h / ppb + 8 const inView = (t: number) => { const d = wrapDelta(t, beat, LOOP_BEATS) @@ -533,17 +529,17 @@ export function createPhosphorScore( const x = pitchX(staff, pitch, gap, sp) ctx.beginPath() let started = false - const steps = 36 + const steps = 56 for (let i = 0; i <= steps; i++) { const d = lerp(visMax, visMin, i / steps) const hitAmt = nearPlay(d) - const pt = project(x, timeY(d, ppb, playY), rotX, rotY) + const pt = project(x, timeY(d, ppb, playY)) if (!started) { ctx.moveTo(pt.x, pt.y) started = true } else ctx.lineTo(pt.x, pt.y) if (i === Math.floor(steps * 0.62)) { - ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * glow) + ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * BLOOM) ctx.lineWidth = 1 ctx.stroke() ctx.beginPath() @@ -561,8 +557,8 @@ export function createPhosphorScore( const d = wrapDelta(bar, beat, LOOP_BEATS) const y = timeY(d, ppb, playY) for (const staff of [0, 1] as const) { - const a = project(pitchX(staff, 0, gap, sp), y, rotX, rotY) - const b = project(pitchX(staff, 8, gap, sp), y, rotX, rotY) + const a = project(pitchX(staff, 0, gap, sp), y) + const b = project(pitchX(staff, 8, gap, sp), y) ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2) ctx.lineWidth = 1 ctx.beginPath() @@ -579,9 +575,7 @@ export function createPhosphorScore( const d = wrapDelta(mark.t, beat, LOOP_BEATS) const pt = project( pitchX(mark.staff, mark.pitch, gap, sp), - timeY(d, ppb, playY), - rotX, - rotY + timeY(d, ppb, playY) ) ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45) ctx.fillText(mark.text, pt.x, pt.y) @@ -600,10 +594,10 @@ export function createPhosphorScore( const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp) const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp) const xMid = pitchX(staff, (outer + inner) / 2, gap, sp) - const a = project(xOuter0, y0, rotX, rotY) - const b = project(xOuter1, y1, rotX, rotY) - const c = project(xMid, y0, rotX, rotY) - const e = project(xMid, y1, rotX, rotY) + const a = project(xOuter0, y0) + const b = project(xOuter1, y1) + const c = project(xMid, y0) + const e = project(xMid, y1) ctx.strokeStyle = rgba(color, 0.22) ctx.lineWidth = 1 ctx.beginPath() @@ -621,23 +615,17 @@ export function createPhosphorScore( const side = slur.staff === 0 ? -1.8 : 1.8 const a = project( pitchX(slur.staff, slur.p0 + side, gap, sp), - timeY(d0, ppb, playY), - rotX, - rotY + timeY(d0, ppb, playY) ) const b = project( pitchX(slur.staff, slur.p1 + side, gap, sp), - timeY(d1, ppb, playY), - rotX, - rotY + timeY(d1, ppb, playY) ) const midT = (slur.t0 + slur.t1) / 2 const midP = (slur.p0 + slur.p1) / 2 + side * 1.6 const c = project( pitchX(slur.staff, midP, gap, sp), - timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY), - rotX, - rotY + timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY) ) ctx.strokeStyle = rgba(color, 0.2) ctx.lineWidth = 1.1 @@ -651,8 +639,8 @@ export function createPhosphorScore( if (!inView(link.t)) continue const d = wrapDelta(link.t, beat, LOOP_BEATS) const y = timeY(d, ppb, playY) - const a = project(pitchX(0, link.p0, gap, sp), y, rotX, rotY) - const b = project(pitchX(1, link.p1, gap, sp), y, rotX, rotY) + const a = project(pitchX(0, link.p0, gap, sp), y) + const b = project(pitchX(1, link.p1, gap, sp), y) ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18) ctx.lineWidth = 1 ctx.beginPath() @@ -676,8 +664,8 @@ export function createPhosphorScore( const x0 = pitchX(note.staff, note.pitch, gap, sp) const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp) return { - head: project(x0, y, rotX, rotY), - tip: project(x1, y, rotX, rotY), + head: project(x0, y), + tip: project(x1, y), d, } } @@ -719,11 +707,11 @@ export function createPhosphorScore( } const playheadPts: Pt[] = [] - for (let i = 0; i <= 12; i++) { - const x = lerp(-gap - sp * 8, gap + sp * 8, i / 12) - playheadPts.push(project(x, playY, rotX, rotY)) + for (let i = 0; i <= 18; i++) { + const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18) + playheadPts.push(project(x, playY)) } - ctx.strokeStyle = rgba(mixWhite(color, 0.4), 0.18 * glow) + ctx.strokeStyle = rgba(color, 0.22 * BLOOM) ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y) @@ -734,22 +722,21 @@ export function createPhosphorScore( ctx.globalCompositeOperation = "lighter" const scan = playheadPts[Math.floor(playheadPts.length / 2)]! - glowBlob(scan, 90 * glow, rgba(color, 0.07 * glow)) + glowBlob(scan, 110 * BLOOM, rgba(color, 0.1 * BLOOM)) for (const note of score.notes) { const d = wrapDelta(note.t, beat, LOOP_BEATS) if (d > visMax || d < visMin) continue const y = timeY(d, ppb, playY) const x = pitchX(note.staff, note.pitch, gap, sp) - const pt = project(x, y, rotX, rotY) + const pt = project(x, y) const intens = nearPlay(d) const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2 const headR = Math.max(2.4, sp * 0.38) - const fillAmt = mixWhite(color, intens * 0.85) const alpha = upcoming + intens * 0.8 if (note.acc !== 0 && d > -0.4) { - const accPt = project(x, y - sp * 0.9, rotX, rotY) + const accPt = project(x, y - sp * 0.9) ctx.fillStyle = rgba(color, 0.25 + intens * 0.5) ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif` ctx.fillText(note.acc > 0 ? "#" : "b", accPt.x - 4, accPt.y) @@ -759,8 +746,8 @@ export function createPhosphorScore( const ledgerPitch = note.pitch < 0 ? -2 : 10 const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp) const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp) - const la = project(lx0, y, rotX, rotY) - const lb = project(lx1, y, rotX, rotY) + const la = project(lx0, y) + const lb = project(lx1, y) ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3) ctx.lineWidth = 1 ctx.beginPath() @@ -772,13 +759,13 @@ export function createPhosphorScore( if (intens > 0.05) { glowBlob( pt, - (18 + intens * 42) * glow, - rgba(fillAmt, (0.12 + intens * 0.38) * glow) + (22 + intens * 52) * BLOOM, + rgba(color, (0.16 + intens * 0.42) * BLOOM) ) } ctx.globalCompositeOperation = "source-over" - drawOval(pt, headR, rgba(fillAmt, clamp(alpha, 0.2, 1))) + drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1))) ctx.globalCompositeOperation = "lighter" if (d <= 0 && d > -0.08 && intens > 0.7) { @@ -789,8 +776,8 @@ export function createPhosphorScore( x, y: playY, age: 0, - life: 0.42 + glow * 0.12, - size: 22 + glow * 18, + life: 0.48, + size: 36, }) if (hit.size > 400) hit.clear() } @@ -805,40 +792,28 @@ export function createPhosphorScore( flares.splice(i, 1) continue } - const pt = project(flare.x, flare.y, rotX, rotY) + const pt = project(flare.x, flare.y) const fade = Math.sin((1 - t) * Math.PI) glowBlob( pt, - flare.size * (0.8 + t * 1.6) * glow, - rgba(mixWhite(color, 0.9), 0.55 * fade * glow) + flare.size * (0.8 + t * 1.6) * BLOOM, + rgba(color, 0.5 * fade * BLOOM) ) ctx.save() ctx.translate(pt.x, pt.y) - ctx.rotate(0) - const flareW = 70 * glow * (1 + t) * pt.s - const flareH = 2.2 * pt.s + ctx.rotate(cam.z) + const flareW = 90 * BLOOM * (1 + t) * pt.s + const flareH = 2.4 * pt.s const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0) - fg.addColorStop(0, "rgba(255,255,255,0)") - fg.addColorStop(0.5, rgba(mixWhite(color, 1), 0.7 * fade)) - fg.addColorStop(1, "rgba(255,255,255,0)") + fg.addColorStop(0, rgba(color, 0)) + fg.addColorStop(0.5, rgba(color, 0.75 * fade)) + fg.addColorStop(1, rgba(color, 0)) ctx.fillStyle = fg ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) ctx.restore() } ctx.globalCompositeOperation = "source-over" - const vig = ctx.createRadialGradient( - w * 0.5, - h * 0.52, - Math.min(w, h) * 0.2, - w * 0.5, - h * 0.52, - Math.max(w, h) * 0.72 - ) - vig.addColorStop(0, "rgba(0,0,0,0)") - vig.addColorStop(1, "rgba(0,0,0,0.55)") - ctx.fillStyle = vig - ctx.fillRect(0, 0, w, h) raf = requestAnimationFrame(frame) } diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index c1a8a54..41577aa 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -7,9 +7,9 @@ createPhosphorScore, DEFAULT_COLOR, DEFAULT_DENSITY, - DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, + DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, type PhosphorScoreInstance, @@ -27,9 +27,9 @@ let { class: className = "", color = DEFAULT_COLOR, - glow = DEFAULT_GLOW, rotateX = DEFAULT_ROTATE_X, rotateY = DEFAULT_ROTATE_Y, + rotateZ = DEFAULT_ROTATE_Z, speed = DEFAULT_SPEED, density = DEFAULT_DENSITY, sway = true, @@ -43,9 +43,9 @@ if (!canvas) return instance = createPhosphorScore(canvas, { color, - glow, rotateX, rotateY, + rotateZ, speed, density, sway, @@ -60,9 +60,9 @@ $effect(() => { instance?.setOptions({ color, - glow, rotateX, rotateY, + rotateZ, speed, density, sway, diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index 913f6aa..97fa98e 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -8,9 +8,9 @@ import { createPhosphorScore, DEFAULT_COLOR, DEFAULT_DENSITY, - DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, + DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, type PhosphorScoreInstance, @@ -20,9 +20,9 @@ import { export { DEFAULT_COLOR, DEFAULT_DENSITY, - DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, + DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, } from "./phosphor-score-vanilla" @@ -42,9 +42,9 @@ export type PhosphorScoreProps = PhosphorScoreOptions & { export function PhosphorScore({ className, color = DEFAULT_COLOR, - glow = DEFAULT_GLOW, rotateX = DEFAULT_ROTATE_X, rotateY = DEFAULT_ROTATE_Y, + rotateZ = DEFAULT_ROTATE_Z, speed = DEFAULT_SPEED, density = DEFAULT_DENSITY, sway = true, @@ -58,9 +58,9 @@ export function PhosphorScore({ if (!canvas) return instanceRef.current = createPhosphorScore(canvas, { color, - glow, rotateX, rotateY, + rotateZ, speed, density, sway, @@ -77,15 +77,15 @@ export function PhosphorScore({ useEffect(() => { instanceRef.current?.setOptions({ color, - glow, rotateX, rotateY, + rotateZ, speed, density, sway, seed, }) - }, [color, glow, rotateX, rotateY, speed, density, sway, seed]) + }, [color, rotateX, rotateY, rotateZ, speed, density, sway, seed]) return (
Date: Tue, 8 Sep 2026 06:06:07 +0000 Subject: [PATCH 3/5] fix: tighten phosphor bloom to 50 and add a light theme Replace the huge pixelated playhead disc with a small note-scale halo, default glow at 50, and follow light/dark with forest ink on paper. Co-authored-by: Jay Sharma --- content/docs/components/phosphor-score.mdx | 58 +++--- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-demo.tsx | 71 +++++-- .../phosphor-score/phosphor-score-vanilla.ts | 179 +++++++++++++----- registry/phosphor-score/phosphor-score.svelte | 23 ++- registry/phosphor-score/phosphor-score.tsx | 62 +++++- 7 files changed, 287 insertions(+), 110 deletions(-) diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index bbe8126..6ab0b4d 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -5,7 +5,7 @@ description: Vertical CRT sheet music — notes slide down the staves, bloom at import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo" -A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. `rotateX` / `rotateY` / `rotateZ` tilt the plane; bloom stays the same color as the notes. `sway` keeps a slow camera drift. Press `d` if you want the docs chrome in the other theme — the canvas stays black. +A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. Default `theme="auto"` follows the site — phosphor on black, forest ink on paper. `glow` is 0–100 (default `50`) and stays a tight halo on the note. Press `d` to toggle. @@ -19,7 +19,7 @@ Or install straight from the public GitHub source registry: ## Usage -Give the parent a size. The canvas is opaque black. +Give the parent a size. The canvas fills the parent. @@ -30,7 +30,7 @@ import { PhosphorScore } from "@/components/ui/phosphor-score" export function HeroScore() { return ( -
+
) @@ -43,38 +43,26 @@ export function HeroScore() { import PhosphorScore from "$lib/components/ui/phosphor-score.svelte" -
+
``` -Tilt it and pick a phosphor. Bloom follows `color`. +Lock a theme, tilt it, or push the bloom. Omit `color` to follow light / dark. ```tsx - + + ``` ```svelte - + + ``` @@ -83,15 +71,19 @@ Tilt it and pick a phosphor. Bloom follows `color`. ## Props -| Prop | Type | Default | -| ----------- | --------- | -------------------------------- | -| `color` | `string` | `"#4DFF6A"` — notes and bloom | -| `rotateX` | `number` | `16` — tilt back, degrees | -| `rotateY` | `number` | `-12` — yaw, degrees | -| `rotateZ` | `number` | `0` — roll, degrees | -| `speed` | `number` | `1.35` — beats per second | -| `density` | `number` | `1` — how packed the notation is | -| `sway` | `boolean` | `true` — slow camera drift | -| `seed` | `number` | `23` | -| `className` | `string` | React only | -| `class` | `string` | Svelte only | +| Prop | Type | Default | +| ----------- | ----------------------------- | --------------------------------------- | +| `color` | `string` | theme phosphor / forest ink | +| `glow` | `number` | `50` — 0–100, halo size at the playhead | +| `rotateX` | `number` | `16` — tilt back, degrees | +| `rotateY` | `number` | `-12` — yaw, degrees | +| `rotateZ` | `number` | `0` — roll, degrees | +| `speed` | `number` | `1.35` — beats per second | +| `density` | `number` | `1` — how packed the notation is | +| `sway` | `boolean` | `true` — slow camera drift | +| `seed` | `number` | `23` | +| `theme` | `"light" \| "dark" \| "auto"` | `"auto"` | +| `className` | `string` | React only | +| `class` | `string` | Svelte only | + +Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index 6efa069..587d26a 100644 --- a/public/r/phosphor-score-svelte.json +++ b/public/r/phosphor-score-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.svelte", - "content": "\n\n\n\n\n \n
\n", + "content": "\n\n\n\n\n \n
\n", "type": "registry:file", "target": "src/lib/components/ui/phosphor-score.svelte" } diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json index f089cbb..bd97437 100644 --- a/public/r/phosphor-score.json +++ b/public/r/phosphor-score.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreOptions = {\n /** Ink / phosphor color. Default `#4DFF6A`. */\n color?: string\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /**\n * Roll the score plane, in degrees. Default `0`.\n */\n rotateZ?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\nexport const DEFAULT_COLOR = \"#4DFF6A\"\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_ROTATE_Z = 0\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\nconst BLOOM = 1\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -80, 80)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: Required<\n Pick<\n PhosphorScoreOptions,\n | \"color\"\n | \"rotateX\"\n | \"rotateY\"\n | \"rotateZ\"\n | \"speed\"\n | \"density\"\n | \"sway\"\n | \"seed\"\n >\n > = {\n color: DEFAULT_COLOR,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n rotateZ: DEFAULT_ROTATE_Z,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let rgb = parseColor(options.color)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(options.color)\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n\n const project = (x: number, y: number): Pt => {\n const cosX = Math.cos(cam.x)\n const sinX = Math.sin(cam.x)\n const cosY = Math.cos(cam.y)\n const sinY = Math.sin(cam.y)\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const xz = x * cosZ - y * sinZ\n const yz = x * sinZ + y * cosZ\n const y1 = yz * cosX\n const z1 = yz * sinX\n const x2 = xz * cosY - z1 * sinY\n const z2 = xz * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 2.4\n const persp = fov / (fov + z2)\n const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z)\n const fit = 0.78 / (1 + tilt * 0.35)\n return {\n x: size.w * 0.5 + x2 * persp * fit,\n y: size.h * 0.5 + y1 * persp * fit,\n s: persp * fit,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, color: string) => {\n const r = radius * pt.s\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, color)\n g.addColorStop(1, \"rgba(0,0,0,0)\")\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.014, 7, 13)\n const gap = clamp(w * 0.16, 52, 96)\n const ppb = clamp(h * 0.075, 28, 64)\n const playY = h * 0.12\n const swayOn = options.sway !== false && !reduce\n cam.x =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0)\n cam.y =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0)\n cam.z =\n (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0)\n const color = rgb\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = \"#050505\"\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, d.a)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -8\n const visMax = h / ppb + 8\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * BLOOM)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 18; i++) {\n const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18)\n playheadPts.push(project(x, playY))\n }\n ctx.strokeStyle = rgba(color, 0.22 * BLOOM)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n ctx.globalCompositeOperation = \"lighter\"\n const scan = playheadPts[Math.floor(playheadPts.length / 2)]!\n glowBlob(scan, 110 * BLOOM, rgba(color, 0.1 * BLOOM))\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.05) {\n glowBlob(\n pt,\n (22 + intens * 52) * BLOOM,\n rgba(color, (0.16 + intens * 0.42) * BLOOM)\n )\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.globalCompositeOperation = \"lighter\"\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.48,\n size: 36,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(\n pt,\n flare.size * (0.8 + t * 1.6) * BLOOM,\n rgba(color, 0.5 * fade * BLOOM)\n )\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 90 * BLOOM * (1 + t) * pt.s\n const flareH = 2.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, 0.75 * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n }\n rgb = parseColor(options.color)\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n },\n }\n}\n\nexport type PhosphorScoreProps = PhosphorScoreOptions & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Transparent to layout; fills the parent.\n */\nexport function PhosphorScore({\n className,\n color = DEFAULT_COLOR,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n rotateZ = DEFAULT_ROTATE_Z,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n })\n }, [color, rotateX, rotateY, rotateZ, speed, density, sway, seed])\n\n return (\n \n \n
\n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /**\n * Roll the score plane, in degrees. Default `0`.\n */\n rotateZ?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on paper */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DARK_BG = \"#050505\"\nexport const LIGHT_BG = \"#F4F1E8\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_ROTATE_Z = 0\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -80, 80)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n rotateZ: DEFAULT_ROTATE_Z,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n\n const project = (x: number, y: number): Pt => {\n const cosX = Math.cos(cam.x)\n const sinX = Math.sin(cam.x)\n const cosY = Math.cos(cam.y)\n const sinY = Math.sin(cam.y)\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const xz = x * cosZ - y * sinZ\n const yz = x * sinZ + y * cosZ\n const y1 = yz * cosX\n const z1 = yz * sinX\n const x2 = xz * cosY - z1 * sinY\n const z2 = xz * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 2.4\n const persp = fov / (fov + z2)\n const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z)\n const fit = 0.78 / (1 + tilt * 0.35)\n return {\n x: size.w * 0.5 + x2 * persp * fit,\n y: size.h * 0.5 + y1 * persp * fit,\n s: persp * fit,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.014, 7, 13)\n const gap = clamp(w * 0.16, 52, 96)\n const ppb = clamp(h * 0.075, 28, 64)\n const playY = h * 0.12\n const swayOn = options.sway !== false && !reduce\n cam.x =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0)\n cam.y =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0)\n cam.z =\n (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = resolveBg(dark)\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -8\n const visMax = h / ppb + 8\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 18; i++) {\n const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18)\n playheadPts.push(project(x, playY))\n }\n ctx.strokeStyle = rgba(color, 0.28 * Math.min(1, bloom))\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n rotateZ = DEFAULT_ROTATE_Z,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n ])\n\n return (\n \n \n
\n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index 71cf3c7..dd7605f 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -1,5 +1,7 @@ "use client" +import { useLayoutEffect, useMemo } from "react" + import { ComponentControls, ControlColor, @@ -7,46 +9,76 @@ import { ControlSwitch, } from "@/components/component-controls" import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { - DEFAULT_COLOR, + DARK_COLOR, DEFAULT_DENSITY, + DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, DEFAULT_ROTATE_Z, DEFAULT_SPEED, + LIGHT_COLOR, PhosphorScore, } from "@/registry/phosphor-score/phosphor-score" -const DEFAULTS = { - color: DEFAULT_COLOR, - rotateX: DEFAULT_ROTATE_X, - rotateY: DEFAULT_ROTATE_Y, - rotateZ: DEFAULT_ROTATE_Z, - speed: DEFAULT_SPEED, - density: DEFAULT_DENSITY, - sway: true, +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function isStockColor(color: string) { + return norm(color) === norm(LIGHT_COLOR) || norm(color) === norm(DARK_COLOR) } export function PhosphorScoreDemo() { - const { props, updateProp, resetProps, hasChanges } = - usePreviewProps(DEFAULTS) + const theme = useHydratedTheme() + const stock = theme === "dark" ? DARK_COLOR : LIGHT_COLOR + + const defaults = useMemo( + () => ({ + color: stock, + glow: DEFAULT_GLOW, + rotateX: DEFAULT_ROTATE_X, + rotateY: DEFAULT_ROTATE_Y, + rotateZ: DEFAULT_ROTATE_Z, + speed: DEFAULT_SPEED, + density: DEFAULT_DENSITY, + sway: true, + }), + [stock] + ) + + const { props, updateProp, resetProps, hasChanges, setProps } = + usePreviewProps(defaults) + + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockColor(prev.color)) return prev + if (norm(prev.color) === norm(stock)) return prev + return { ...prev, color: stock } + }) + }, [setProps, stock]) + + const useAutoColor = isStockColor(props.color) return ( <> -
+
@@ -56,7 +88,8 @@ export function PhosphorScoreDemo() { onReset={resetProps} component="PhosphorScore" snippetProps={{ - color: props.color === DEFAULT_COLOR ? undefined : props.color, + color: useAutoColor ? undefined : props.color, + glow: props.glow === DEFAULT_GLOW ? undefined : props.glow, rotateX: props.rotateX === DEFAULT_ROTATE_X ? undefined : props.rotateX, rotateY: @@ -74,6 +107,14 @@ export function PhosphorScoreDemo() { value={props.color} onChange={(v) => updateProp("color", v)} /> + updateProp("glow", v)} + /> void } export type PhosphorScoreInstance = { @@ -28,14 +45,21 @@ export type PhosphorScoreInstance = { destroy: () => void } -export const DEFAULT_COLOR = "#4DFF6A" +/** Phosphor on black */ +export const DARK_COLOR = "#4DFF6A" +/** Forest ink on paper */ +export const LIGHT_COLOR = "#147A3A" +/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */ +export const DEFAULT_COLOR = DARK_COLOR +export const DARK_BG = "#050505" +export const LIGHT_BG = "#F4F1E8" +export const DEFAULT_GLOW = 50 export const DEFAULT_ROTATE_X = 16 export const DEFAULT_ROTATE_Y = -12 export const DEFAULT_ROTATE_Z = 0 export const DEFAULT_SPEED = 1.35 export const DEFAULT_DENSITY = 1 export const DEFAULT_SEED = 23 -const BLOOM = 1 const LOOP_BEATS = 48 const DYNAMICS = ["pp", "p", "mp", "mf", "f", "ff"] as const @@ -143,6 +167,11 @@ function rgba(rgb: Rgb, a: number) { return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})` } +function glowOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GLOW + return clamp(value, 0, 100) +} + function rotateOf(value: number | undefined, fallback: number) { if (typeof value !== "number" || !Number.isFinite(value)) return fallback return clamp(value, -80, 80) @@ -165,6 +194,45 @@ function seedOf(value?: number) { return Math.round(value) >>> 0 } +function themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme { + return value === "light" || value === "dark" || value === "auto" + ? value + : "auto" +} + +function optionalColor(value?: string) { + const next = value?.trim() + return next ? next : undefined +} + +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: PhosphorScoreTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +export function resolveColor(color: string | undefined, dark: boolean) { + const custom = optionalColor(color) + if (custom) return custom + return dark ? DARK_COLOR : LIGHT_COLOR +} + +export function resolveBg(dark: boolean) { + return dark ? DARK_BG : LIGHT_BG +} + type Score = { notes: Note[] marks: Mark[] @@ -330,20 +398,8 @@ export function createPhosphorScore( canvas: HTMLCanvasElement, initial: PhosphorScoreOptions = {} ): PhosphorScoreInstance | null { - let options: Required< - Pick< - PhosphorScoreOptions, - | "color" - | "rotateX" - | "rotateY" - | "rotateZ" - | "speed" - | "density" - | "sway" - | "seed" - > - > = { - color: DEFAULT_COLOR, + let options: PhosphorScoreOptions = { + glow: DEFAULT_GLOW, rotateX: DEFAULT_ROTATE_X, rotateY: DEFAULT_ROTATE_Y, rotateZ: DEFAULT_ROTATE_Z, @@ -352,6 +408,7 @@ export function createPhosphorScore( seed: DEFAULT_SEED, ...initial, sway: initial.sway ?? true, + theme: themeOf(initial.theme), } const ctx = canvas.getContext("2d", { alpha: false }) @@ -360,7 +417,9 @@ export function createPhosphorScore( const size = { w: 0, h: 0 } let score = generateScore(seedOf(options.seed), densityOf(options.density)) let dust = makeDust(seedOf(options.seed)) - let rgb = parseColor(options.color) + let dark = resolveDark(themeOf(options.theme)) + let rgb = parseColor(resolveColor(options.color, dark)) + options.onThemeChange?.(dark) const flares: Flare[] = [] const hit = new Set() let reduce = false @@ -396,10 +455,25 @@ export function createPhosphorScore( onReduce() mqReduce.addEventListener("change", onReduce) + const syncTheme = () => { + const next = resolveDark(themeOf(options.theme)) + if (next === dark) return + dark = next + rgb = parseColor(resolveColor(options.color, dark)) + options.onThemeChange?.(dark) + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + const rebuild = () => { score = generateScore(seedOf(options.seed), densityOf(options.density)) dust = makeDust(seedOf(options.seed)) - rgb = parseColor(options.color) + rgb = parseColor(resolveColor(options.color, dark)) hit.clear() flares.length = 0 } @@ -461,11 +535,13 @@ export function createPhosphorScore( ctx.restore() } - const glowBlob = (pt: Pt, radius: number, color: string) => { - const r = radius * pt.s + const glowBlob = (pt: Pt, radius: number, alpha: number) => { + const r = Math.max(1.2, radius * pt.s) const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r) - g.addColorStop(0, color) - g.addColorStop(1, "rgba(0,0,0,0)") + g.addColorStop(0, rgba(rgb, alpha)) + g.addColorStop(0.28, rgba(rgb, alpha * 0.42)) + g.addColorStop(0.62, rgba(rgb, alpha * 0.1)) + g.addColorStop(1, rgba(rgb, 0)) ctx.fillStyle = g ctx.beginPath() ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2) @@ -496,10 +572,13 @@ export function createPhosphorScore( cam.z = (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 + (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0) - const color = rgb + const bloom = glowOf(options.glow) / 50 + const color = parseColor(resolveColor(options.color, dark)) + rgb = color + ctx.imageSmoothingEnabled = true ctx.globalCompositeOperation = "source-over" - ctx.fillStyle = "#050505" + ctx.fillStyle = resolveBg(dark) ctx.fillRect(0, 0, w, h) ctx.save() @@ -507,7 +586,7 @@ export function createPhosphorScore( for (const d of dust) { const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1 const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1 - ctx.fillStyle = rgba(color, d.a) + ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28) ctx.fillRect(px * w, py * h, d.s, d.s) } ctx.restore() @@ -539,7 +618,7 @@ export function createPhosphorScore( started = true } else ctx.lineTo(pt.x, pt.y) if (i === Math.floor(steps * 0.62)) { - ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * BLOOM) + ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom) ctx.lineWidth = 1 ctx.stroke() ctx.beginPath() @@ -711,7 +790,7 @@ export function createPhosphorScore( const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18) playheadPts.push(project(x, playY)) } - ctx.strokeStyle = rgba(color, 0.22 * BLOOM) + ctx.strokeStyle = rgba(color, 0.28 * Math.min(1, bloom)) ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y) @@ -720,9 +799,8 @@ export function createPhosphorScore( } ctx.stroke() - ctx.globalCompositeOperation = "lighter" - const scan = playheadPts[Math.floor(playheadPts.length / 2)]! - glowBlob(scan, 110 * BLOOM, rgba(color, 0.1 * BLOOM)) + const bloomOp = dark ? "lighter" : "source-over" + ctx.globalCompositeOperation = bloomOp for (const note of score.notes) { const d = wrapDelta(note.t, beat, LOOP_BEATS) @@ -756,17 +834,20 @@ export function createPhosphorScore( ctx.stroke() } - if (intens > 0.05) { - glowBlob( - pt, - (22 + intens * 52) * BLOOM, - rgba(color, (0.16 + intens * 0.42) * BLOOM) - ) + if (intens > 0.04 && bloom > 0) { + const halo = (5 + intens * 9) * bloom + glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom)) } ctx.globalCompositeOperation = "source-over" + ctx.save() + if (intens > 0.2 && bloom > 0) { + ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45) + ctx.shadowBlur = (3.5 + intens * 5) * bloom + } drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1))) - ctx.globalCompositeOperation = "lighter" + ctx.restore() + ctx.globalCompositeOperation = bloomOp if (d <= 0 && d > -0.08 && intens > 0.7) { const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0 @@ -776,8 +857,8 @@ export function createPhosphorScore( x, y: playY, age: 0, - life: 0.48, - size: 36, + life: 0.38, + size: 10, }) if (hit.size > 400) hit.clear() } @@ -794,19 +875,15 @@ export function createPhosphorScore( } const pt = project(flare.x, flare.y) const fade = Math.sin((1 - t) * Math.PI) - glowBlob( - pt, - flare.size * (0.8 + t * 1.6) * BLOOM, - rgba(color, 0.5 * fade * BLOOM) - ) + glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade) ctx.save() ctx.translate(pt.x, pt.y) ctx.rotate(cam.z) - const flareW = 90 * BLOOM * (1 + t) * pt.s - const flareH = 2.4 * pt.s + const flareW = 22 * bloom * (1 + t * 0.4) * pt.s + const flareH = 1.4 * pt.s const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0) fg.addColorStop(0, rgba(color, 0)) - fg.addColorStop(0.5, rgba(color, 0.75 * fade)) + fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade)) fg.addColorStop(1, rgba(color, 0)) ctx.fillStyle = fg ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) @@ -828,8 +905,10 @@ export function createPhosphorScore( ...options, ...next, sway: next.sway ?? options.sway, + theme: themeOf(next.theme ?? options.theme), } - rgb = parseColor(options.color) + syncTheme() + rgb = parseColor(resolveColor(options.color, dark)) if ( seedOf(options.seed) !== prevSeed || densityOf(options.density) !== prevDens @@ -841,7 +920,9 @@ export function createPhosphorScore( running = false cancelAnimationFrame(raf) ro.disconnect() + mo.disconnect() mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) }, } } diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index 41577aa..d4acf50 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -5,13 +5,16 @@ import { onMount } from "svelte" import { createPhosphorScore, - DEFAULT_COLOR, + DARK_BG, DEFAULT_DENSITY, + DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, + LIGHT_BG, + resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, } from "./phosphor-score-vanilla" @@ -20,13 +23,14 @@ return parts.filter(Boolean).join(" ") } - interface Props extends PhosphorScoreOptions { + interface Props extends Omit { class?: string } let { class: className = "", - color = DEFAULT_COLOR, + color, + glow = DEFAULT_GLOW, rotateX = DEFAULT_ROTATE_X, rotateY = DEFAULT_ROTATE_Y, rotateZ = DEFAULT_ROTATE_Z, @@ -34,15 +38,19 @@ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + theme = "auto", }: Props = $props() let canvas: HTMLCanvasElement | undefined = $state() + let isDark = $state(false) let instance: PhosphorScoreInstance | null = null onMount(() => { + isDark = resolveDark(theme) if (!canvas) return instance = createPhosphorScore(canvas, { color, + glow, rotateX, rotateY, rotateZ, @@ -50,6 +58,10 @@ density, sway, seed, + theme, + onThemeChange: (dark) => { + isDark = dark + }, }) return () => { instance?.destroy() @@ -60,6 +72,7 @@ $effect(() => { instance?.setOptions({ color, + glow, rotateX, rotateY, rotateZ, @@ -67,6 +80,7 @@ density, sway, seed, + theme, }) }) @@ -75,7 +89,8 @@ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - class={cn("absolute inset-0 overflow-hidden bg-black", className)} + class={cn("absolute inset-0 overflow-hidden", className)} + style="background-color: {isDark ? DARK_BG : LIGHT_BG}" >
diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index 97fa98e..4988c19 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -1,47 +1,59 @@ "use client" -import { useEffect, useRef } from "react" +import { useEffect, useRef, useState } from "react" import { cn } from "@/lib/utils" import { createPhosphorScore, - DEFAULT_COLOR, + DARK_BG, DEFAULT_DENSITY, + DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, + LIGHT_BG, + resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, } from "./phosphor-score-vanilla" export { + DARK_BG, + DARK_COLOR, DEFAULT_COLOR, DEFAULT_DENSITY, + DEFAULT_GLOW, DEFAULT_ROTATE_X, DEFAULT_ROTATE_Y, DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, + LIGHT_BG, + LIGHT_COLOR, + resolveColor, + resolveDark, } from "./phosphor-score-vanilla" export type { PhosphorScoreInstance, PhosphorScoreOptions, + PhosphorScoreTheme, } from "./phosphor-score-vanilla" -export type PhosphorScoreProps = PhosphorScoreOptions & { +export type PhosphorScoreProps = Omit & { className?: string } /** * Vertical phosphor sheet music — notes fall toward a playhead, bloom, - * then exit in a flare. Transparent to layout; fills the parent. + * then exit in a flare. Follows light and dark. */ export function PhosphorScore({ className, - color = DEFAULT_COLOR, + color, + glow = DEFAULT_GLOW, rotateX = DEFAULT_ROTATE_X, rotateY = DEFAULT_ROTATE_Y, rotateZ = DEFAULT_ROTATE_Z, @@ -49,15 +61,34 @@ export function PhosphorScore({ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + theme = "auto", }: PhosphorScoreProps) { const canvasRef = useRef(null) const instanceRef = useRef(null) + const [isDark, setIsDark] = useState(false) + + useEffect(() => { + const sync = () => setIsDark(resolveDark(theme)) + sync() + const mo = new MutationObserver(sync) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", sync) + return () => { + mo.disconnect() + mq.removeEventListener("change", sync) + } + }, [theme]) useEffect(() => { const canvas = canvasRef.current if (!canvas) return instanceRef.current = createPhosphorScore(canvas, { color, + glow, rotateX, rotateY, rotateZ, @@ -65,6 +96,8 @@ export function PhosphorScore({ density, sway, seed, + theme, + onThemeChange: setIsDark, }) return () => { instanceRef.current?.destroy() @@ -77,6 +110,7 @@ export function PhosphorScore({ useEffect(() => { instanceRef.current?.setOptions({ color, + glow, rotateX, rotateY, rotateZ, @@ -84,15 +118,29 @@ export function PhosphorScore({ density, sway, seed, + theme, + onThemeChange: setIsDark, }) - }, [color, rotateX, rotateY, rotateZ, speed, density, sway, seed]) + }, [ + color, + glow, + rotateX, + rotateY, + rotateZ, + speed, + density, + sway, + seed, + theme, + ]) return (
From 1295f621dde3b6bd787e456de9c148ca99bc402a Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 8 Sep 2026 12:20:38 +0530 Subject: [PATCH 4/5] fix: drop rotate, fit the score, and fade the edges Remove rotateX/Y/Z. Light mode is transparent over the page background. Scale the notation to the canvas with a thinner full-width playhead, a tighter gap between staves, and blur fades at the top and bottom. --- content/docs/components/phosphor-score.mdx | 13 +- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-demo.tsx | 39 ------ .../phosphor-score/phosphor-score-vanilla.ts | 125 ++++++++---------- registry/phosphor-score/phosphor-score.svelte | 52 +++++--- registry/phosphor-score/phosphor-score.tsx | 83 +++++++----- 7 files changed, 148 insertions(+), 168 deletions(-) diff --git a/content/docs/components/phosphor-score.mdx b/content/docs/components/phosphor-score.mdx index 6ab0b4d..34260ca 100644 --- a/content/docs/components/phosphor-score.mdx +++ b/content/docs/components/phosphor-score.mdx @@ -5,7 +5,7 @@ description: Vertical CRT sheet music — notes slide down the staves, bloom at import { PhosphorScoreDemo } from "@/registry/phosphor-score/phosphor-score-demo" -A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. Default `theme="auto"` follows the site — phosphor on black, forest ink on paper. `glow` is 0–100 (default `50`) and stays a tight halo on the note. Press `d` to toggle. +A falling score, not a piano roll. Two vertical staves, beamed runs, dynamics, and a scan line: notes dim on the way down, bloom in the phosphor color as they play, then flare out. Default `theme="auto"` follows the site — phosphor on black, forest ink on the page background. `glow` is 0–100 (default `50`) and stays a tight halo on the note. Press `d` to toggle. @@ -50,18 +50,18 @@ export function HeroScore() {
-Lock a theme, tilt it, or push the bloom. Omit `color` to follow light / dark. +Lock a theme or push the bloom. Omit `color` to follow light / dark. ```tsx - + ``` ```svelte - + ``` @@ -75,9 +75,6 @@ Lock a theme, tilt it, or push the bloom. Omit `color` to follow light / dark. | ----------- | ----------------------------- | --------------------------------------- | | `color` | `string` | theme phosphor / forest ink | | `glow` | `number` | `50` — 0–100, halo size at the playhead | -| `rotateX` | `number` | `16` — tilt back, degrees | -| `rotateY` | `number` | `-12` — yaw, degrees | -| `rotateZ` | `number` | `0` — roll, degrees | | `speed` | `number` | `1.35` — beats per second | | `density` | `number` | `1` — how packed the notation is | | `sway` | `boolean` | `true` — slow camera drift | @@ -86,4 +83,4 @@ Lock a theme, tilt it, or push the bloom. Omit `color` to follow light / dark. | `className` | `string` | React only | | `class` | `string` | Svelte only | -Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. +Omit `color` and `theme="auto"` picks phosphor green on dark, forest ink on light. The canvas is transparent in light mode so the parent background shows through. diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index 587d26a..a37fc5e 100644 --- a/public/r/phosphor-score-svelte.json +++ b/public/r/phosphor-score-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.svelte", - "content": "\n\n\n\n\n \n\n", + "content": "\n\n\n\n\n
\n \n \n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/phosphor-score.svelte" } diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json index bd97437..7695f30 100644 --- a/public/r/phosphor-score.json +++ b/public/r/phosphor-score.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /**\n * Tilt the score plane back, in degrees. Default `16`.\n */\n rotateX?: number\n /**\n * Yaw the score plane, in degrees. Default `-12`.\n */\n rotateY?: number\n /**\n * Roll the score plane, in degrees. Default `0`.\n */\n rotateZ?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on paper */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DARK_BG = \"#050505\"\nexport const LIGHT_BG = \"#F4F1E8\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_ROTATE_X = 16\nexport const DEFAULT_ROTATE_Y = -12\nexport const DEFAULT_ROTATE_Z = 0\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction rotateOf(value: number | undefined, fallback: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, -80, 80)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n rotateX: DEFAULT_ROTATE_X,\n rotateY: DEFAULT_ROTATE_Y,\n rotateZ: DEFAULT_ROTATE_Z,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: false })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n\n const project = (x: number, y: number): Pt => {\n const cosX = Math.cos(cam.x)\n const sinX = Math.sin(cam.x)\n const cosY = Math.cos(cam.y)\n const sinY = Math.sin(cam.y)\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const xz = x * cosZ - y * sinZ\n const yz = x * sinZ + y * cosZ\n const y1 = yz * cosX\n const z1 = yz * sinX\n const x2 = xz * cosY - z1 * sinY\n const z2 = xz * sinY + z1 * cosY\n const fov = Math.max(size.h, 420) * 2.4\n const persp = fov / (fov + z2)\n const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z)\n const fit = 0.78 / (1 + tilt * 0.35)\n return {\n x: size.w * 0.5 + x2 * persp * fit,\n y: size.h * 0.5 + y1 * persp * fit,\n s: persp * fit,\n z: z2,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const sp = clamp(Math.min(w, h) * 0.014, 7, 13)\n const gap = clamp(w * 0.16, 52, 96)\n const ppb = clamp(h * 0.075, 28, 64)\n const playY = h * 0.12\n const swayOn = options.sway !== false && !reduce\n cam.x =\n (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0)\n cam.y =\n (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 +\n (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0)\n cam.z =\n (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 +\n (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n ctx.fillStyle = resolveBg(dark)\n ctx.fillRect(0, 0, w, h)\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = -8\n const visMax = h / ppb + 8\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playheadPts: Pt[] = []\n for (let i = 0; i <= 18; i++) {\n const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18)\n playheadPts.push(project(x, playY))\n }\n ctx.strokeStyle = rgba(color, 0.28 * Math.min(1, bloom))\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y)\n for (let i = 1; i < playheadPts.length; i++) {\n ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y)\n }\n ctx.stroke()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n rotateX = DEFAULT_ROTATE_X,\n rotateY = DEFAULT_ROTATE_Y,\n rotateZ = DEFAULT_ROTATE_Z,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [\n color,\n glow,\n rotateX,\n rotateY,\n rotateZ,\n speed,\n density,\n sway,\n seed,\n theme,\n ])\n\n return (\n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on the page background */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-background` shows through. */\nexport const LIGHT_BG = \"transparent\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n let frameFit = 0.78\n\n const project = (x: number, y: number): Pt => {\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const rx = x * cosZ - y * sinZ + cam.y\n const ry = x * sinZ + y * cosZ + cam.x\n const s = frameFit\n return {\n x: size.w * 0.5 + rx * s,\n y: size.h * 0.5 + ry * s,\n s,\n z: 0,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const swayOn = options.sway !== false && !reduce\n cam.x = swayOn ? Math.sin(clock * 0.31) * 10 : 0\n cam.y = swayOn ? Math.cos(clock * 0.23) * 14 : 0\n cam.z = swayOn ? Math.sin(clock * 0.17) * 0.018 : 0\n frameFit = 0.78\n\n const pad = Math.max(28, Math.min(w, h) * 0.1)\n const innerW = Math.max(1, w - pad * 2)\n const innerH = Math.max(1, h - pad * 2)\n let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28)\n const maxHalf = innerW / 2 / frameFit\n let gap = maxHalf * 0.4\n if (gap < sp * 7) {\n sp = clamp(maxHalf * 0.4 / 7, 10, 28)\n gap = maxHalf * 0.4\n }\n gap = Math.max(sp * 7, gap)\n const topY = -innerH / 2 / frameFit\n const playY = (innerH / 2 - sp * 2) / frameFit\n const ppb = Math.max(28, (playY - topY) / 7.6)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n if (dark) {\n ctx.fillStyle = DARK_BG\n ctx.fillRect(0, 0, w, h)\n } else {\n ctx.clearRect(0, 0, w, h)\n }\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = (playY - innerH / 2 / frameFit) / ppb\n const visMax = (playY - topY) / ppb\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playYScreen = project(0, playY).y\n ctx.save()\n ctx.lineCap = \"butt\"\n ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom))\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom))\n ctx.lineWidth = 0.75\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.restore()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\nfunction PhosphorEdgeFade({\n edge,\n dark,\n}: {\n edge: \"top\" | \"bottom\"\n dark: boolean\n}) {\n const isTop = edge === \"top\"\n return (\n <>\n \n \n \n )\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [color, glow, speed, density, sway, seed, theme])\n\n return (\n \n \n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index dd7605f..1d4c9d8 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -15,9 +15,6 @@ import { DARK_COLOR, DEFAULT_DENSITY, DEFAULT_GLOW, - DEFAULT_ROTATE_X, - DEFAULT_ROTATE_Y, - DEFAULT_ROTATE_Z, DEFAULT_SPEED, LIGHT_COLOR, PhosphorScore, @@ -39,9 +36,6 @@ export function PhosphorScoreDemo() { () => ({ color: stock, glow: DEFAULT_GLOW, - rotateX: DEFAULT_ROTATE_X, - rotateY: DEFAULT_ROTATE_Y, - rotateZ: DEFAULT_ROTATE_Z, speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, sway: true, @@ -72,9 +66,6 @@ export function PhosphorScoreDemo() { updateProp("glow", v)} /> - updateProp("rotateX", v)} - /> - updateProp("rotateY", v)} - /> - updateProp("rotateZ", v)} - /> { - const cosX = Math.cos(cam.x) - const sinX = Math.sin(cam.x) - const cosY = Math.cos(cam.y) - const sinY = Math.sin(cam.y) const cosZ = Math.cos(cam.z) const sinZ = Math.sin(cam.z) - const xz = x * cosZ - y * sinZ - const yz = x * sinZ + y * cosZ - const y1 = yz * cosX - const z1 = yz * sinX - const x2 = xz * cosY - z1 * sinY - const z2 = xz * sinY + z1 * cosY - const fov = Math.max(size.h, 420) * 2.4 - const persp = fov / (fov + z2) - const tilt = Math.abs(cam.x) + Math.abs(cam.y) + Math.abs(cam.z) - const fit = 0.78 / (1 + tilt * 0.35) + const rx = x * cosZ - y * sinZ + cam.y + const ry = x * sinZ + y * cosZ + cam.x + const s = frameFit return { - x: size.w * 0.5 + x2 * persp * fit, - y: size.h * 0.5 + y1 * persp * fit, - s: persp * fit, - z: z2, + x: size.w * 0.5 + rx * s, + y: size.h * 0.5 + ry * s, + s, + z: 0, } } @@ -558,28 +526,38 @@ export function createPhosphorScore( const w = size.w const h = size.h - const sp = clamp(Math.min(w, h) * 0.014, 7, 13) - const gap = clamp(w * 0.16, 52, 96) - const ppb = clamp(h * 0.075, 28, 64) - const playY = h * 0.12 const swayOn = options.sway !== false && !reduce - cam.x = - (rotateOf(options.rotateX, DEFAULT_ROTATE_X) * Math.PI) / 180 + - (swayOn ? Math.sin(clock * 0.33) * 0.11 : 0) - cam.y = - (rotateOf(options.rotateY, DEFAULT_ROTATE_Y) * Math.PI) / 180 + - (swayOn ? Math.cos(clock * 0.21) * 0.16 : 0) - cam.z = - (rotateOf(options.rotateZ, DEFAULT_ROTATE_Z) * Math.PI) / 180 + - (swayOn ? Math.sin(clock * 0.17) * 0.08 : 0) + cam.x = swayOn ? Math.sin(clock * 0.31) * 10 : 0 + cam.y = swayOn ? Math.cos(clock * 0.23) * 14 : 0 + cam.z = swayOn ? Math.sin(clock * 0.17) * 0.018 : 0 + frameFit = 0.78 + + const pad = Math.max(28, Math.min(w, h) * 0.1) + const innerW = Math.max(1, w - pad * 2) + const innerH = Math.max(1, h - pad * 2) + let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28) + const maxHalf = innerW / 2 / frameFit + let gap = maxHalf * 0.4 + if (gap < sp * 7) { + sp = clamp(maxHalf * 0.4 / 7, 10, 28) + gap = maxHalf * 0.4 + } + gap = Math.max(sp * 7, gap) + const topY = -innerH / 2 / frameFit + const playY = (innerH / 2 - sp * 2) / frameFit + const ppb = Math.max(28, (playY - topY) / 7.6) const bloom = glowOf(options.glow) / 50 const color = parseColor(resolveColor(options.color, dark)) rgb = color ctx.imageSmoothingEnabled = true ctx.globalCompositeOperation = "source-over" - ctx.fillStyle = resolveBg(dark) - ctx.fillRect(0, 0, w, h) + if (dark) { + ctx.fillStyle = DARK_BG + ctx.fillRect(0, 0, w, h) + } else { + ctx.clearRect(0, 0, w, h) + } ctx.save() ctx.globalAlpha = 1 @@ -591,8 +569,8 @@ export function createPhosphorScore( } ctx.restore() - const visMin = -8 - const visMax = h / ppb + 8 + const visMin = (playY - innerH / 2 / frameFit) / ppb + const visMax = (playY - topY) / ppb const inView = (t: number) => { const d = wrapDelta(t, beat, LOOP_BEATS) @@ -785,19 +763,22 @@ export function createPhosphorScore( } } - const playheadPts: Pt[] = [] - for (let i = 0; i <= 18; i++) { - const x = lerp(-gap - sp * 18, gap + sp * 18, i / 18) - playheadPts.push(project(x, playY)) - } - ctx.strokeStyle = rgba(color, 0.28 * Math.min(1, bloom)) - ctx.lineWidth = 1 + const playYScreen = project(0, playY).y + ctx.save() + ctx.lineCap = "butt" + ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom)) + ctx.lineWidth = 2 ctx.beginPath() - ctx.moveTo(playheadPts[0]!.x, playheadPts[0]!.y) - for (let i = 1; i < playheadPts.length; i++) { - ctx.lineTo(playheadPts[i]!.x, playheadPts[i]!.y) - } + ctx.moveTo(0, playYScreen) + ctx.lineTo(w, playYScreen) + ctx.stroke() + ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom)) + ctx.lineWidth = 0.75 + ctx.beginPath() + ctx.moveTo(0, playYScreen) + ctx.lineTo(w, playYScreen) ctx.stroke() + ctx.restore() const bloomOp = dark ? "lighter" : "source-over" ctx.globalCompositeOperation = bloomOp diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index d4acf50..d1f8ec0 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -8,12 +8,8 @@ DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, - DEFAULT_ROTATE_X, - DEFAULT_ROTATE_Y, - DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, - LIGHT_BG, resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, @@ -31,9 +27,6 @@ class: className = "", color, glow = DEFAULT_GLOW, - rotateX = DEFAULT_ROTATE_X, - rotateY = DEFAULT_ROTATE_Y, - rotateZ = DEFAULT_ROTATE_Z, speed = DEFAULT_SPEED, density = DEFAULT_DENSITY, sway = true, @@ -51,9 +44,6 @@ instance = createPhosphorScore(canvas, { color, glow, - rotateX, - rotateY, - rotateZ, speed, density, sway, @@ -73,9 +63,6 @@ instance?.setOptions({ color, glow, - rotateX, - rotateY, - rotateZ, speed, density, sway, @@ -89,8 +76,41 @@ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - class={cn("absolute inset-0 overflow-hidden", className)} - style="background-color: {isDark ? DARK_BG : LIGHT_BG}" + class={cn("absolute inset-0 overflow-hidden bg-background", className)} + style={isDark ? `background-color: ${DARK_BG}` : ""} > - + + + + + diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index 4988c19..9efd7b5 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -9,12 +9,8 @@ import { DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, - DEFAULT_ROTATE_X, - DEFAULT_ROTATE_Y, - DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, - LIGHT_BG, resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, @@ -26,9 +22,6 @@ export { DEFAULT_COLOR, DEFAULT_DENSITY, DEFAULT_GLOW, - DEFAULT_ROTATE_X, - DEFAULT_ROTATE_Y, - DEFAULT_ROTATE_Z, DEFAULT_SEED, DEFAULT_SPEED, LIGHT_BG, @@ -46,6 +39,49 @@ export type PhosphorScoreProps = Omit & { className?: string } +function PhosphorEdgeFade({ + edge, + dark, +}: { + edge: "top" | "bottom" + dark: boolean +}) { + const isTop = edge === "top" + return ( + <> +
+
+ + ) +} + /** * Vertical phosphor sheet music — notes fall toward a playhead, bloom, * then exit in a flare. Follows light and dark. @@ -54,9 +90,6 @@ export function PhosphorScore({ className, color, glow = DEFAULT_GLOW, - rotateX = DEFAULT_ROTATE_X, - rotateY = DEFAULT_ROTATE_Y, - rotateZ = DEFAULT_ROTATE_Z, speed = DEFAULT_SPEED, density = DEFAULT_DENSITY, sway = true, @@ -89,9 +122,6 @@ export function PhosphorScore({ instanceRef.current = createPhosphorScore(canvas, { color, glow, - rotateX, - rotateY, - rotateZ, speed, density, sway, @@ -111,9 +141,6 @@ export function PhosphorScore({ instanceRef.current?.setOptions({ color, glow, - rotateX, - rotateY, - rotateZ, speed, density, sway, @@ -121,28 +148,22 @@ export function PhosphorScore({ theme, onThemeChange: setIsDark, }) - }, [ - color, - glow, - rotateX, - rotateY, - rotateZ, - speed, - density, - sway, - seed, - theme, - ]) + }, [color, glow, speed, density, sway, seed, theme]) return (
- + + +
) } From 9bf8494fc35a6314b893436ee94e157947422572 Mon Sep 17 00:00:00 2001 From: Radiumcoders Date: Tue, 8 Sep 2026 12:39:29 +0530 Subject: [PATCH 5/5] the ai limit reached so this is the message: updated the lines to be more noice xD --- public/r/phosphor-score-svelte.json | 2 +- public/r/phosphor-score.json | 2 +- .../phosphor-score/phosphor-score-vanilla.ts | 57 ++++++++----------- registry/phosphor-score/phosphor-score.svelte | 33 ++++++----- registry/phosphor-score/phosphor-score.tsx | 44 ++++++++------ 5 files changed, 69 insertions(+), 69 deletions(-) diff --git a/public/r/phosphor-score-svelte.json b/public/r/phosphor-score-svelte.json index a37fc5e..582b854 100644 --- a/public/r/phosphor-score-svelte.json +++ b/public/r/phosphor-score-svelte.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.svelte", - "content": "\n\n\n\n\n \n
\n
\n \n \n\n", + "content": "\n\n\n\n\n \n \n \n \n \n \n \n\n", "type": "registry:file", "target": "src/lib/components/ui/phosphor-score.svelte" } diff --git a/public/r/phosphor-score.json b/public/r/phosphor-score.json index 7695f30..429840e 100644 --- a/public/r/phosphor-score.json +++ b/public/r/phosphor-score.json @@ -6,7 +6,7 @@ "files": [ { "path": "registry/phosphor-score/phosphor-score.tsx", - "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on the page background */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-background` shows through. */\nexport const LIGHT_BG = \"transparent\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction lerp(a: number, b: number, t: number) {\n return a + (b - a) * t\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n let frameFit = 0.78\n\n const project = (x: number, y: number): Pt => {\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const rx = x * cosZ - y * sinZ + cam.y\n const ry = x * sinZ + y * cosZ + cam.x\n const s = frameFit\n return {\n x: size.w * 0.5 + rx * s,\n y: size.h * 0.5 + ry * s,\n s,\n z: 0,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const dt = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const swayOn = options.sway !== false && !reduce\n cam.x = swayOn ? Math.sin(clock * 0.31) * 10 : 0\n cam.y = swayOn ? Math.cos(clock * 0.23) * 14 : 0\n cam.z = swayOn ? Math.sin(clock * 0.17) * 0.018 : 0\n frameFit = 0.78\n\n const pad = Math.max(28, Math.min(w, h) * 0.1)\n const innerW = Math.max(1, w - pad * 2)\n const innerH = Math.max(1, h - pad * 2)\n let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28)\n const maxHalf = innerW / 2 / frameFit\n let gap = maxHalf * 0.4\n if (gap < sp * 7) {\n sp = clamp(maxHalf * 0.4 / 7, 10, 28)\n gap = maxHalf * 0.4\n }\n gap = Math.max(sp * 7, gap)\n const topY = -innerH / 2 / frameFit\n const playY = (innerH / 2 - sp * 2) / frameFit\n const ppb = Math.max(28, (playY - topY) / 7.6)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n if (dark) {\n ctx.fillStyle = DARK_BG\n ctx.fillRect(0, 0, w, h)\n } else {\n ctx.clearRect(0, 0, w, h)\n }\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = (playY - innerH / 2 / frameFit) / ppb\n const visMax = (playY - topY) / ppb\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const pitch = line * 2\n const x = pitchX(staff, pitch, gap, sp)\n ctx.beginPath()\n let started = false\n const steps = 56\n for (let i = 0; i <= steps; i++) {\n const d = lerp(visMax, visMin, i / steps)\n const hitAmt = nearPlay(d)\n const pt = project(x, timeY(d, ppb, playY))\n if (!started) {\n ctx.moveTo(pt.x, pt.y)\n started = true\n } else ctx.lineTo(pt.x, pt.y)\n if (i === Math.floor(steps * 0.62)) {\n ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom)\n ctx.lineWidth = 1\n ctx.stroke()\n ctx.beginPath()\n ctx.moveTo(pt.x, pt.y)\n }\n }\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.stroke()\n }\n }\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playYScreen = project(0, playY).y\n ctx.save()\n ctx.lineCap = \"butt\"\n ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom))\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom))\n ctx.lineWidth = 0.75\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.restore()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\nfunction PhosphorEdgeFade({\n edge,\n dark,\n}: {\n edge: \"top\" | \"bottom\"\n dark: boolean\n}) {\n const isTop = edge === \"top\"\n return (\n <>\n \n \n \n )\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [color, glow, speed, density, sway, seed, theme])\n\n return (\n \n \n \n \n \n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PhosphorScoreTheme = \"light\" | \"dark\" | \"auto\"\n\nexport type PhosphorScoreOptions = {\n /**\n * Ink / phosphor color. Omit to follow `theme`\n * (`LIGHT_COLOR` / `DARK_COLOR`).\n */\n color?: string\n /**\n * Bloom amount, 0–100. Default `50`. Scales the playhead halo\n * and exit flare — kept small so it sits on the note, not a disc.\n */\n glow?: number\n /** Scroll speed in beats per second. Default `1.35`. */\n speed?: number\n /** How packed the notation is. Default `1`. */\n density?: number\n /** Slow camera drift. Default `true`. */\n sway?: boolean\n /** Deterministic score. Default `23`. */\n seed?: number\n /**\n * Palette mode. Default `auto` follows shadcn / next-themes\n * (`html.dark` class).\n */\n theme?: PhosphorScoreTheme\n /** Fires whenever resolved dark mode changes. */\n onThemeChange?: (dark: boolean) => void\n}\n\nexport type PhosphorScoreInstance = {\n setOptions: (options: Partial) => void\n destroy: () => void\n}\n\n/** Phosphor on black */\nexport const DARK_COLOR = \"#4DFF6A\"\n/** Forest ink on the page background */\nexport const LIGHT_COLOR = \"#147A3A\"\n/** @deprecated Use `DARK_COLOR` or omit `color` and set `theme`. */\nexport const DEFAULT_COLOR = DARK_COLOR\nexport const DARK_BG = \"#050505\"\n/** Light mode is transparent — the parent `bg-background` shows through. */\nexport const LIGHT_BG = \"transparent\"\nexport const DEFAULT_GLOW = 50\nexport const DEFAULT_SPEED = 1.35\nexport const DEFAULT_DENSITY = 1\nexport const DEFAULT_SEED = 23\n\nconst LOOP_BEATS = 48\nconst DYNAMICS = [\"pp\", \"p\", \"mp\", \"mf\", \"f\", \"ff\"] as const\n\ntype Note = {\n t: number\n pitch: number\n dur: number\n staff: 0 | 1\n beam: number\n acc: -1 | 0 | 1\n}\n\ntype Mark = {\n t: number\n staff: 0 | 1\n text: string\n pitch: number\n}\n\ntype Hairpin = {\n t: number\n dur: number\n staff: 0 | 1\n open: boolean\n}\n\ntype Slur = {\n t0: number\n t1: number\n p0: number\n p1: number\n staff: 0 | 1\n}\n\ntype Link = {\n t: number\n p0: number\n p1: number\n}\n\ntype Dust = {\n x: number\n y: number\n z: number\n s: number\n a: number\n}\n\ntype Flare = {\n x: number\n y: number\n age: number\n life: number\n size: number\n}\n\ntype Rgb = [number, number, number]\n\nfunction mulberry32(seed: number) {\n let a = seed >>> 0\n return () => {\n a |= 0\n a = (a + 0x6d2b79f5) | 0\n let t = Math.imul(a ^ (a >>> 15), 1 | a)\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n }\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction wrapDelta(t: number, now: number, loop: number) {\n let d = t - now\n d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2\n return d\n}\n\nfunction parseColor(input?: string): Rgb {\n const raw = (input ?? DEFAULT_COLOR).trim()\n if (/^#[0-9a-fA-F]{6}$/.test(raw)) {\n return [\n parseInt(raw.slice(1, 3), 16),\n parseInt(raw.slice(3, 5), 16),\n parseInt(raw.slice(5, 7), 16),\n ]\n }\n if (/^#[0-9a-fA-F]{3}$/.test(raw)) {\n return [\n parseInt(raw[1]! + raw[1], 16),\n parseInt(raw[2]! + raw[2], 16),\n parseInt(raw[3]! + raw[3], 16),\n ]\n }\n return [77, 255, 106]\n}\n\nfunction rgba(rgb: Rgb, a: number) {\n return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})`\n}\n\nfunction glowOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_GLOW\n return clamp(value, 0, 100)\n}\n\nfunction speedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SPEED\n return clamp(value, 0.15, 4)\n}\n\nfunction densityOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) {\n return DEFAULT_DENSITY\n }\n return clamp(value, 0.35, 1.85)\n}\n\nfunction seedOf(value?: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return DEFAULT_SEED\n return Math.round(value) >>> 0\n}\n\nfunction themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme {\n return value === \"light\" || value === \"dark\" || value === \"auto\"\n ? value\n : \"auto\"\n}\n\nfunction optionalColor(value?: string) {\n const next = value?.trim()\n return next ? next : undefined\n}\n\n/** Resolves shadcn / next-themes dark mode (`attribute=\"class\"` → `html.dark`). */\nexport function isDarkTheme(): boolean {\n if (typeof document === \"undefined\") return false\n const root = document.documentElement\n if (root.classList.contains(\"dark\")) return true\n if (root.classList.contains(\"light\")) return false\n const dataTheme = root.getAttribute(\"data-theme\")\n if (dataTheme === \"dark\") return true\n if (dataTheme === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n}\n\nexport function resolveDark(theme: PhosphorScoreTheme): boolean {\n if (theme === \"dark\") return true\n if (theme === \"light\") return false\n return isDarkTheme()\n}\n\nexport function resolveColor(color: string | undefined, dark: boolean) {\n const custom = optionalColor(color)\n if (custom) return custom\n return dark ? DARK_COLOR : LIGHT_COLOR\n}\n\nexport function resolveBg(dark: boolean) {\n return dark ? DARK_BG : LIGHT_BG\n}\n\ntype Score = {\n notes: Note[]\n marks: Mark[]\n hairpins: Hairpin[]\n slurs: Slur[]\n links: Link[]\n}\n\nfunction generateScore(seed: number, density: number): Score {\n const rng = mulberry32(seed)\n const notes: Note[] = []\n const marks: Mark[] = []\n const hairpins: Hairpin[] = []\n const slurs: Slur[] = []\n const links: Link[] = []\n let group = 1\n const dens = densityOf(density)\n\n const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]!\n\n const pushNote = (\n t: number,\n pitch: number,\n dur: number,\n staff: 0 | 1,\n beam: number\n ) => {\n notes.push({\n t,\n pitch: clamp(pitch, -3, 11),\n dur,\n staff,\n beam,\n acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0,\n })\n }\n\n const addRun = (\n staff: 0 | 1,\n start: number,\n pitch: number,\n count: number,\n step: number,\n dir: number\n ) => {\n const beam = group++\n let p = pitch\n for (let i = 0; i < count; i++) {\n if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam)\n const hop = rng() < 0.18 ? 2 : 1\n p += dir * hop\n if (p > 10 || p < -2) dir *= -1\n }\n if (count >= 4 && rng() < 0.45) {\n slurs.push({\n t0: start,\n t1: start + (count - 1) * step,\n p0: pitch,\n p1: p,\n staff,\n })\n }\n }\n\n const addArp = (staff: 0 | 1, start: number, root: number, step: number) => {\n const pattern = [0, 2, 4, 7, 4, 2, 0, -1]\n const beam = group++\n for (let i = 0; i < pattern.length; i++) {\n pushNote(start + i * step, root + pattern[i]!, step, staff, beam)\n }\n }\n\n const addChord = (staff: 0 | 1, t: number, root: number) => {\n const beam = -group++\n pushNote(t, root, 1, staff, beam)\n pushNote(t, root + 2, 1, staff, beam)\n if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam)\n }\n\n let t = 0\n while (t < LOOP_BEATS - 1) {\n const kind = rng()\n const leftBusy = rng() < 0.55 + dens * 0.15\n const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4\n const step = rng() < 0.28 * dens ? 0.125 : 0.25\n const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0)\n\n if (kind < 0.5) {\n addRun(\n leftBusy ? 0 : 1,\n t,\n startPitch,\n runLen,\n step,\n rng() < 0.5 ? 1 : -1\n )\n if (rng() < 0.55 * dens) {\n addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4))\n }\n t += runLen * step\n } else if (kind < 0.72) {\n addArp(0, t, 1 + Math.floor(rng() * 5), 0.25)\n if (rng() < 0.7 * dens) {\n addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1)\n }\n t += 2\n } else if (kind < 0.86) {\n addChord(0, t, 3 + Math.floor(rng() * 4))\n addChord(1, t, Math.floor(rng() * 4))\n if (rng() < 0.5) {\n links.push({\n t,\n p0: 4,\n p1: 4,\n })\n }\n t += rng() < 0.5 ? 1 : 2\n } else {\n t += rng() < 0.5 ? 0.5 : 1\n }\n\n if (rng() < 0.18) {\n marks.push({\n t,\n staff: rng() < 0.5 ? 0 : 1,\n text: pick(DYNAMICS),\n pitch: rng() < 0.5 ? -3.6 : 11.4,\n })\n }\n if (rng() < 0.12) {\n hairpins.push({\n t,\n dur: 2 + Math.floor(rng() * 3),\n staff: rng() < 0.5 ? 0 : 1,\n open: rng() < 0.5,\n })\n }\n }\n\n return { notes, marks, hairpins, slurs, links }\n}\n\nfunction makeDust(seed: number): Dust[] {\n const rng = mulberry32(seed ^ 0x9e3779b9)\n const dust: Dust[] = []\n for (let i = 0; i < 90; i++) {\n dust.push({\n x: (rng() - 0.5) * 2,\n y: (rng() - 0.5) * 2,\n z: rng(),\n s: 0.4 + rng() * 1.4,\n a: 0.08 + rng() * 0.22,\n })\n }\n return dust\n}\n\n/**\n * Vertical phosphor sheet music — notation falls toward a playhead,\n * blooms, then exits in a flare. Canvas 2D, CRT-green by default.\n */\nexport function createPhosphorScore(\n canvas: HTMLCanvasElement,\n initial: PhosphorScoreOptions = {}\n): PhosphorScoreInstance | null {\n let options: PhosphorScoreOptions = {\n glow: DEFAULT_GLOW,\n speed: DEFAULT_SPEED,\n density: DEFAULT_DENSITY,\n seed: DEFAULT_SEED,\n ...initial,\n sway: initial.sway ?? true,\n theme: themeOf(initial.theme),\n }\n\n const ctx = canvas.getContext(\"2d\", { alpha: true })\n if (!ctx) return null\n\n const size = { w: 0, h: 0 }\n let score = generateScore(seedOf(options.seed), densityOf(options.density))\n let dust = makeDust(seedOf(options.seed))\n let dark = resolveDark(themeOf(options.theme))\n let rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n const flares: Flare[] = []\n const hit = new Set()\n let reduce = false\n let raf = 0\n let running = true\n let lastFrame = performance.now()\n let elapsed = 8\n let clock = 0\n let dtSmooth = 1 / 60\n\n const resize = () => {\n const parent = canvas.parentElement\n if (!parent) return\n const dpr = Math.min(window.devicePixelRatio || 1, 2)\n const w = parent.clientWidth\n const h = parent.clientHeight\n size.w = w\n size.h = h\n canvas.width = Math.floor(w * dpr)\n canvas.height = Math.floor(h * dpr)\n canvas.style.width = `${w}px`\n canvas.style.height = `${h}px`\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n }\n\n resize()\n const ro = new ResizeObserver(resize)\n if (canvas.parentElement) ro.observe(canvas.parentElement)\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const onReduce = () => {\n reduce = mqReduce.matches\n }\n onReduce()\n mqReduce.addEventListener(\"change\", onReduce)\n\n const syncTheme = () => {\n const next = resolveDark(themeOf(options.theme))\n if (next === dark) return\n dark = next\n rgb = parseColor(resolveColor(options.color, dark))\n options.onThemeChange?.(dark)\n }\n const mo = new MutationObserver(syncTheme)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", syncTheme)\n\n const rebuild = () => {\n score = generateScore(seedOf(options.seed), densityOf(options.density))\n dust = makeDust(seedOf(options.seed))\n rgb = parseColor(resolveColor(options.color, dark))\n hit.clear()\n flares.length = 0\n }\n\n type Pt = { x: number; y: number; s: number; z: number }\n\n const cam = { x: 0, y: 0, z: 0 }\n let frameFit = 0.78\n\n const project = (x: number, y: number): Pt => {\n const cosZ = Math.cos(cam.z)\n const sinZ = Math.sin(cam.z)\n const rx = x * cosZ - y * sinZ + cam.y\n const ry = x * sinZ + y * cosZ + cam.x\n const s = frameFit\n return {\n x: size.w * 0.5 + rx * s,\n y: size.h * 0.5 + ry * s,\n s,\n z: 0,\n }\n }\n\n const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap)\n\n const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) =>\n staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5)\n\n const timeY = (delta: number, ppb: number, playY: number) =>\n playY - delta * ppb\n\n const nearPlay = (delta: number) => {\n if (delta > 0.55) return 0\n if (delta > 0) return 1 - delta / 0.55\n if (delta > -0.28) return 1\n if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07\n return 0\n }\n\n const drawOval = (pt: Pt, r: number, fill: string) => {\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(-0.38)\n ctx.scale(1, 0.7)\n ctx.beginPath()\n ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2)\n ctx.fillStyle = fill\n ctx.fill()\n ctx.restore()\n }\n\n const glowBlob = (pt: Pt, radius: number, alpha: number) => {\n const r = Math.max(1.2, radius * pt.s)\n const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r)\n g.addColorStop(0, rgba(rgb, alpha))\n g.addColorStop(0.28, rgba(rgb, alpha * 0.42))\n g.addColorStop(0.62, rgba(rgb, alpha * 0.1))\n g.addColorStop(1, rgba(rgb, 0))\n ctx.fillStyle = g\n ctx.beginPath()\n ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2)\n ctx.fill()\n }\n\n const frame = (now: number) => {\n if (!running) return\n const raw = Math.min(0.05, (now - lastFrame) / 1000)\n lastFrame = now\n dtSmooth += (raw - dtSmooth) * 0.2\n const dt = dtSmooth\n clock += dt\n if (!reduce) elapsed += dt * speedOf(options.speed)\n const beat = elapsed % LOOP_BEATS\n\n const w = size.w\n const h = size.h\n const swayOn = options.sway !== false && !reduce\n cam.x = swayOn ? Math.sin(clock * 0.31) * 8 : 0\n cam.y = swayOn ? Math.cos(clock * 0.23) * 10 : 0\n cam.z = 0\n frameFit = 0.78\n\n const pad = Math.max(28, Math.min(w, h) * 0.1)\n const innerW = Math.max(1, w - pad * 2)\n const innerH = Math.max(1, h - pad * 2)\n let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28)\n const maxHalf = innerW / 2 / frameFit\n let gap = maxHalf * 0.4\n if (gap < sp * 7) {\n sp = clamp(maxHalf * 0.4 / 7, 10, 28)\n gap = maxHalf * 0.4\n }\n gap = Math.max(sp * 7, gap)\n const topY = -innerH / 2 / frameFit\n const playY = (innerH / 2 - sp * 2) / frameFit\n const ppb = Math.max(28, (playY - topY) / 7.6)\n const bloom = glowOf(options.glow) / 50\n const color = parseColor(resolveColor(options.color, dark))\n rgb = color\n\n ctx.imageSmoothingEnabled = true\n ctx.globalCompositeOperation = \"source-over\"\n if (dark) {\n ctx.fillStyle = DARK_BG\n ctx.fillRect(0, 0, w, h)\n } else {\n ctx.clearRect(0, 0, w, h)\n }\n\n ctx.save()\n ctx.globalAlpha = 1\n for (const d of dust) {\n const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1\n const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1\n ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28)\n ctx.fillRect(px * w, py * h, d.s, d.s)\n }\n ctx.restore()\n\n const visMin = (playY - innerH / 2 / frameFit) / ppb\n const visMax = (playY - topY) / ppb\n\n const inView = (t: number) => {\n const d = wrapDelta(t, beat, LOOP_BEATS)\n return d < visMax && d > visMin\n }\n\n const staffY0 = topY - h * 0.4 / frameFit\n const staffY1 = playY + h * 0.25 / frameFit\n ctx.lineCap = \"butt\"\n ctx.lineJoin = \"miter\"\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n for (const staff of [0, 1] as const) {\n for (let line = 0; line < 5; line++) {\n const x = pitchX(staff, line * 2, gap, sp)\n const a = project(x, staffY0)\n const b = project(x, staffY1)\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.lineCap = \"round\"\n ctx.lineJoin = \"round\"\n\n for (let bar = 0; bar < LOOP_BEATS; bar += 4) {\n if (!inView(bar)) continue\n const d = wrapDelta(bar, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const alpha = 0.14 + nearPlay(d) * 0.2\n for (const staff of [0, 1] as const) {\n const a = project(pitchX(staff, 0, gap, sp), y)\n const b = project(pitchX(staff, 8, gap, sp), y)\n ctx.strokeStyle = rgba(color, alpha)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n }\n\n ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, \"Times New Roman\", serif`\n ctx.textBaseline = \"middle\"\n for (const mark of score.marks) {\n if (!inView(mark.t)) continue\n const d = wrapDelta(mark.t, beat, LOOP_BEATS)\n const pt = project(\n pitchX(mark.staff, mark.pitch, gap, sp),\n timeY(d, ppb, playY)\n )\n ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45)\n ctx.fillText(mark.text, pt.x, pt.y)\n }\n\n for (const pin of score.hairpins) {\n const t1 = pin.t + pin.dur\n if (!inView(pin.t) && !inView(t1)) continue\n const d0 = wrapDelta(pin.t, beat, LOOP_BEATS)\n const d1 = wrapDelta(t1, beat, LOOP_BEATS)\n const staff = pin.staff\n const outer = staff === 0 ? -4.6 : 12.6\n const inner = staff === 0 ? -3.4 : 11.4\n const y0 = timeY(d0, ppb, playY)\n const y1 = timeY(d1, ppb, playY)\n const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp)\n const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp)\n const xMid = pitchX(staff, (outer + inner) / 2, gap, sp)\n const a = project(xOuter0, y0)\n const b = project(xOuter1, y1)\n const c = project(xMid, y0)\n const e = project(xMid, y1)\n ctx.strokeStyle = rgba(color, 0.22)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.moveTo(c.x, c.y)\n ctx.lineTo(e.x, e.y)\n ctx.stroke()\n }\n\n for (const slur of score.slurs) {\n if (!inView(slur.t0) && !inView(slur.t1)) continue\n const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS)\n const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS)\n const side = slur.staff === 0 ? -1.8 : 1.8\n const a = project(\n pitchX(slur.staff, slur.p0 + side, gap, sp),\n timeY(d0, ppb, playY)\n )\n const b = project(\n pitchX(slur.staff, slur.p1 + side, gap, sp),\n timeY(d1, ppb, playY)\n )\n const midT = (slur.t0 + slur.t1) / 2\n const midP = (slur.p0 + slur.p1) / 2 + side * 1.6\n const c = project(\n pitchX(slur.staff, midP, gap, sp),\n timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY)\n )\n ctx.strokeStyle = rgba(color, 0.2)\n ctx.lineWidth = 1.1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.quadraticCurveTo(c.x, c.y, b.x, b.y)\n ctx.stroke()\n }\n\n for (const link of score.links) {\n if (!inView(link.t)) continue\n const d = wrapDelta(link.t, beat, LOOP_BEATS)\n const y = timeY(d, ppb, playY)\n const a = project(pitchX(0, link.p0, gap, sp), y)\n const b = project(pitchX(1, link.p1, gap, sp), y)\n ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(a.x, a.y)\n ctx.lineTo(b.x, b.y)\n ctx.stroke()\n }\n\n const groups = new Map()\n for (const note of score.notes) {\n if (note.beam <= 0) continue\n if (!inView(note.t)) continue\n const list = groups.get(note.beam)\n if (list) list.push(note)\n else groups.set(note.beam, [note])\n }\n\n const stemTip = (note: Note, d: number) => {\n const dir = note.staff === 0 ? -1 : 1\n const y = timeY(d, ppb, playY)\n const x0 = pitchX(note.staff, note.pitch, gap, sp)\n const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp)\n return {\n head: project(x0, y),\n tip: project(x1, y),\n d,\n }\n }\n\n for (const note of score.notes) {\n if (!inView(note.t)) continue\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n const { head, tip } = stemTip(note, d)\n const intens = nearPlay(d)\n ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45)\n ctx.lineWidth = 1 * head.s\n ctx.beginPath()\n ctx.moveTo(head.x, head.y)\n ctx.lineTo(tip.x, tip.y)\n ctx.stroke()\n }\n\n for (const [, groupNotes] of groups) {\n if (groupNotes.length < 2) continue\n groupNotes.sort((a, b) => a.t - b.t)\n const first = groupNotes[0]!\n const last = groupNotes[groupNotes.length - 1]!\n const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS))\n const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS))\n const intens = Math.max(nearPlay(a.d), nearPlay(b.d))\n ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5)\n ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5)\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y)\n ctx.lineTo(b.tip.x, b.tip.y)\n ctx.stroke()\n if (first.dur <= 0.13) {\n const oy = (first.staff === 0 ? -1 : 1) * 3.2\n ctx.beginPath()\n ctx.moveTo(a.tip.x, a.tip.y + oy)\n ctx.lineTo(b.tip.x, b.tip.y + oy)\n ctx.stroke()\n }\n }\n\n const playYScreen = project(0, playY).y\n ctx.save()\n ctx.lineCap = \"butt\"\n ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom))\n ctx.lineWidth = 2\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom))\n ctx.lineWidth = 0.75\n ctx.beginPath()\n ctx.moveTo(0, playYScreen)\n ctx.lineTo(w, playYScreen)\n ctx.stroke()\n ctx.restore()\n\n const bloomOp = dark ? \"lighter\" : \"source-over\"\n ctx.globalCompositeOperation = bloomOp\n\n for (const note of score.notes) {\n const d = wrapDelta(note.t, beat, LOOP_BEATS)\n if (d > visMax || d < visMin) continue\n const y = timeY(d, ppb, playY)\n const x = pitchX(note.staff, note.pitch, gap, sp)\n const pt = project(x, y)\n const intens = nearPlay(d)\n const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2\n const headR = Math.max(2.4, sp * 0.38)\n const alpha = upcoming + intens * 0.8\n\n if (note.acc !== 0 && d > -0.4) {\n const accPt = project(x, y - sp * 0.9)\n ctx.fillStyle = rgba(color, 0.25 + intens * 0.5)\n ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif`\n ctx.fillText(note.acc > 0 ? \"#\" : \"b\", accPt.x - 4, accPt.y)\n }\n\n if (note.pitch < 0 || note.pitch > 8) {\n const ledgerPitch = note.pitch < 0 ? -2 : 10\n const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp)\n const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp)\n const la = project(lx0, y)\n const lb = project(lx1, y)\n ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3)\n ctx.lineWidth = 1\n ctx.beginPath()\n ctx.moveTo(la.x, la.y)\n ctx.lineTo(lb.x, lb.y)\n ctx.stroke()\n }\n\n if (intens > 0.04 && bloom > 0) {\n const halo = (5 + intens * 9) * bloom\n glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom))\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n ctx.save()\n if (intens > 0.2 && bloom > 0) {\n ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45)\n ctx.shadowBlur = (3.5 + intens * 5) * bloom\n }\n drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1)))\n ctx.restore()\n ctx.globalCompositeOperation = bloomOp\n\n if (d <= 0 && d > -0.08 && intens > 0.7) {\n const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0\n if (!hit.has(id)) {\n hit.add(id)\n flares.push({\n x,\n y: playY,\n age: 0,\n life: 0.38,\n size: 10,\n })\n if (hit.size > 400) hit.clear()\n }\n }\n }\n\n for (let i = flares.length - 1; i >= 0; i--) {\n const flare = flares[i]!\n flare.age += dt\n const t = flare.age / flare.life\n if (t >= 1) {\n flares.splice(i, 1)\n continue\n }\n const pt = project(flare.x, flare.y)\n const fade = Math.sin((1 - t) * Math.PI)\n glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade)\n ctx.save()\n ctx.translate(pt.x, pt.y)\n ctx.rotate(cam.z)\n const flareW = 22 * bloom * (1 + t * 0.4) * pt.s\n const flareH = 1.4 * pt.s\n const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0)\n fg.addColorStop(0, rgba(color, 0))\n fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade))\n fg.addColorStop(1, rgba(color, 0))\n ctx.fillStyle = fg\n ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2)\n ctx.restore()\n }\n\n ctx.globalCompositeOperation = \"source-over\"\n\n raf = requestAnimationFrame(frame)\n }\n\n raf = requestAnimationFrame(frame)\n\n return {\n setOptions(next) {\n const prevSeed = seedOf(options.seed)\n const prevDens = densityOf(options.density)\n options = {\n ...options,\n ...next,\n sway: next.sway ?? options.sway,\n theme: themeOf(next.theme ?? options.theme),\n }\n syncTheme()\n rgb = parseColor(resolveColor(options.color, dark))\n if (\n seedOf(options.seed) !== prevSeed ||\n densityOf(options.density) !== prevDens\n ) {\n rebuild()\n }\n },\n destroy() {\n running = false\n cancelAnimationFrame(raf)\n ro.disconnect()\n mo.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n mqDark.removeEventListener(\"change\", syncTheme)\n },\n }\n}\n\nexport type PhosphorScoreProps = Omit & {\n className?: string\n}\n\nfunction PhosphorEdgeFade({\n edge,\n dark,\n}: {\n edge: \"top\" | \"bottom\"\n dark: boolean\n}) {\n const isTop = edge === \"top\"\n const pos = isTop ? \"top-0\" : \"bottom-0\"\n const mask = isTop\n ? \"mask-[linear-gradient(to_bottom,black,transparent)]\"\n : \"mask-[linear-gradient(to_top,black,transparent)]\"\n return (\n <>\n \n \n \n \n )\n}\n\n/**\n * Vertical phosphor sheet music — notes fall toward a playhead, bloom,\n * then exit in a flare. Follows light and dark.\n */\nexport function PhosphorScore({\n className,\n color,\n glow = DEFAULT_GLOW,\n speed = DEFAULT_SPEED,\n density = DEFAULT_DENSITY,\n sway = true,\n seed = DEFAULT_SEED,\n theme = \"auto\",\n}: PhosphorScoreProps) {\n const canvasRef = useRef(null)\n const instanceRef = useRef(null)\n const [isDark, setIsDark] = useState(false)\n\n useEffect(() => {\n const sync = () => setIsDark(resolveDark(theme))\n sync()\n const mo = new MutationObserver(sync)\n mo.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\"],\n })\n const mq = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mq.addEventListener(\"change\", sync)\n return () => {\n mo.disconnect()\n mq.removeEventListener(\"change\", sync)\n }\n }, [theme])\n\n useEffect(() => {\n const canvas = canvasRef.current\n if (!canvas) return\n instanceRef.current = createPhosphorScore(canvas, {\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n color,\n glow,\n speed,\n density,\n sway,\n seed,\n theme,\n onThemeChange: setIsDark,\n })\n }, [color, glow, speed, density, sway, seed, theme])\n\n return (\n \n \n \n \n \n )\n}\n", "type": "registry:ui", "target": "components/ui/phosphor-score.tsx" } diff --git a/registry/phosphor-score/phosphor-score-vanilla.ts b/registry/phosphor-score/phosphor-score-vanilla.ts index 8c6791b..ec3fad0 100644 --- a/registry/phosphor-score/phosphor-score-vanilla.ts +++ b/registry/phosphor-score/phosphor-score-vanilla.ts @@ -120,10 +120,6 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } -function lerp(a: number, b: number, t: number) { - return a + (b - a) * t -} - function wrapDelta(t: number, now: number, loop: number) { let d = t - now d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2 @@ -406,6 +402,7 @@ export function createPhosphorScore( let lastFrame = performance.now() let elapsed = 8 let clock = 0 + let dtSmooth = 1 / 60 const resize = () => { const parent = canvas.parentElement @@ -518,8 +515,10 @@ export function createPhosphorScore( const frame = (now: number) => { if (!running) return - const dt = Math.min(0.05, (now - lastFrame) / 1000) + const raw = Math.min(0.05, (now - lastFrame) / 1000) lastFrame = now + dtSmooth += (raw - dtSmooth) * 0.2 + const dt = dtSmooth clock += dt if (!reduce) elapsed += dt * speedOf(options.speed) const beat = elapsed % LOOP_BEATS @@ -527,9 +526,9 @@ export function createPhosphorScore( const w = size.w const h = size.h const swayOn = options.sway !== false && !reduce - cam.x = swayOn ? Math.sin(clock * 0.31) * 10 : 0 - cam.y = swayOn ? Math.cos(clock * 0.23) * 14 : 0 - cam.z = swayOn ? Math.sin(clock * 0.17) * 0.018 : 0 + cam.x = swayOn ? Math.sin(clock * 0.31) * 8 : 0 + cam.y = swayOn ? Math.cos(clock * 0.23) * 10 : 0 + cam.z = 0 frameFit = 0.78 const pad = Math.max(28, Math.min(w, h) * 0.1) @@ -577,46 +576,36 @@ export function createPhosphorScore( return d < visMax && d > visMin } - ctx.lineCap = "round" - ctx.lineJoin = "round" - + const staffY0 = topY - h * 0.4 / frameFit + const staffY1 = playY + h * 0.25 / frameFit + ctx.lineCap = "butt" + ctx.lineJoin = "miter" + ctx.strokeStyle = rgba(color, 0.22) + ctx.lineWidth = 1 for (const staff of [0, 1] as const) { for (let line = 0; line < 5; line++) { - const pitch = line * 2 - const x = pitchX(staff, pitch, gap, sp) + const x = pitchX(staff, line * 2, gap, sp) + const a = project(x, staffY0) + const b = project(x, staffY1) ctx.beginPath() - let started = false - const steps = 56 - for (let i = 0; i <= steps; i++) { - const d = lerp(visMax, visMin, i / steps) - const hitAmt = nearPlay(d) - const pt = project(x, timeY(d, ppb, playY)) - if (!started) { - ctx.moveTo(pt.x, pt.y) - started = true - } else ctx.lineTo(pt.x, pt.y) - if (i === Math.floor(steps * 0.62)) { - ctx.strokeStyle = rgba(color, 0.16 + hitAmt * 0.28 * bloom) - ctx.lineWidth = 1 - ctx.stroke() - ctx.beginPath() - ctx.moveTo(pt.x, pt.y) - } - } - ctx.strokeStyle = rgba(color, 0.22) - ctx.lineWidth = 1 + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) ctx.stroke() } } + ctx.lineCap = "round" + ctx.lineJoin = "round" + for (let bar = 0; bar < LOOP_BEATS; bar += 4) { if (!inView(bar)) continue const d = wrapDelta(bar, beat, LOOP_BEATS) const y = timeY(d, ppb, playY) + const alpha = 0.14 + nearPlay(d) * 0.2 for (const staff of [0, 1] as const) { const a = project(pitchX(staff, 0, gap, sp), y) const b = project(pitchX(staff, 8, gap, sp), y) - ctx.strokeStyle = rgba(color, 0.14 + nearPlay(d) * 0.2) + ctx.strokeStyle = rgba(color, alpha) ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(a.x, a.y) diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index d1f8ec0..3dba72e 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -79,38 +79,41 @@ class={cn("absolute inset-0 overflow-hidden bg-background", className)} style={isDark ? `background-color: ${DARK_BG}` : ""} > - + + + + - diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index 9efd7b5..b5b86e9 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -47,37 +47,48 @@ function PhosphorEdgeFade({ dark: boolean }) { const isTop = edge === "top" + const pos = isTop ? "top-0" : "bottom-0" + const mask = isTop + ? "mask-[linear-gradient(to_bottom,black,transparent)]" + : "mask-[linear-gradient(to_top,black,transparent)]" return ( <> +
+
-
) } @@ -158,10 +169,7 @@ export function PhosphorScore({ className={cn("absolute inset-0 overflow-hidden bg-background", className)} style={isDark ? { backgroundColor: DARK_BG } : undefined} > - +