Chromatic Orbits ← Open the instrumentInstrument

Field guide / how it works

Painting with
gravity

Chromatic Orbits is a small orbital-mechanics simulator wearing a poster-studio's clothes. Every planet you launch obeys an inverse-square sun; every frame of its path is inked onto a hidden canvas with additive light. This page walks through the whole machine — no libraries, roughly 500 lines of vanilla JavaScript.

01 — Concept

An instrument, not a demo

The premise: orbital mechanics is already a drawing machine. A body in a gravity well traces ellipses, rosettes and spirograph petals for free — the design work is deciding what the pen looks like. Here the pen is light: trails are composited with globalCompositeOperation = 'lighter', so overlapping passes add their RGB values and bloom toward white exactly like long-exposure photography.

Three interactions cover the whole instrument: tap drops a planet into a near-circular orbit (instant gratification, works on phones), drag slingshots one with a live trajectory preview, and collisions shatter planets into particle sprays that keep feeling gravity — so even destruction paints.

02 — Physics

A softened inverse-square sun

One attractor sits at the canvas centre. Acceleration is Newton's a = GM / r² with a small softening term (+ 900, i.e. ε = 30px) so a planet skimming the core can't receive a near-infinite kick and explode numerically:

function accel(x, y) {
  const dx = CX() - x, dy = CY() - y;
  const r2 = dx*dx + dy*dy + 900;   // softening: eps² = 30px²
  const inv = 1 / Math.sqrt(r2);
  const f = MU / r2;                // GM / r²
  return [dx*inv*f, dy*inv*f];
}

main.js — the entire gravity model

Integration is semi-implicit Euler (velocity first, then position) at four substeps per frame. That integrator is symplectic: it conserves orbital energy well enough that a tapped-in planet keeps its ellipse for minutes instead of spiralling away. MU is derived from the viewport so behaviour is identical on a phone and a 5K monitor — we pick a reference radius R0 = 0.3 × minDim and solve for the GM that gives an 8-second period there:

R0 = minDim * 0.3;
const vCirc = (TAU * R0) / 8;  // 8s circular period at R0
MU = vCirc * vCirc * R0;       // since v² = GM / r

responsive gravity — tuned in seconds and screen-fractions, not magic numbers

03 — The pen

Additive light-trails

Trails live on a second, never-cleared offscreen canvas. Each physics substep strokes a tiny segment from the planet's previous position to its current one — twice. A wide whisper-alpha pass builds the halo; a narrow pass builds the core. Line width breathes with speed: near the sun a planet moves fast and inks a thin filament, at apoapsis it slows and the ink pools wide, exactly like a real pen.

function paintSegment(p, px, py) {
  const speed = Math.hypot(p.vx, p.vy);
  const w = p.r * clamp(150 / (speed + 50), 0.35, 1.7);
  tctx.globalCompositeOperation = 'lighter';   // additive: RGB sums, bloom for free
  tctx.strokeStyle = p.color;
  tctx.globalAlpha = 0.05;  tctx.lineWidth = w * 3.4;  // halo pass
  tctx.beginPath(); tctx.moveTo(px, py); tctx.lineTo(p.x, p.y); tctx.stroke();
  tctx.globalAlpha = 0.42;  tctx.lineWidth = w;        // core pass
  tctx.beginPath(); tctx.moveTo(px, py); tctx.lineTo(p.x, p.y); tctx.stroke();
}

two strokes per substep — the whole rendering trick

The optional Fade mode erases the trail canvas by 2% per frame with destination-out, turning the poster machine into a living, breathing comet tank. Clear just wipes the offscreen canvas; the orbits keep flying.

04 — Aiming

The trajectory preview is honest

While you drag, the dotted arc isn't an approximation — it runs the exact same integrator the planet will use, 340 steps ahead, and plots every seventh point with decaying size and alpha. Prediction and reality can't disagree, which is what makes slingshotting feel fair:

