Field guide / how it works
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.
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.
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
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.
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.
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.
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.
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
prefers-reduced-motion: the opening composition is pre-warmed further and left calm — no idle launches, no grain shimmer, no star twinkle, no sun pulse, no scroll tweens; the instrument still works when you choose to play it.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.
prewarm(s) just calls step(1/30) in a loop before the first frame. Six seconds of orbital painting cost a few milliseconds of blocking time; the veil lifts on a finished-looking rosette.[radius×R0, angle, direction, eccentricity] per planet plus a palette index. The same seeds drive three things: the opening overture, the "Play" buttons that reload the main stage, and the gallery thumbnails.paintPoster(ctx) drawing at a logical 1600×2000, so the preview just sets ctx.setTransform(0.4, ...) and calls it on a 640×800 canvas every couple of seconds while visible. Export and preview cannot drift apart.prefers-reduced-motion is set, everything is simply visible. The simulation itself never touches a library.accel(x,y) — inverse-square pull toward centre with a softening constant. Integrate with semi-implicit Euler, 4 substeps.'lighter': one wide 5%-alpha halo pass, one narrow 42% core pass. Map line width to 1/speed.√(GM/r) launch. Drag = slingshot; preview by running the same integrator forward and dotting the path.toBlob().