shapes-planet
v0.1.0
Published
A 3D Julia-set planet shaped by the chord being played: calm chords make it smooth and round, tense chords facet it into crystal, clashing ones crumble it to dust, and every chord pours its color in. Ray-marched in WebGL2, no dependencies.
Maintainers
Readme
shapes-planet
A 3D fractal planet shaped by the chord being played. Calm chords make a smooth, round planet; tenser ones give it wiggly edges and then facet it into crystal shards; clashing chords crumble it into floating dust. Each key has its own planet, and every chord pours its color in from the top. It is a quaternion Julia set, ray-marched in WebGL2. No dependencies.

It comes from Shapes, a music toy for young children (the planet.html page there is built on this package). Its flat sibling is shapes-julia, and shapes-orbit puts this planet in orbit round a Mandelbulb.
- Install
- Quick start
- Recipes: no bundler, React, MIDI keyboard, sound, no WebGL2, no touch
- API
- Optional extras:
/synth,/math - Performance
- How the chord picks the shape
Install
npm install shapes-planetIt's ES modules only, with TypeScript types included. It works with any bundler (Vite, webpack, esbuild, Rollup, Parcel), or straight from a CDN with no build step (see No bundler).
Quick start
A complete page. Give the canvas a size with CSS: the drawing follows that size.
<div style="height: 70vh"><canvas id="planet" style="width: 100%; height: 100%; touch-action: none"></canvas></div>
<p id="caption"></p>
<script type="module">
import { createPlanet } from 'shapes-planet';
const caption = document.getElementById('caption');
const planet = createPlanet(document.getElementById('planet'));
planet.on('chord', ({ name, feel, color }) => {
caption.textContent = `${name}: ${feel}`; // "C major: A smooth, round planet"
caption.style.color = color;
});
// Whenever the notes sounding change (MIDI note numbers, 60 = middle C)…
planet.setNotes([60, 64, 67]);
// …and whenever a note or chord is struck (strength 0–1, e.g. velocity / 127)
planet.strike(0.8);
</script>There are two calls to know:
setNotes(notes)says what is sounding. It picks the planet's shape, which glides there. Call it every time a note starts or stops, passing everything still held. An empty list leaves the last shape in place, so the picture doesn't collapse between chords.strike(strength)says when something was played. A ring of the chord's color flows in and soaks into the planet from the top, and the planet bounces, harder for a harder strike. Call it on each note-on (or each chord you trigger).
Try [60, 64, 67] (C major: smooth and round), [55, 59, 62, 65] (G7: crystal shards) and [60, 61, 62, 63] (a cluster: dust).
The planet turns slowly by itself. People can drag it to spin it (a flick keeps it spinning for a moment), and a tap emits a tap event. touch-action: none on the canvas stops a drag from scrolling the page.
Recipes
No bundler
Map the bare name to a CDN with an import map, and the Quick start page works as a static file:
<script type="importmap">
{
"imports": {
"shapes-planet": "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.js",
"shapes-planet/synth": "https://cdn.jsdelivr.net/npm/[email protected]/dist/synth.js"
}
}
</script>Or import from the URL directly: import { createPlanet } from 'https://esm.sh/[email protected]'.
React
Create the view once the canvas exists, and destroy it when the component goes away. Hold it in a ref, so a render never makes a second view.
import { useEffect, useRef, useState } from 'react';
import { createPlanet, type PlanetView } from 'shapes-planet';
export function Planet({ notes, strikeKey }: { notes: number[]; strikeKey: number }) {
const canvas = useRef<HTMLCanvasElement>(null);
const view = useRef<PlanetView | null>(null);
const [caption, setCaption] = useState({ text: '', color: '' });
useEffect(() => {
const planet = createPlanet(canvas.current!);
planet.on('chord', ({ name, feel, color }) => setCaption({ text: `${name}: ${feel}`, color }));
view.current = planet;
return () => { planet.destroy(); view.current = null; };
}, []);
useEffect(() => { view.current?.setNotes(notes); }, [notes]);
useEffect(() => { if (strikeKey) view.current?.strike(0.8); }, [strikeKey]); // bump strikeKey to strike
return (
<>
<canvas ref={canvas} style={{ width: '100%', height: '60vh', touchAction: 'none' }} />
<p style={{ color: caption.color }}>{caption.text}</p>
</>
);
}React's Strict Mode mounts twice in development. That's fine: destroy() frees everything, and the canvas takes the new view. With server rendering, importing the package on the server is safe: nothing touches the DOM until createPlanet runs. Other frameworks work the same way: create in the mount hook and destroy in the unmount hook.
A MIDI keyboard
The Web MIDI API delivers note numbers that setNotes takes as they are. Keep the set of held keys, send all of it on every change, and strike on each note-on:
import { createPlanet } from 'shapes-planet';
const planet = createPlanet(document.getElementById('planet'));
const held = new Set();
const access = await navigator.requestMIDIAccess();
for (const input of access.inputs.values()) {
input.onmidimessage = ({ data: [status, note, velocity] }) => {
const kind = status & 0xf0;
if (kind === 0x90 && velocity > 0) { // note on
held.add(note);
planet.setNotes([...held]);
planet.strike(velocity / 127);
} else if (kind === 0x80 || kind === 0x90) { // note off (or note on at velocity 0)
held.delete(note);
planet.setNotes([...held]); // [] keeps the last shape
}
};
}Web MIDI works in Chrome, Edge, Firefox and Chrome on Android. Safari and iOS don't have it. When several notes of a chord land a few milliseconds apart, the view merges their rings into one.
With sound
Pair it with shapes-planet/synth, whose timbre follows the chord too, and let the glow follow what's actually heard. A tap on the picture can replay the chord:
import { createPlanet } from 'shapes-planet';
import { startNote, playNotes, loudness } from 'shapes-planet/synth';
const planet = createPlanet(canvas, { loudness });
const releases = new Map();
const held = new Set();
let lastChord = [];
function noteOn(note, velocity) {
releases.set(note, startNote(note, velocity / 127));
held.add(note);
lastChord = [...held];
planet.setNotes(lastChord);
planet.strike(velocity / 127);
}
function noteOff(note) {
releases.get(note)?.();
releases.delete(note);
held.delete(note);
planet.setNotes([...held]);
}
planet.on('tap', () => { if (lastChord.length) { playNotes(lastChord); planet.strike(0.8); } });When WebGL2 is missing
createPlanet doesn't throw. Check isSupported() and show something else, and do the same if the browser takes the graphics away:
const planet = createPlanet(canvas);
const fallback = document.getElementById('no-gl'); // e.g. "This device can't draw the planet"
fallback.hidden = planet.isSupported();
planet.on('contextlost', () => { fallback.hidden = false; });
planet.on('contextrestored', () => { fallback.hidden = true; });The chord event still fires without WebGL2, so captions and colors keep working.
A picture only (no touch)
For a background, a thumbnail, or when your app owns the pointer, turn the drag and tap off with createPlanet(canvas, { interactive: false }). The canvas then gets no pointer listeners at all, and needs no touch-action.
API
createPlanet(canvas, options?) → PlanetView
Each call makes its own view, so a page can hold several (see Performance first). It does not throw when WebGL2 is missing: check isSupported() and show your own fallback. It does throw a TypeError for a background it can't read.
| Option | Default | |
|---|---|---|
| dark | follows prefers-color-scheme | Dark-theme paints and background. |
| reducedMotion | follows prefers-reduced-motion | No turning, wandering, gliding, bounces or rings; changes are immediate, and drawing stops once the planet settles. |
| background | #edf1ef light, #101518 dark | Behind the planet: '#rrggbb', '#rgb' or [r, g, b] in 0–1. Match it to the page so the glow blends in. |
| maxPixelRatio | 2 | The highest drawing resolution, in drawing pixels per CSS pixel. |
| minPixelRatio | 1 | The least drawing resolution before adapting: above the screen's own it supersamples. Never more than maxPixelRatio. |
| autoResize | true | Follow the canvas's CSS size with a ResizeObserver. Set when created; with false, call resize() yourself. |
| interactive | true | Drag to spin the planet; a tap emits tap. Set when created. |
| loudness | none | () => number, 0–1, read every frame: louder is a brighter, wider glow. Without it the level is what setLoudness() last gave, or each strike fading away. |
Leave dark and reducedMotion out to follow the system, which updates live. Pass them to follow your app's own setting instead.
PlanetView
| Method | |
|---|---|
| setNotes(notes: number[]) | The notes sounding now, as MIDI numbers (any octave, any order, duplicates fine). The planet glides to the chord's shape; an empty list leaves it as it is. Emits chord. |
| strike(strength = 0.7, notes = last chord) | A strike: a ring of the chord's color flows in and soaks into the planet, and it bounces. Does nothing before any chord. |
| setLoudness(value: number \| null) | Push a loudness (0–1); null goes back to following strikes. Ignored when the loudness option is given. |
| setOptions(partial) | Change options live (not autoResize or interactive). A key given as undefined goes back to its default (e.g. following the system theme again). |
| on(event, listener) → () => void | Listen; call the returned function to stop. |
| resize() | Re-measure the canvas now. |
| isSupported() | false without WebGL2, when the shader can't be built, or while the context is lost. |
| destroy() | Stop drawing, remove every listener and observer (pointer listeners included), and free the WebGL program and buffer. Safe to call twice; later calls to the view do nothing. The canvas can be given to a new createPlanet. |
Events
| Event | Payload |
|---|---|
| chord | A PlanetChord (below), after every setNotes with at least one note. |
| tap | None. A press on the picture that wasn't dragged (only with interactive). |
| contextlost | None. The browser took the graphics away (common on phones in the background); drawing stops. |
| contextrestored | None. They came back and drawing has resumed. |
A listener that throws doesn't stop the others; its error is rethrown on a microtask so it still shows up in the console.
PlanetChord
| Field | Type | |
|---|---|---|
| notes | number[] | The notes given to setNotes. |
| name | string | 'C major', 'A minor', 'G7'… or the note names when they make no chord ('C D♭ D E♭'). |
| mood | Mood \| null | 'major' \| 'minor' \| 'maj7' \| 'dom7' \| 'm7' \| 'm7b5' \| 'dim7', or null when there is no chord. |
| feel | string | Words for the shape, written for a child: 'A smooth, round planet', 'A planet with wiggly edges', 'Curls and spirals', 'Sharp crystal shards', 'Crumbled into dust!'. |
| color | string | The color the chord paints, as CSS rgb(r g b), ready for style.color. |
| rgb | [r, g, b] | The same color in sRGB 0–1. |
| tension | number | 0 for calm and up; the planet crumbles into dust at about DUST (0.73). |
| dust | boolean | Whether the planet crumbled into dust. |
Also exported: toRGB(color), which reads a background value as [r, g, b]. Types exported: PlanetView, PlanetOptions, PlanetChord, PlanetEvents, PlanetListener, RGB.
Optional extras
shapes-planet/synth: the matching sound
The synth voice from Shapes (the same as shapes-julia/synth and shapes-orbit/synth), whose timbre follows the chord: round and mellow for calm chords, buzzing for tense ones, crackling for clusters. It is a single shared audio graph per page, and it follows every note it is playing by itself.
| Export | |
|---|---|
| startNote(midi: number, strength: number) → () => void | Start a held note (strength 0–1, e.g. velocity / 127). Call the returned function to release it. |
| playNotes(notes: number[]) | A quick strum that rings out by itself, as when a chord is tapped. |
| loudness() → number | How loud the output is right now, 0 to about 1, measured from the sound itself. Pass it as createPlanet's loudness option. |
| ensureAudio() → AudioContext | The shared context, created (and resumed) on first call. Call it inside a tap or key handler if you want sound ready before the first note. |
| unlock(event) | The handler the entry installs on the first pointerup, click or keydown to start audio. You won't usually need it. |
Browsers only start audio after a user gesture. Importing shapes-planet/synth listens once for the first tap, click or key anywhere to unlock it, so this entry has side effects. The others don't. The synth plays at most 24 notes at once (the oldest fades).
shapes-planet/math: the shape without the drawing
The pure functions behind the picture. They have no DOM and no WebGL, so they're safe on a server, in a worker or in tests. For example, to describe a chord's planet without drawing it:
import { juliaTarget, feelOfPlanet, tension, foldFor, nameChord, moodColor, toHex } from 'shapes-planet/math';
const notes = [55, 59, 62, 65]; // G7
const { name, mood } = nameChord(notes); // 'G7', 'dom7'
feelOfPlanet({ ...juliaTarget(notes), fold: foldFor(tension(notes)) }); // 'Sharp crystal shards'
if (mood) toHex(moodColor(mood)); // the chord's color as '#rrggbb'| Function | |
|---|---|
| juliaTarget(notes, last?) → JuliaTarget | Where the chord sits: theta, reach (1 = the edge between a solid planet and dust), spin and dust. Ambiguous chords (augmented, °7) keep the last target's theta and spin. |
| planetC(theta, reach, tilt) → Quaternion | The planet's c, with its imaginary part tilted toward the fourth axis. |
| flatOf(c) → Point | The flat complex c whose escape behaviour the planet shares. |
| planetDistance(point, c, steps = 12, fold?, alpha?) → number | The planet's distance estimate at a point [x, y, z] (the shader iterates 160 steps). |
| escapeRadius(c) → number | A radius that holds the whole Julia set for a flat c. |
| attractor(c) → { alpha, rate } | The point inside an island that its orbits settle on, and how fast they get there. |
| feelOfPlanet(target) → string | The feel words. feelOf gives the flat page's words ("island"). |
| foldFor(tension), foldNear(fold, reach), facetGrowth(fold) | How faceted the planet is (0 to 1), faded near dust, and how much the facets grow it. |
Chords and colors: tension(notes), nameChord(notes), DUST, ISLAND_EDGE, and the OKLCH colors moodColor(mood, dark?), noteColor(midi, dark?) with toHex(lch) for CSS. These work exactly as in shapes-julia/math.
The shader: vertexSource and fragmentSource (GLSL ES 3.00) and UNIFORMS (every uniform name) are exported so you can read or reuse them. They're tied to the view's internal camera and color model and aren't a stable API. To draw the planet, use createPlanet.
Performance
This is a ray march through a fractal for every pixel: up to 96 steps, each iterating a quaternion Julia set up to 160 times. Give it a real GPU. On slower devices the adaptive resolution trades sharpness for frame rate, so the picture gets softer rather than slower.
- Adaptive resolution. The view draws at most 30 frames a second. When frames take longer than 40 ms on average, it steps the resolution down by 20% at a time, as far as 30% of full. It steps back up by 5% only after a long calm (5 s of quick frames, and 10 s since it last stepped down), so the picture never flickers between sizes.
maxPixelRatio: 1lowers the starting point for big canvases. - It only draws while something moves. Without
reducedMotionthe planet is always turning, so it keeps drawing at 30 fps. Destroy views you're not showing. WithreducedMotionit stops completely once the planet settles. - It pauses while the page is hidden and carries on where it was when the page comes back.
- One view per screen is the intended use. Each view has its own WebGL context and full GPU load, and browsers allow only about 16 contexts per page.
How the chord picks the shape
The planet is the 3D cousin of shapes-julia's island. The chord's pitch-class Fourier transform picks a point on the edge of the Mandelbrot set's main cardioid: the fifths component chooses where, so each key has its own planet. Tension (roughness plus instability) chooses how far out the point sits: inside, the planet is one solid body, smooth for triads and wigglier as chords tense; past the edge, it crumbles into dust. The thirds component tilts c into the fourth dimension, which turns the planet's shape.
Tense chords also facet the planet into straight-edged crystal shards. Every chord's color rings in from outside, then soaks into the planet from the top down, so it remembers what was played.
Browser support
WebGL2: Safari 15+, Chrome/Edge 56+, Firefox 51+, Android System WebView on Android 7+. The code targets ES2020. TypeScript users need moduleResolution bundler, node16 or nodenext (or node, through typesVersions).
Changes
See CHANGELOG.md.
Licence
MIT. See LICENSE.
