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..34260ca --- /dev/null +++ b/content/docs/components/phosphor-score.mdx @@ -0,0 +1,86 @@ +--- +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, 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. + + + +## Installation + + + +Or install straight from the public GitHub source registry: + + + +## Usage + +Give the parent a size. The canvas fills the parent. + + + +```tsx +"use client" + +import { PhosphorScore } from "@/components/ui/phosphor-score" + +export function HeroScore() { + return ( +
+ +
+ ) +} +``` +
+ +```svelte + + +
+ +
+``` +
+
+ +Lock a theme or push the bloom. Omit `color` to follow light / dark. + + + + ```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` | theme phosphor / forest ink | +| `glow` | `number` | `50` — 0–100, halo size at the playhead | +| `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. The canvas is transparent in light mode so the parent background shows through. 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..582b854 --- /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 \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..429840e --- /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, 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" + } + ], + "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..1d4c9d8 --- /dev/null +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -0,0 +1,128 @@ +"use client" + +import { useLayoutEffect, useMemo } from "react" + +import { + ComponentControls, + ControlColor, + ControlSlider, + 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 { + DARK_COLOR, + DEFAULT_DENSITY, + DEFAULT_GLOW, + DEFAULT_SPEED, + LIGHT_COLOR, + PhosphorScore, +} from "@/registry/phosphor-score/phosphor-score" + +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 theme = useHydratedTheme() + const stock = theme === "dark" ? DARK_COLOR : LIGHT_COLOR + + const defaults = useMemo( + () => ({ + color: stock, + glow: DEFAULT_GLOW, + 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 ( + <> + +
+ +
+
+ + + updateProp("color", v)} + /> + updateProp("glow", 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..ec3fad0 --- /dev/null +++ b/registry/phosphor-score/phosphor-score-vanilla.ts @@ -0,0 +1,898 @@ +export type PhosphorScoreTheme = "light" | "dark" | "auto" + +export type PhosphorScoreOptions = { + /** + * Ink / phosphor color. Omit to follow `theme` + * (`LIGHT_COLOR` / `DARK_COLOR`). + */ + color?: string + /** + * Bloom amount, 0–100. Default `50`. Scales the playhead halo + * and exit flare — kept small so it sits on the note, not a disc. + */ + glow?: 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 + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class). + */ + theme?: PhosphorScoreTheme + /** Fires whenever resolved dark mode changes. */ + onThemeChange?: (dark: boolean) => void +} + +export type PhosphorScoreInstance = { + setOptions: (options: Partial) => void + destroy: () => void +} + +/** Phosphor on black */ +export const DARK_COLOR = "#4DFF6A" +/** Forest ink on the page background */ +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" +/** Light mode is transparent — the parent `bg-background` shows through. */ +export const LIGHT_BG = "transparent" +export const DEFAULT_GLOW = 50 +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 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 glowOf(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GLOW + return clamp(value, 0, 100) +} + +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 +} + +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[] + 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: PhosphorScoreOptions = { + glow: DEFAULT_GLOW, + speed: DEFAULT_SPEED, + density: DEFAULT_DENSITY, + seed: DEFAULT_SEED, + ...initial, + sway: initial.sway ?? true, + theme: themeOf(initial.theme), + } + + const ctx = canvas.getContext("2d", { alpha: true }) + 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 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 + let raf = 0 + let running = true + let lastFrame = performance.now() + let elapsed = 8 + let clock = 0 + let dtSmooth = 1 / 60 + + 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 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(resolveColor(options.color, dark)) + hit.clear() + flares.length = 0 + } + + type Pt = { x: number; y: number; s: number; z: number } + + const cam = { x: 0, y: 0, z: 0 } + let frameFit = 0.78 + + const project = (x: number, y: number): Pt => { + const cosZ = Math.cos(cam.z) + const sinZ = Math.sin(cam.z) + 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 + rx * s, + y: size.h * 0.5 + ry * s, + s, + z: 0, + } + } + + 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, 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, 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) + ctx.fill() + } + + const frame = (now: number) => { + if (!running) return + 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 + + const w = size.w + const h = size.h + const swayOn = options.sway !== false && !reduce + 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) + 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" + if (dark) { + ctx.fillStyle = DARK_BG + ctx.fillRect(0, 0, w, h) + } else { + ctx.clearRect(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(color, dark ? d.a : d.a * 0.28) + ctx.fillRect(px * w, py * h, d.s, d.s) + } + ctx.restore() + + const visMin = (playY - innerH / 2 / frameFit) / ppb + const visMax = (playY - topY) / ppb + + const inView = (t: number) => { + const d = wrapDelta(t, beat, LOOP_BEATS) + return d < visMax && d > visMin + } + + 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 x = pitchX(staff, line * 2, gap, sp) + const a = project(x, staffY0) + const b = project(x, staffY1) + ctx.beginPath() + 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, alpha) + 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) + ) + 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) + 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() + 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) + ) + const b = project( + pitchX(slur.staff, slur.p1 + side, gap, sp), + 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) + ) + 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) + 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() + 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), + tip: project(x1, y), + 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 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(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 + + 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) + 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 alpha = upcoming + intens * 0.8 + + if (note.acc !== 0 && d > -0.4) { + 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) + } + + 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) + const lb = project(lx1, y) + 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.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.restore() + ctx.globalCompositeOperation = bloomOp + + 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.38, + size: 10, + }) + 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) + const fade = Math.sin((1 - t) * Math.PI) + 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 = 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, (dark ? 0.7 : 0.4) * fade)) + fg.addColorStop(1, rgba(color, 0)) + ctx.fillStyle = fg + ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) + ctx.restore() + } + + ctx.globalCompositeOperation = "source-over" + + 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, + theme: themeOf(next.theme ?? options.theme), + } + syncTheme() + rgb = parseColor(resolveColor(options.color, dark)) + if ( + seedOf(options.seed) !== prevSeed || + densityOf(options.density) !== prevDens + ) { + rebuild() + } + }, + destroy() { + 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 new file mode 100644 index 0000000..3dba72e --- /dev/null +++ b/registry/phosphor-score/phosphor-score.svelte @@ -0,0 +1,119 @@ + + + + + diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx new file mode 100644 index 0000000..b5b86e9 --- /dev/null +++ b/registry/phosphor-score/phosphor-score.tsx @@ -0,0 +1,177 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + createPhosphorScore, + DARK_BG, + DEFAULT_DENSITY, + DEFAULT_GLOW, + DEFAULT_SEED, + DEFAULT_SPEED, + resolveDark, + type PhosphorScoreInstance, + type PhosphorScoreOptions, +} from "./phosphor-score-vanilla" + +export { + DARK_BG, + DARK_COLOR, + DEFAULT_COLOR, + DEFAULT_DENSITY, + DEFAULT_GLOW, + 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 = Omit & { + className?: string +} + +function PhosphorEdgeFade({ + edge, + dark, +}: { + edge: "top" | "bottom" + 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 ( + <> +
+
+
+ + ) +} + +/** + * Vertical phosphor sheet music — notes fall toward a playhead, bloom, + * then exit in a flare. Follows light and dark. + */ +export function PhosphorScore({ + className, + color, + glow = DEFAULT_GLOW, + speed = DEFAULT_SPEED, + 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, + speed, + density, + sway, + seed, + theme, + onThemeChange: setIsDark, + }) + 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, + speed, + density, + sway, + seed, + theme, + onThemeChange: setIsDark, + }) + }, [color, glow, speed, density, sway, seed, theme]) + + 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" + } + ] + } + ] +}