let x = drag.x0, y = drag.y0, ux = vx*k, uy = vy*k;
for (let i = 0; i < 340; i++) {
  const [ax, ay] = accel(x, y);
  ux += ax*h; uy += ay*h;         // same semi-implicit Euler
  x  += ux*h; y  += uy*h;
  if (Math.hypot(x - CX(), y - CY()) < SUN_R) break;  // would burn up
  if (i % 7 === 0) plotDot(x, y, 1 - i/340);
}

the preview — simulation as UI

A tap (drag under 8px) instead computes the tangential circular velocity at that point, v = √(GM/r), jittered by ±10% so orbits precess into rosettes rather than stacking into one boring ring.

05 — Violence

Collisions that keep painting

Planet pairs are checked each frame (n ≤ 34, so O(n²) is nothing). Two outcomes, split by relative speed: below 90 px/s they merge — momentum-weighted average of velocity and position, radii added in area (r = √(r₁²+r₂²)). Above it, both shatter into 16–26 spark particles plus an easing shockwave ring.

The crucial detail: sparks are not confetti. They feel 60% of the sun's gravity and stroke their own micro-segments onto the trail canvas as they die, so every explosion smears into curved pigment strokes — collisions are a brush, not an effect. Flying into the sun triggers the same burst at 1.2× scale.

06 — Colour

Six moods, five inks each

Each palette is a named object: a deep background, a sun tint, and five trail inks assigned to new planets round-robin. Backgrounds are near-black but never #000 — each carries its palette's temperature. Because blending is additive, inks are chosen bright and saturated; overlaps do the mixing.

07 — The poster

Export, framed and signed

Export composites onto a fresh 1600×2000 canvas: palette background, the trail canvas fitted into a hairline-framed plate, the sun and live planets stamped on top, then a caption block — letter-spaced wordmark, the palette name in italic Fraunces at 104px, launch count, date, and a swatch strip. document.fonts.load() guarantees the web fonts are ready before drawing text, and canvas.toBlob() hands you the PNG.

await document.fonts.load('italic 400 96px Fraunces');
p.drawImage(trail, dx, dy, dw, dh);            // the painting, aspect-fitted
p.font = 'italic 400 104px Fraunces';
p.fillText(pal.name, plate.x - 4, baseY + 112);
poster.toBlob(blob => download(blob));         // chromatic-orbits-<palette>.png

the exporter — typography is part of the artwork

08 — Craft notes

Details that carry the mood

09 — The site around it

From toy to place

The instrument alone is a fullscreen canvas — impressive for thirty seconds, then opaque. The page around it turns it into a website: the canvas stays position: fixed behind a 100svh hero (which is pointer-events: none so taps fall straight through to the simulation), and three solid-background sections scroll up over it. A sticky nav slides in once you've scrolled past 55% of the hero. Physics and rendering pause whenever the hero leaves the viewport — an IntersectionObserver flips one boolean and the rAF loop skips work.

10 — Replicate it

Build your own in an afternoon

  1. Two stacked canvases: a visible one redrawn every frame, and an offscreen trail canvas that never clears. Cap DPR at 2.
  2. Write accel(x,y) — inverse-square pull toward centre with a softening constant. Integrate with semi-implicit Euler, 4 substeps.
  3. Derive GM from the viewport (pick a radius and a period) so it feels the same on every screen.
  4. On every substep, stroke prev→current onto the trail canvas with 'lighter': one wide 5%-alpha halo pass, one narrow 42% core pass. Map line width to 1/speed.
  5. Tap = tangential √(GM/r) launch. Drag = slingshot; preview by running the same integrator forward and dotting the path.
  6. Collisions: merge when slow, burst into gravity-feeling, trail-painting particles when fast.
  7. Export: compose background + trail canvas + typography on a poster-sized canvas, then toBlob().
  8. Make it a place, not a toy: fix the canvas behind a pointer-transparent hero, scroll solid sections over it, pre-warm the opening seeds so first paint is alive, and reuse the same simulation for gallery thumbnails and a live poster preview.