← back to Rose City Rooftop Solstice
Rose City Rooftop Solstice — self-contained animated rooftop-dance gallery piece
77a9df85eb32f760ec37f120eebc592e04e26187 · 2026-09-01 16:31:24 -0700 · Steve Abrams
Files touched
A .gitignoreA README.mdA index.html
Diff
commit 77a9df85eb32f760ec37f120eebc592e04e26187
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 16:31:24 2026 -0700
Rose City Rooftop Solstice — self-contained animated rooftop-dance gallery piece
---
.gitignore | 10 +
README.md | 57 +++
index.html | 1276 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1343 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..729bb6a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
+*.png
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec0b831
--- /dev/null
+++ b/README.md
@@ -0,0 +1,57 @@
+# Rose City Rooftop Solstice
+
+A self-contained, animated urban-fantasy gallery piece. At sunset, interconnected
+city rooftops become a communal dance garden above a glowing city — the whole
+thing rendered live on a single HTML canvas with no external assets, no build
+step, and no network calls.
+
+Open `index.html` in any modern browser. That's the entire program.
+
+## What's in the scene
+
+**Four choreography zones**
+- A **turning circle** of figures orbiting an abstract rose-shaped light sculpture.
+- **Bridge couples** crossing narrow rooftop bridges in mirrored steps.
+- **Freestyle dancers** near the water towers, tracing curved motion trails.
+- A quiet **procession** climbing the fire escapes into the celebration.
+
+**Animal companions**
+- Bears sway with grounded weight, cats leap between ledges, pigeons rise in
+ coordinated spirals, dogs weave among the dancers, and moths gather at the lanterns.
+
+**Depth & atmosphere**
+- Parallax skyline layers, drifting clouds, swaying laundry lines, reflective
+ windows with a moving specular sweep, a luminous city-glow band, and light that
+ shifts from gold to indigo.
+
+**Three scenes**, each with its own palette and movement complexity:
+`Arrival` (warm, calm) → `Long Light` (luminous, fuller) → `Midnight Bloom`
+(deep indigo, moon, lanterns, the rose in full bloom). They drift automatically,
+or you can pin one.
+
+## Controls
+
+- **Cameras** — Skyline, Rooftop, Bird's-eye (with a plan-view tilt).
+- **Visual tempo** and **crowd density** sliders.
+- **Isolate group** — focus a single choreography zone; the others fade to ghosts.
+- Toggles for **animal & zone names**, **motion trails**, and **route diagrams**.
+- **Pause**, **Restart**, and a true **Reduced motion** still composition.
+
+Keyboard: `Space` pause · `1 2 3` cameras · `R` restart · `N` names ·
+`T` trails · `D` diagrams · `M` reduced motion · `C` hide panel.
+
+## An honest note
+
+The piece keeps its own time. Its rhythm, swells and blooms are **authored** — a
+composed visual tempo — and are **not** reactive to any audio or microphone.
+Turning on *Reduced motion* yields a calm, composed still (and the OS
+`prefers-reduced-motion` setting is honored on load).
+
+## Craft notes
+
+- Pure Canvas 2D, ~1.2k lines of vanilla JS in one file. Deterministic PRNG so the
+ composition is repeatable; `Restart` reseeds it.
+- Responsive + DPR-aware, keyboard-accessible native controls with strong focus
+ states, refined serif/sans type.
+- All imagery is original — the gathering evokes the generous, improvisational
+ spirit of Grateful Dead rooftop culture without reproducing any of it.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..e85ac7a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,1276 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+<meta name="color-scheme" content="dark" />
+<meta name="description" content="Rose City Rooftop Solstice — an animated urban-fantasy gallery piece: interconnected rooftops become a communal dance garden above a glowing city at sunset." />
+<title>Rose City Rooftop Solstice</title>
+<style>
+ :root{
+ --ink:#f6efe4;
+ --ink-dim:#c9bfb0;
+ --gold:#ffd98a;
+ --rose:#ffb1d8;
+ --indigo:#2a2350;
+ --panel:rgba(18,15,34,.82);
+ --panel-edge:rgba(255,214,158,.22);
+ --focus:#8fe6ff;
+ --serif:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
+ --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ }
+ *{box-sizing:border-box}
+ html,body{height:100%}
+ body{
+ margin:0;background:#0a0818;color:var(--ink);
+ font-family:var(--sans);overflow:hidden;
+ -webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
+ }
+ #stage{position:fixed;inset:0}
+ canvas{display:block;width:100%;height:100%}
+
+ /* ---- Masthead ---- */
+ .masthead{
+ position:fixed;left:clamp(14px,3vw,42px);top:clamp(12px,2.4vw,30px);
+ z-index:5;max-width:min(60ch,74vw);pointer-events:none;
+ text-shadow:0 2px 22px rgba(6,4,20,.65);
+ }
+ .kicker{
+ font:600 clamp(.62rem,1.5vw,.76rem)/1 var(--sans);
+ letter-spacing:.42em;text-transform:uppercase;color:var(--gold);
+ margin:0 0 .55em;opacity:.9;
+ }
+ .title{
+ font:500 clamp(1.7rem,6vw,3.6rem)/.98 var(--serif);
+ letter-spacing:.005em;margin:0;
+ background:linear-gradient(96deg,#fff2d8 0%,var(--rose) 58%,#c7b6ff 100%);
+ -webkit-background-clip:text;background-clip:text;color:transparent;
+ }
+ .subtitle{
+ font:italic 400 clamp(.82rem,2.1vw,1.06rem)/1.35 var(--serif);
+ color:var(--ink-dim);margin:.5em 0 0;max-width:44ch;
+ }
+ .scene-chip{
+ display:inline-flex;gap:.5em;align-items:center;margin-top:.9em;
+ pointer-events:none;font:600 .72rem/1 var(--sans);letter-spacing:.14em;
+ text-transform:uppercase;color:var(--ink);
+ background:var(--panel);border:1px solid var(--panel-edge);
+ padding:.5em .8em;border-radius:999px;backdrop-filter:blur(6px);
+ }
+ .scene-chip .dot{width:.55em;height:.55em;border-radius:50%;background:var(--gold);
+ box-shadow:0 0 10px var(--gold)}
+
+ /* ---- Control dock ---- */
+ .dock{
+ position:fixed;right:clamp(10px,2vw,26px);top:clamp(12px,2.4vw,30px);bottom:auto;
+ z-index:6;width:min(320px,86vw);max-height:calc(100dvh - 48px);overflow:auto;
+ background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
+ backdrop-filter:blur(12px) saturate(1.1);
+ box-shadow:0 18px 60px rgba(4,2,16,.5);
+ padding:14px 14px 16px;
+ transition:transform .35s cubic-bezier(.2,.8,.2,1),opacity .3s;
+ }
+ .dock[hidden]{display:block;transform:translateX(115%);opacity:0;pointer-events:none}
+ .dock h2{
+ font:600 .68rem/1 var(--sans);letter-spacing:.26em;text-transform:uppercase;
+ color:var(--gold);margin:.4em 0 .3em;
+ }
+ .dock .lede{font:400 .78rem/1.4 var(--sans);color:var(--ink-dim);margin:.1em 0 1em}
+ .grp{margin:0 0 14px;padding:0 0 12px;border-bottom:1px solid rgba(255,255,255,.07)}
+ .grp:last-of-type{border-bottom:0;padding-bottom:0}
+ .row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin:8px 0}
+ label{font:500 .82rem/1.2 var(--sans);color:var(--ink)}
+ .hint{font:400 .72rem/1.3 var(--sans);color:var(--ink-dim)}
+
+ .seg{display:flex;gap:6px;flex-wrap:wrap}
+ .seg button, .btn{
+ font:600 .78rem/1 var(--sans);color:var(--ink);
+ background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.14);
+ padding:.55em .7em;border-radius:9px;cursor:pointer;
+ transition:background .18s,border-color .18s,color .18s,transform .05s;
+ }
+ .seg button:hover,.btn:hover{background:rgba(255,255,255,.13)}
+ .seg button[aria-pressed="true"]{
+ background:linear-gradient(180deg,rgba(255,217,138,.28),rgba(255,177,216,.16));
+ border-color:var(--gold);color:#fff;
+ }
+ .btn:active{transform:translateY(1px)}
+ select{
+ font:500 .82rem/1 var(--sans);color:var(--ink);background:rgba(10,8,24,.9);
+ border:1px solid rgba(255,255,255,.18);border-radius:9px;padding:.5em .6em;min-width:130px;
+ }
+ input[type=range]{width:150px;accent-color:var(--rose);height:22px}
+ input[type=checkbox]{width:18px;height:18px;accent-color:var(--gold)}
+
+ .actions{display:flex;gap:8px;flex-wrap:wrap}
+ .actions .btn{flex:1;text-align:center;min-width:84px}
+
+ /* ---- Focus states (strong, consistent) ---- */
+ :focus{outline:none}
+ :focus-visible{
+ outline:2.5px solid var(--focus);outline-offset:2px;border-radius:8px;
+ box-shadow:0 0 0 5px rgba(143,230,255,.22);
+ }
+ .skip{
+ position:fixed;left:8px;top:-60px;z-index:20;background:#0a0818;color:var(--ink);
+ padding:.6em .9em;border-radius:8px;border:1px solid var(--panel-edge);
+ transition:top .2s;font:600 .8rem var(--sans);text-decoration:none;
+ }
+ .skip:focus{top:8px}
+
+ /* ---- Toggle + note ---- */
+ .toolbar{
+ position:fixed;left:clamp(14px,3vw,42px);bottom:clamp(12px,2.4vw,26px);z-index:6;
+ display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;max-width:min(70ch,88vw);
+ }
+ .note{
+ font:400 .74rem/1.4 var(--sans);color:var(--ink-dim);
+ background:var(--panel);border:1px solid var(--panel-edge);border-radius:12px;
+ padding:.6em .85em;max-width:40ch;backdrop-filter:blur(8px);
+ }
+ .note b{color:var(--ink);font-weight:600}
+ .dock-toggle{
+ position:fixed;right:clamp(10px,2vw,26px);bottom:clamp(12px,2.4vw,26px);z-index:7;
+ }
+
+ /* ---- Legend ---- */
+ .legend{display:flex;flex-wrap:wrap;gap:6px}
+ .legend .tag{
+ font:600 .68rem/1 var(--sans);letter-spacing:.04em;color:var(--ink);
+ border:1px solid rgba(255,255,255,.14);border-radius:999px;padding:.42em .6em;
+ background:rgba(255,255,255,.05);
+ }
+ .legend .tag i{width:.55em;height:.55em;border-radius:50%;display:inline-block;margin-right:.45em;vertical-align:middle}
+
+ @media (max-width:720px){
+ .subtitle{display:none}
+ .dock{top:auto;bottom:64px;right:8px}
+ .toolbar{max-width:64vw}
+ }
+ @media (prefers-reduced-motion:reduce){
+ .dock,.scene-chip{transition:none}
+ }
+</style>
+</head>
+<body>
+ <a class="skip" href="#controls">Skip to controls</a>
+
+ <div id="stage">
+ <canvas id="sky" role="img"
+ aria-label="An animated urban-fantasy scene at sunset: interconnected city rooftops host a communal dance garden. A ring of figures turns around a glowing rose-shaped light sculpture, couples cross narrow bridges in mirrored steps, freestyle dancers trace curved trails near water towers, and a quiet procession climbs fire escapes. Bears sway, cats leap between ledges, pigeons spiral, dogs weave, and moths gather at lanterns while the light shifts from gold to indigo."></canvas>
+ </div>
+
+ <header class="masthead">
+ <p class="kicker">A Rooftop Gathering</p>
+ <h1 class="title">Rose City Rooftop Solstice</h1>
+ <p class="subtitle">At sunset, the rooftops open like a garden — a communal dance above a glowing city, held together by light rather than sound.</p>
+ <div class="scene-chip"><span class="dot" id="sceneDot"></span><span id="sceneName">Arrival</span></div>
+ </header>
+
+ <!-- ============ CONTROL DOCK ============ -->
+ <aside class="dock" id="controls" aria-label="Composition controls">
+ <h2>Compose the piece</h2>
+ <p class="lede">Every choice below reshapes the gathering. The visual rhythm is authored — nothing here listens to audio.</p>
+
+ <div class="grp" role="group" aria-label="Camera">
+ <div class="row"><label id="camLbl">Camera</label></div>
+ <div class="seg" role="group" aria-labelledby="camLbl" id="camSeg">
+ <button data-cam="skyline" aria-pressed="false">Skyline</button>
+ <button data-cam="rooftop" aria-pressed="true">Rooftop</button>
+ <button data-cam="birdseye" aria-pressed="false">Bird’s-eye</button>
+ </div>
+ </div>
+
+ <div class="grp" role="group" aria-label="Scene">
+ <div class="row">
+ <label for="sceneSel">Scene</label>
+ <select id="sceneSel">
+ <option value="0">Arrival</option>
+ <option value="1">Long Light</option>
+ <option value="2">Midnight Bloom</option>
+ </select>
+ </div>
+ <div class="row">
+ <label for="autoScene">Drift through scenes</label>
+ <input type="checkbox" id="autoScene" checked />
+ </div>
+ <p class="hint">Each scene changes palette and movement complexity — Arrival is warm and calm, Midnight Bloom is deep and intricate.</p>
+ </div>
+
+ <div class="grp" role="group" aria-label="Motion">
+ <div class="row">
+ <label for="tempo">Visual tempo</label>
+ <input type="range" id="tempo" min="0.15" max="2" step="0.05" value="1" aria-describedby="tempoVal" />
+ </div>
+ <div class="row"><span class="hint" id="tempoVal">1.0×</span></div>
+ <div class="row">
+ <label for="density">Crowd density</label>
+ <input type="range" id="density" min="0.3" max="1.5" step="0.05" value="1" aria-describedby="densVal" />
+ </div>
+ <div class="row"><span class="hint" id="densVal">Full</span></div>
+ </div>
+
+ <div class="grp" role="group" aria-label="Choreography">
+ <div class="row">
+ <label for="isolate">Isolate group</label>
+ <select id="isolate">
+ <option value="all">All zones</option>
+ <option value="circle">Rose circle</option>
+ <option value="bridges">Bridge couples</option>
+ <option value="freestyle">Freestyle trails</option>
+ <option value="procession">Fire-escape procession</option>
+ </select>
+ </div>
+ </div>
+
+ <div class="grp" role="group" aria-label="Overlays">
+ <div class="row"><label for="tgNames">Animal & zone names</label><input type="checkbox" id="tgNames" /></div>
+ <div class="row"><label for="tgTrails">Motion trails</label><input type="checkbox" id="tgTrails" checked /></div>
+ <div class="row"><label for="tgDiag">Route diagrams</label><input type="checkbox" id="tgDiag" /></div>
+ </div>
+
+ <div class="grp" role="group" aria-label="Accessibility & playback">
+ <div class="row"><label for="tgReduce">Reduced motion (still composition)</label><input type="checkbox" id="tgReduce" /></div>
+ <div class="actions">
+ <button class="btn" id="btnPause" aria-pressed="false">Pause</button>
+ <button class="btn" id="btnRestart">Restart</button>
+ </div>
+ </div>
+
+ <div class="grp" role="group" aria-label="Companions legend">
+ <h2 style="margin-top:0">Companions</h2>
+ <div class="legend" id="legend"></div>
+ </div>
+
+ <p class="hint" style="margin-bottom:0">Keys — <b>Space</b> pause · <b>1 2 3</b> cameras · <b>R</b> restart · <b>N</b> names · <b>T</b> trails · <b>D</b> diagrams · <b>M</b> reduced motion · <b>C</b> hide panel.</p>
+ </aside>
+
+ <button class="btn dock-toggle" id="dockToggle" aria-expanded="true" aria-controls="controls">Hide panel</button>
+
+ <div class="toolbar">
+ <p class="note"><b>An honest note.</b> This piece keeps time on its own. Its rhythm, swells and blooms are <b>authored</b> — a composed visual tempo — <em>not</em> reactive to any music or microphone. Turn on <b>Reduced motion</b> for a calm, still composition.</p>
+ </div>
+
+<script>
+"use strict";
+(function(){
+/* =========================================================================
+ Rose City Rooftop Solstice — a self-contained animated gallery piece.
+ Canvas 2D. World coordinates (y grows downward). Cameras map world→screen.
+ No audio, no network, no external assets. All imagery is original.
+ ========================================================================= */
+
+const canvas = document.getElementById('sky');
+const ctx = canvas.getContext('2d', { alpha:false });
+
+/* ---------- deterministic PRNG (so the composition is repeatable) ---------- */
+function mulberry32(a){return function(){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;};}
+let rnd = mulberry32(0x50C1CE|0);
+const R = (a,b)=>a+(b-a)*rnd();
+const RI = (a,b)=>Math.floor(R(a,b+1));
+const TAU = Math.PI*2;
+const clamp=(v,a,b)=>v<a?a:v>b?b:v;
+const lerp=(a,b,t)=>a+(b-a)*t;
+
+/* ---------- color helpers ---------- */
+function hx(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
+function mix(a,b,t){return [lerp(a[0],b[0],t),lerp(a[1],b[1],t),lerp(a[2],b[2],t)];}
+function rgb(c,a){return a==null?`rgb(${c[0]|0},${c[1]|0},${c[2]|0})`:`rgba(${c[0]|0},${c[1]|0},${c[2]|0},${a})`;}
+
+/* ---------- scenes: palette + complexity ---------- */
+const SCENES = [
+ { name:'Arrival', complexity:0.55, sunY:0.72, star:0.0,
+ skyTop:hx('4a3c7e'), skyMid:hx('c8724e'), skyBot:hx('f5b862'),
+ orb:hx('ffe6a8'), roof:hx('4a3d63'), city:hx('2a2350'),
+ glow:hx('ffcf8a'), accent:hx('ffe6b0'), lantern:hx('ffb257') },
+ { name:'Long Light', complexity:0.85, sunY:0.80, star:0.15,
+ skyTop:hx('2f6d86'), skyMid:hx('e0a15a'), skyBot:hx('ffd27a'),
+ orb:hx('fff0c0'), roof:hx('3a5566'), city:hx('153043'),
+ glow:hx('ffdca0'), accent:hx('bff0e0'), lantern:hx('ffbe5e') },
+ { name:'Midnight Bloom', complexity:1.0, sunY:0.30, star:1.0,
+ skyTop:hx('0a0e2c'), skyMid:hx('241a52'), skyBot:hx('5a2f7a'),
+ orb:hx('dfe6ff'), roof:hx('241f3a'), city:hx('0a0a24'),
+ glow:hx('cf8cff'), accent:hx('ffd1f0'), lantern:hx('ffb257') },
+];
+const SCENE_DUR = 26; // seconds per scene when drifting
+
+/* ---------- global state ---------- */
+const S = {
+ cam:'rooftop', targetScene:0, autoScene:true, sceneT:0,
+ tempo:1, density:1, isolate:'all', paused:false, reduced:false,
+ names:false, trails:true, diagrams:false,
+ t:0, // animation clock (scaled by tempo)
+ cfrac:SCENES[0].complexity,
+};
+
+/* current, continuously-lerped palette + numeric fields */
+const P = {
+ skyTop:SCENES[0].skyTop.slice(), skyMid:SCENES[0].skyMid.slice(), skyBot:SCENES[0].skyBot.slice(),
+ orb:SCENES[0].orb.slice(), roof:SCENES[0].roof.slice(), city:SCENES[0].city.slice(),
+ glow:SCENES[0].glow.slice(), accent:SCENES[0].accent.slice(), lantern:SCENES[0].lantern.slice(),
+ sunY:SCENES[0].sunY, star:SCENES[0].star,
+};
+
+/* ---------- world layout ---------- */
+const WORLD = { w:1600 };
+// Foreground rooftops (platforms). top = world-y of the roof surface (feet line).
+const ROOFS = [
+ { id:'westTower', x:250, w:150, top:64, kind:'tower' },
+ { id:'proc', x:585, w:180, top:118, kind:'flat' },
+ { id:'center', x:840, w:290, top:80, kind:'plaza' },
+ { id:'eastTower', x:1360, w:150, top:52, kind:'tower' },
+];
+const roofById = id => ROOFS.find(r=>r.id===id);
+// bridges connect center to the towers (narrow crossing platforms)
+const BRIDGES = [
+ { x0:395, x1:695, y:150, side:-1 }, // west bridge (westTower→proc/center)
+ { x0:985, x1:1285, y:132, side:1 }, // east bridge (center→eastTower)
+];
+// lanterns sit around the plaza + towers
+const LANTERNS = [
+ {x:705,y:66}, {x:975,y:66}, {x:840,y:150}, {x:250,y:44}, {x:1360,y:32}, {x:585,y:100},
+];
+
+/* ---------- background parallax skyline (procedural, deterministic) ---------- */
+function buildSkyline(seed, count, baseTop, spreadTop, wMin, wMax){
+ const r = mulberry32(seed);
+ const arr=[]; let x=-200;
+ while(x < WORLD.w+300){
+ const w = wMin + r()*(wMax-wMin);
+ const top = baseTop + (r()-0.5)*spreadTop;
+ arr.push({x, w, top, lit:r()});
+ x += w + r()*30 - 6;
+ }
+ return arr;
+}
+const FAR = buildSkyline(11, 0, 230, 130, 60, 130);
+const MID = buildSkyline(29, 0, 300, 150, 80, 170);
+
+/* ---------- clouds ---------- */
+const CLOUDS = Array.from({length:9},()=>({
+ x:R(-200,WORLD.w+200), y:R(-40,180), s:R(60,150), v:R(3,9), o:R(.10,.28), d:R(0,1)>.5?1:1,
+}));
+
+/* ---------- laundry lines ---------- */
+const LAUNDRY = [
+ { x0:170,y0:120,x1:330,y1:150, items:6 },
+ { x0:900,y0:150,x1:1030,y1:190, items:5 },
+ { x0:500,y0:190,x1:665,y1:210, items:6 },
+];
+
+/* ============================ ENTITIES ============================ */
+const BODY_COLORS = ['#e8756b','#f2b25e','#6fbf9b','#7aa6e6','#c98cd8','#e79ac0','#f0d27a','#79cbd0'];
+
+/* ---- Rose circle: ring of dancers turning around the light sculpture ---- */
+const center = roofById('center');
+const ROSE = { x:center.x, y:center.top-6, R:96 };
+function makeCircle(n){
+ const a=[];
+ for(let i=0;i<n;i++){
+ a.push({ a0:(i/n)*TAU, r:ROSE.R*R(.86,1.06), col:BODY_COLORS[i%BODY_COLORS.length], ph:R(0,TAU), sz:R(.9,1.12) });
+ }
+ return a;
+}
+let CIRCLE = makeCircle(20);
+
+/* ---- Bridge couples: mirrored steps crossing narrow bridges ---- */
+function makeCouples(){
+ const c=[];
+ BRIDGES.forEach((b,bi)=>{
+ for(let k=0;k<2;k++){
+ c.push({ b:bi, off:k*0.5, ph:R(0,TAU), col:BODY_COLORS[(bi*2+k)%BODY_COLORS.length] });
+ }
+ });
+ return c;
+}
+let COUPLES = makeCouples();
+
+/* ---- Freestyle dancers near water towers: curved trails ---- */
+function makeFreestyle(n){
+ const a=[]; const anchors=[roofById('westTower'),roofById('eastTower')];
+ for(let i=0;i<n;i++){
+ const an=anchors[i%2];
+ a.push({ cx:an.x+R(-38,38), cy:an.top-R(6,26), rx:R(26,48), ry:R(14,30),
+ sp:R(.6,1.15)*(rnd()>.5?1:-1), ph:R(0,TAU), col:BODY_COLORS[(i+3)%BODY_COLORS.length],
+ trail:[] });
+ }
+ return a;
+}
+let FREE = makeFreestyle(6);
+
+/* ---- Procession: quiet climb up the fire escape, then toward the plaza ---- */
+const escBuild = roofById('proc');
+// fire escape zig-zag path on the left face of the 'proc' building, then across the roof
+function processionPath(){
+ const bx = escBuild.x - escBuild.w/2; // left edge
+ const roofY = escBuild.top;
+ const cityY = roofY + 210; // start low in the city
+ const pts=[];
+ pts.push([bx-8, cityY]);
+ let y=cityY, dir=1;
+ const steps=6;
+ for(let i=0;i<steps;i++){
+ y -= (cityY-roofY)/steps;
+ pts.push([bx + (dir>0? 26: -6), y]);
+ dir*=-1;
+ }
+ pts.push([bx+10, roofY]);
+ pts.push([escBuild.x+escBuild.w/2-10, roofY-2]); // walk across roof
+ pts.push([center.x-ROSE.R-30, center.top+4]); // arrive at the circle
+ return pts;
+}
+const PROC_PATH = processionPath();
+const PROC_LEN = (()=>{let L=0;for(let i=1;i<PROC_PATH.length;i++){L+=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);}return L;})();
+function pathAt(u){ // u in [0,1] along PROC_PATH → [x,y, angle]
+ let d=u*PROC_LEN;
+ for(let i=1;i<PROC_PATH.length;i++){
+ const seg=Math.hypot(PROC_PATH[i][0]-PROC_PATH[i-1][0],PROC_PATH[i][1]-PROC_PATH[i-1][1]);
+ if(d<=seg||i===PROC_PATH.length-1){
+ const t=seg?d/seg:0;
+ const x=lerp(PROC_PATH[i-1][0],PROC_PATH[i][0],t);
+ const y=lerp(PROC_PATH[i-1][1],PROC_PATH[i][1],t);
+ return [x,y];
+ }
+ d-=seg;
+ }
+ return PROC_PATH[PROC_PATH.length-1];
+}
+function makeProcession(n){
+ const a=[]; for(let i=0;i<n;i++) a.push({ u:i/(n+2)*0.9, col:BODY_COLORS[(i+5)%BODY_COLORS.length], ph:R(0,TAU) });
+ return a;
+}
+let PROC = makeProcession(9);
+
+/* ---- Animal companions ---- */
+let BEARS=[], CATS=[], DOGS=[], MOTHS=[], PIGEONS=null;
+function makeAnimals(){
+ BEARS = [
+ {x:center.x-150,y:center.top+2,s:1.1,ph:R(0,TAU)},
+ {x:center.x+158,y:center.top+2,s:1.0,ph:R(0,TAU)},
+ {x:escBuild.x+20,y:escBuild.top+2,s:.95,ph:R(0,TAU)},
+ ];
+ // cats leap between ledges: each has a start ledge & end ledge and a phase
+ const ledges=[[roofById('westTower').x, roofById('westTower').top],
+ [escBuild.x-escBuild.w/2+10, escBuild.top],
+ [center.x-ROSE.R-40, center.top],
+ [center.x+ROSE.R+40, center.top],
+ [roofById('eastTower').x, roofById('eastTower').top]];
+ CATS=[];
+ for(let i=0;i<4;i++){
+ const p0=ledges[i%ledges.length], p1=ledges[(i+1)%ledges.length];
+ CATS.push({p0,p1,ph:R(0,TAU),sp:R(.4,.7)});
+ }
+ // dogs weave among the circle (sine path across plaza)
+ DOGS=[];
+ for(let i=0;i<3;i++) DOGS.push({ph:R(0,TAU),sp:R(.5,.8),amp:R(26,40),col:i});
+ // moths cloud around each lantern
+ MOTHS=[];
+ LANTERNS.forEach((L,li)=>{ for(let k=0;k<6;k++) MOTHS.push({L:li,a:R(0,TAU),r:R(6,20),sp:R(1.4,2.6)*(rnd()>.5?1:-1),ph:R(0,TAU)}); });
+ // pigeons: a flock that periodically launches into a rising spiral
+ PIGEONS={ home:[escBuild.x+40, escBuild.top-4], birds:Array.from({length:14},(_,i)=>({a:R(0,TAU),r0:R(2,18),ph:R(0,TAU),i})), cycle:R(0,8) };
+}
+makeAnimals();
+
+/* rebuild population-scaled entities on density change */
+function rebuildDensity(){
+ const d=S.density;
+ CIRCLE = makeCircle(Math.round(20*d));
+ FREE = makeFreestyle(Math.max(2,Math.round(6*d)));
+ PROC = makeProcession(Math.max(3,Math.round(9*d)));
+ // couples & animals stay (small, structural)
+}
+
+/* ============================ CAMERA ============================ */
+const CAM_PRESETS = {
+ skyline: { scale:0.60, cx:820, cy:150, tilt:0.02, name:'Skyline' },
+ rooftop: { scale:1.34, cx:840, cy:60, tilt:0.12, name:'Rooftop' },
+ birdseye: { scale:0.78, cx:840, cy:90, tilt:0.72, name:'Bird’s-eye' },
+};
+const cam = { scale:1.08, cx:840, cy:70, tilt:0.10 };
+let VW=0, VH=0, DPR=1, screenCx=0, screenCy=0;
+
+function project(wx,wy,depth){
+ depth = depth==null?1:depth;
+ const s = cam.scale;
+ const sx = screenCx + (wx - cam.cx) * s * depth;
+ const sy = screenCy + (wy - cam.cy) * s * (1 - 0.5*cam.tilt);
+ return [sx, sy];
+}
+function pscale(depth){ return cam.scale * (depth==null?1:depth); }
+
+/* ============================ RESIZE ============================ */
+function resize(){
+ DPR = Math.min(window.devicePixelRatio||1, 2);
+ VW = window.innerWidth; VH = window.innerHeight;
+ canvas.width = Math.round(VW*DPR); canvas.height = Math.round(VH*DPR);
+ canvas.style.width=VW+'px'; canvas.style.height=VH+'px';
+ ctx.setTransform(DPR,0,0,DPR,0,0);
+ screenCx = VW*0.5;
+ screenCy = VH*0.62;
+}
+window.addEventListener('resize', resize);
+
+/* ============================ DRAW: SKY & BACKDROP ============================ */
+function drawSky(){
+ const g=ctx.createLinearGradient(0,0,0,VH);
+ g.addColorStop(0, rgb(P.skyTop));
+ g.addColorStop(0.52, rgb(P.skyMid));
+ g.addColorStop(1, rgb(P.skyBot));
+ ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
+
+ // stars (fade in for midnight)
+ if(P.star>0.02){
+ ctx.save(); ctx.globalAlpha=P.star;
+ const sr=mulberry32(7);
+ for(let i=0;i<120;i++){
+ const x=sr()*VW, y=sr()*VH*0.55, tw=0.4+0.6*Math.abs(Math.sin(S.t*0.6+i));
+ ctx.globalAlpha=P.star*tw*0.9;
+ ctx.fillStyle= i%9===0? rgb(P.accent):'#fff';
+ const s=sr()*1.6+0.3; ctx.fillRect(x,y,s,s);
+ }
+ ctx.restore();
+ }
+
+ // sun / moon — a soft luminous orb near the horizon
+ const orbX = screenCx + (890 - cam.cx)*cam.scale*0.4;
+ const orbY = VH*P.sunY;
+ const orbR = Math.max(48, VH*0.14)*(0.8+0.2*cam.scale);
+ const halo=ctx.createRadialGradient(orbX,orbY,orbR*0.2,orbX,orbY,orbR*3.4);
+ halo.addColorStop(0, rgb(P.orb,0.9));
+ halo.addColorStop(0.18, rgb(P.orb,0.34));
+ halo.addColorStop(1, rgb(P.orb,0));
+ ctx.fillStyle=halo; ctx.fillRect(0,0,VW,VH);
+ ctx.beginPath(); ctx.arc(orbX,orbY,orbR,0,TAU);
+ ctx.fillStyle=rgb(P.orb,0.96); ctx.fill();
+
+ // clouds drift (tinted by light)
+ const cloudCol = mix(P.skyMid,[255,255,255],0.35);
+ CLOUDS.forEach(c=>{
+ const [x,y]=project(c.x,c.y,0.5);
+ const s=c.s*cam.scale*0.5;
+ ctx.save(); ctx.globalAlpha=c.o*(0.7+0.3*(1-P.star));
+ ctx.fillStyle=rgb(cloudCol);
+ puff(x,y,s);
+ ctx.restore();
+ });
+}
+function puff(x,y,s){
+ ctx.beginPath();
+ ctx.ellipse(x,y,s,s*0.5,0,0,TAU);
+ ctx.ellipse(x-s*0.6,y+s*0.12,s*0.55,s*0.34,0,0,TAU);
+ ctx.ellipse(x+s*0.6,y+s*0.14,s*0.6,s*0.36,0,0,TAU);
+ ctx.fill();
+}
+
+function drawSkylineLayer(list, depth, colBase, alpha){
+ ctx.save(); ctx.globalAlpha=alpha; ctx.fillStyle=rgb(colBase);
+ list.forEach(b=>{
+ const [x,y]=project(b.x,b.top,depth);
+ const w=b.w*pscale(depth), h=(700)*pscale(depth);
+ ctx.fillRect(x,y,w,h);
+ // scattered lit windows
+ if(b.lit>0.3){
+ ctx.save(); ctx.globalAlpha=alpha*clamp(P.star+0.25,0.2,0.9);
+ ctx.fillStyle=rgb(P.lantern, 0.7);
+ const cols=Math.max(1,(w/12)|0), rows=Math.max(1,(h/16)|0);
+ const wr=mulberry32((b.x*7+b.top)|0);
+ for(let cc=0;cc<cols;cc++)for(let rr=0;rr<rows;rr++){
+ if(wr()>0.86){ ctx.fillRect(x+4+cc*12, y+6+rr*16, 4, 6); }
+ }
+ ctx.restore();
+ }
+ });
+ ctx.restore();
+}
+
+function drawCityGlow(){
+ // luminous band where the city meets the rooftops
+ const [,y]=project(0, 320, 1);
+ const g=ctx.createLinearGradient(0,y-60,0,y+120);
+ g.addColorStop(0, rgb(P.glow,0));
+ g.addColorStop(0.5, rgb(P.glow,0.18));
+ g.addColorStop(1, rgb(P.glow,0));
+ ctx.fillStyle=g; ctx.fillRect(0,y-60,VW,180);
+}
+
+/* ============================ DRAW: ROOFTOP ARCHITECTURE ============================ */
+function drawRoofs(){
+ ROOFS.forEach(r=>{
+ const half=r.w/2;
+ const [lx,ty]=project(r.x-half, r.top);
+ const [rx]=project(r.x+half, r.top);
+ const w=rx-lx;
+ const bottom=VH+40;
+ // building body
+ const bodyCol = mix(P.roof, P.city, 0.15);
+ ctx.fillStyle=rgb(bodyCol);
+ ctx.fillRect(lx, ty, w, bottom-ty);
+ // reflective window grid with a sweeping specular highlight
+ drawWindows(lx,ty,w,bottom-ty, r);
+ // roof cap (ellipse) — reads as looking-down when tilted
+ if(cam.tilt>0.05){
+ const capH = Math.max(4, w*0.16*cam.tilt);
+ ctx.beginPath();
+ ctx.ellipse(lx+w/2, ty, w/2, capH, 0, 0, TAU);
+ ctx.fillStyle=rgb(mix(P.roof,P.accent,0.12));
+ ctx.fill();
+ // edge light
+ ctx.strokeStyle=rgb(P.glow,0.35); ctx.lineWidth=1.5; ctx.stroke();
+ } else {
+ // parapet edge highlight
+ ctx.fillStyle=rgb(mix(P.roof,P.accent,0.22));
+ ctx.fillRect(lx, ty-3*cam.scale, w, 3*cam.scale);
+ }
+ // water tower on tower roofs
+ if(r.kind==='tower') drawWaterTower(r);
+ if(r.id==='proc') drawFireEscape(r);
+ });
+ // bridges
+ BRIDGES.forEach(b=>{
+ const [x0,y0]=project(b.x0,b.y), [x1,y1]=project(b.x1,b.y);
+ ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.3)); ctx.lineWidth=Math.max(4,7*cam.scale); ctx.lineCap='round';
+ ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
+ // rail lights
+ ctx.strokeStyle=rgb(P.glow,0.5); ctx.lineWidth=1.4;
+ ctx.beginPath(); ctx.moveTo(x0,y0-6*cam.scale); ctx.lineTo(x1,y1-6*cam.scale); ctx.stroke();
+ });
+ // laundry lines swaying
+ drawLaundry();
+ // lanterns (glow)
+ LANTERNS.forEach((L,i)=>drawLantern(L,i));
+}
+
+function drawWindows(x,y,w,h,r){
+ const cols=Math.max(2,(w/(16*cam.scale))|0);
+ const rows=Math.max(3,(h/(20))|0);
+ const cw=w/cols, ch=Math.min(26, (h/rows));
+ const wr=mulberry32((r.x*13+r.top*3)|0);
+ const sweep=((S.t*40 + r.x) % (w+220)) - 110; // moving specular
+ for(let c=0;c<cols;c++){
+ for(let rr=0;rr<rows;rr++){
+ const wx=x+c*cw+cw*0.18, wy=y+ch*0.5+rr*ch, ww=cw*0.64, wh=ch*0.6;
+ const lit = wr()>0.55;
+ // reflective glass base
+ const base = lit ? mix(P.glow,[255,255,255],0.15) : mix(P.city,[255,255,255],0.05);
+ ctx.fillStyle=rgb(base, lit?0.6:0.4);
+ ctx.fillRect(wx,wy,ww,wh);
+ // specular sweep highlight
+ const dx=(wx-x)-sweep;
+ if(dx>-30 && dx<30){
+ const a=(1-Math.abs(dx)/30)*0.5;
+ ctx.fillStyle=rgb(P.accent,a); ctx.fillRect(wx,wy,ww,wh);
+ }
+ }
+ }
+}
+
+function drawWaterTower(r){
+ const [cx,ty]=project(r.x, r.top);
+ const s=cam.scale;
+ const tw=34*s, th=40*s, legH=26*s;
+ const bx=cx, by=ty - (cam.tilt>0.05?4:0);
+ // legs
+ ctx.strokeStyle=rgb(mix(P.roof,P.city,0.2)); ctx.lineWidth=2*s;
+ ctx.beginPath();
+ ctx.moveTo(bx-tw*0.4, by); ctx.lineTo(bx-tw*0.3, by-legH);
+ ctx.moveTo(bx+tw*0.4, by); ctx.lineTo(bx+tw*0.3, by-legH);
+ ctx.stroke();
+ // tank
+ const tankY=by-legH;
+ ctx.fillStyle=rgb(mix(P.roof,P.accent,0.16));
+ ctx.beginPath();
+ ctx.moveTo(bx-tw/2, tankY);
+ ctx.lineTo(bx+tw/2, tankY);
+ ctx.lineTo(bx+tw/2, tankY-th);
+ ctx.lineTo(bx, tankY-th-tw*0.35);
+ ctx.lineTo(bx-tw/2, tankY-th);
+ ctx.closePath(); ctx.fill();
+ ctx.strokeStyle=rgb(P.glow,0.4); ctx.lineWidth=1; ctx.stroke();
+}
+
+function drawFireEscape(r){
+ const bx=r.x - r.w/2;
+ const [x0]=project(bx, r.top);
+ const s=cam.scale;
+ ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.35),0.85); ctx.lineWidth=1.4;
+ // zig-zag rails matching the procession path region
+ for(let i=1;i<PROC_PATH.length-2;i++){
+ const [ax,ay]=project(PROC_PATH[i-1][0],PROC_PATH[i-1][1]);
+ const [bx2,by2]=project(PROC_PATH[i][0],PROC_PATH[i][1]);
+ ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx2,by2); ctx.stroke();
+ // rungs
+ ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(ax-5*s,ay); ctx.stroke();
+ }
+}
+
+function drawLaundry(){
+ LAUNDRY.forEach((L,li)=>{
+ const [x0,y0]=project(L.x0,L.y0), [x1,y1]=project(L.x1,L.y1);
+ ctx.strokeStyle=rgb(mix(P.roof,P.accent,0.4),0.5); ctx.lineWidth=1;
+ ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke();
+ for(let i=0;i<L.items;i++){
+ const t=(i+0.5)/L.items;
+ const px=lerp(x0,x1,t), py=lerp(y0,y1,t);
+ const sway=Math.sin(S.t*1.4 + i*1.1 + li)*0.28;
+ const w=8*cam.scale, h=13*cam.scale;
+ ctx.save(); ctx.translate(px,py); ctx.rotate(sway);
+ ctx.fillStyle=rgb(BODY_COLORS_RGB[(i+li)%BODY_COLORS_RGB.length], 0.62);
+ ctx.fillRect(-w/2,0,w,h);
+ ctx.restore();
+ }
+ });
+}
+const BODY_COLORS_RGB = BODY_COLORS.map(hx);
+
+function drawLantern(L,i){
+ const [x,y]=project(L.x,L.y);
+ const s=cam.scale;
+ const pulse=0.75+0.25*Math.sin(S.t*1.5+i);
+ const rad=14*s*pulse*(0.7+0.6*P.star);
+ const g=ctx.createRadialGradient(x,y,1,x,y,rad*2.6);
+ g.addColorStop(0, rgb(P.lantern, 0.9));
+ g.addColorStop(0.4, rgb(P.lantern, 0.32));
+ g.addColorStop(1, rgb(P.lantern, 0));
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,rad*2.6,0,TAU); ctx.fill();
+ // post
+ ctx.strokeStyle=rgb(mix(P.roof,P.city,0.3)); ctx.lineWidth=1.5*s;
+ ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(x,y+16*s); ctx.stroke();
+ ctx.fillStyle=rgb(P.lantern); ctx.beginPath(); ctx.arc(x,y,3.4*s,0,TAU); ctx.fill();
+}
+
+/* ============================ FIGURES ============================ */
+// generic dancer. feet at (x,y). style: 'dance'|'walk'|'spin'
+function figure(x,y,sc,ph,col,style,lean,alpha){
+ const s=sc*cam.scale;
+ if(s<0.6) { // too small: dot
+ ctx.globalAlpha=(alpha==null?1:alpha); ctx.fillStyle=col;
+ ctx.beginPath(); ctx.arc(x,y-6*s,2.4*s,0,TAU); ctx.fill(); ctx.globalAlpha=1; return;
+ }
+ const bob = S.reduced?0:Math.sin(ph)*2*s;
+ const swing = S.reduced?0.25:Math.sin(ph);
+ lean = (lean||0);
+ ctx.save();
+ ctx.globalAlpha=(alpha==null?1:alpha);
+ ctx.translate(x, y-bob);
+ ctx.rotate(lean);
+ ctx.strokeStyle=col; ctx.lineCap='round'; ctx.lineJoin='round';
+ ctx.lineWidth=3.4*s;
+ const hip=-13*s, sh=-27*s, head=-33*s;
+ // legs
+ const legSp = style==='walk'? swing*6*s : Math.abs(swing)*4*s;
+ ctx.beginPath();
+ ctx.moveTo(0,hip); ctx.lineTo(-legSp*0.5, 0);
+ ctx.moveTo(0,hip); ctx.lineTo(legSp*0.5, 0);
+ ctx.stroke();
+ // torso
+ ctx.beginPath(); ctx.moveTo(0,hip); ctx.lineTo(0,sh); ctx.stroke();
+ // arms
+ ctx.beginPath();
+ if(style==='dance'||style==='spin'){
+ const a=0.7+0.5*swing;
+ ctx.moveTo(0,sh); ctx.lineTo(-11*s*a, sh-9*s*a);
+ ctx.moveTo(0,sh); ctx.lineTo(11*s*(1.2-a), sh-9*s*(1.2-a));
+ } else {
+ ctx.moveTo(0,sh); ctx.lineTo(-8*s, sh+8*s*swing);
+ ctx.moveTo(0,sh); ctx.lineTo(8*s, sh-8*s*swing);
+ }
+ ctx.stroke();
+ // head
+ ctx.fillStyle=col; ctx.beginPath(); ctx.arc(0,head,5*s,0,TAU); ctx.fill();
+ ctx.restore();
+ ctx.globalAlpha=1;
+}
+
+/* ---------- Rose light sculpture ---------- */
+function drawRose(){
+ const [x,y]=project(ROSE.x, ROSE.y);
+ const s=cam.scale;
+ const bloom = clamp((S.cfrac-0.4)/0.6, 0, 1); // fuller in later scenes
+ const rot = S.reduced?0.4:S.t*0.35;
+ const layers=5, petalsPer=8;
+ const baseR=ROSE.R*s*(0.42+0.28*bloom);
+ // ground glow
+ const g=ctx.createRadialGradient(x,y,2,x,y,baseR*2.6);
+ g.addColorStop(0, rgb(P.glow,0.85));
+ g.addColorStop(0.5, rgb(P.glow,0.28));
+ g.addColorStop(1, rgb(P.glow,0));
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,baseR*2.6,0,TAU); ctx.fill();
+
+ ctx.save(); ctx.translate(x,y);
+ for(let l=0;l<layers;l++){
+ const lr = baseR*(0.35+ l*0.2);
+ const alpha = 0.5 - l*0.06;
+ const spin = rot*(l%2?-1:1)*(1+l*0.15);
+ for(let p=0;p<petalsPer;p++){
+ const a = spin + (p/petalsPer)*TAU + l*0.3;
+ const px=Math.cos(a)*lr*0.5, py=Math.sin(a)*lr*0.5;
+ ctx.save(); ctx.translate(px,py); ctx.rotate(a);
+ const petalCol = mix(P.glow, P.accent, l/layers);
+ ctx.fillStyle=rgb(petalCol, alpha*(0.7+0.3*bloom));
+ ctx.beginPath();
+ ctx.ellipse(lr*0.5,0, lr*0.6, lr*0.26, 0, 0, TAU);
+ ctx.fill();
+ ctx.restore();
+ }
+ }
+ // bright core
+ const core=ctx.createRadialGradient(0,0,1,0,0,baseR*0.6);
+ core.addColorStop(0,'#ffffff'); core.addColorStop(0.5,rgb(P.accent,0.9)); core.addColorStop(1,rgb(P.glow,0));
+ ctx.fillStyle=core; ctx.beginPath(); ctx.arc(0,0,baseR*0.6,0,TAU); ctx.fill();
+ ctx.restore();
+}
+
+/* ---------- draw the four choreography zones ---------- */
+function show(group){ return S.isolate==='all' || S.isolate===group; }
+function ghostAlpha(group){ return show(group)?1:0.12; }
+
+function drawCircle(){
+ const al=ghostAlpha('circle');
+ if(al<=0.12 && S.isolate!=='all'){} // still draw faint
+ const rot = S.reduced?0.3:S.t*0.32;
+ // sort by depth (back first) using sin of angle for tilt overlap
+ const list = CIRCLE.map(d=>{const a=d.a0+rot; return {d,a,depthY:Math.sin(a)};}).sort((p,q)=>p.depthY-q.depthY);
+ list.forEach(({d,a})=>{
+ const wx=ROSE.x+Math.cos(a)*d.r;
+ const tiltY = cam.tilt* Math.sin(a)* d.r*0.5;
+ const wy=ROSE.y - 4 + Math.sin(a)*d.r*0.14 + tiltY;
+ const [x,y]=project(wx,wy);
+ const lean = Math.cos(a)*0.18; // lean into the turn
+ figure(x,y,d.sz,S.t*3*S.tempo0+d.ph, d.col,'spin',lean, al);
+ });
+}
+
+function drawCouples(){
+ const al=ghostAlpha('bridges');
+ COUPLES.forEach(c=>{
+ const b=BRIDGES[c.b];
+ const tri = 0.5+0.5*Math.sin(S.t*0.7 + c.off*Math.PI); // 0..1 crossing
+ const wx=lerp(b.x0,b.x1, tri);
+ const wy=lerp(b.y0!=null?b.y0:b.y, b.y1!=null?b.y1:b.y, tri) - 8;
+ // partner mirrored across bridge centre
+ const midT = 0.5;
+ for(let m=0;m<2;m++){
+ const t = m===0? tri : (1-tri);
+ const px=lerp(b.x0,b.x1,t);
+ const py=b.y-9;
+ const [x,y]=project(px,py);
+ const lean=(m===0?1:-1)*0.14*Math.sin(S.t*2+c.ph);
+ figure(x,y,0.98,S.t*2.4*S.tempo0+c.ph+m*Math.PI, c.col,'dance',lean, al);
+ }
+ });
+}
+
+function drawFreestyle(){
+ const al=ghostAlpha('freestyle');
+ FREE.forEach(f=>{
+ const a=f.ph + S.t*f.sp*1.4;
+ const wx=f.cx+Math.cos(a)*f.rx;
+ const wy=f.cy+Math.sin(a*1.3)*f.ry - 6;
+ const [x,y]=project(wx,wy);
+ // trail
+ if(S.trails && show('freestyle') && !S.reduced){
+ f.trail.push([x,y]); if(f.trail.length>26) f.trail.shift();
+ ctx.lineCap='round';
+ for(let i=1;i<f.trail.length;i++){
+ const t=i/f.trail.length;
+ ctx.strokeStyle=rgb(hx(f.col), t*0.5*al);
+ ctx.lineWidth=(1+t*3)*cam.scale;
+ ctx.beginPath(); ctx.moveTo(f.trail[i-1][0],f.trail[i-1][1]); ctx.lineTo(f.trail[i][0],f.trail[i][1]); ctx.stroke();
+ }
+ } else if(!S.trails){ f.trail.length=0; }
+ const lean=Math.cos(a)*0.3;
+ figure(x,y,1.0,a*2.2, f.col,'dance',lean, al);
+ });
+}
+
+function drawProcession(){
+ const al=ghostAlpha('procession');
+ const flow = S.reduced?0: (S.t*0.02*S.tempo0);
+ PROC.forEach((p,i)=>{
+ let u=(p.u + flow) % 1;
+ const [wx,wy]=pathAt(u);
+ const [x,y]=project(wx,wy);
+ figure(x,y,0.92, S.t*3+p.ph, p.col,'walk', 0, al);
+ });
+}
+
+/* ============================ ANIMALS ============================ */
+function drawBears(){
+ BEARS.forEach(b=>{
+ const [x,y]=project(b.x,b.y); const s=b.s*cam.scale;
+ if(s<0.5) return;
+ const sway=S.reduced?0:Math.sin(S.t*1.1+b.ph)*0.10;
+ ctx.save(); ctx.translate(x,y); ctx.rotate(sway);
+ const col=mix(P.roof,[60,44,38],0.5);
+ ctx.fillStyle=rgb(col);
+ // body
+ ctx.beginPath(); ctx.ellipse(0,-13*s,13*s,16*s,0,0,TAU); ctx.fill();
+ // head
+ ctx.beginPath(); ctx.arc(0,-32*s,9*s,0,TAU); ctx.fill();
+ // ears
+ ctx.beginPath(); ctx.arc(-6*s,-39*s,3.4*s,0,TAU); ctx.arc(6*s,-39*s,3.4*s,0,TAU); ctx.fill();
+ // legs
+ ctx.fillRect(-10*s,-4*s,6*s,6*s); ctx.fillRect(4*s,-4*s,6*s,6*s);
+ ctx.restore();
+ });
+}
+function drawDogs(){
+ if(!show('circle') && S.isolate!=='all') return;
+ DOGS.forEach(d=>{
+ const t=S.t*d.sp;
+ const wx=ROSE.x + Math.sin(t)*(ROSE.R+40);
+ const wy=ROSE.y+8 + Math.cos(t*1.6)*d.amp*0.3;
+ const [x,y]=project(wx,wy); const s=cam.scale;
+ if(s<0.5) return;
+ const col=BODY_COLORS_RGB[(d.col+2)%BODY_COLORS_RGB.length];
+ ctx.save(); ctx.translate(x,y);
+ const dir=Math.cos(t)>=0?1:-1; ctx.scale(dir,1);
+ ctx.fillStyle=rgb(col);
+ ctx.beginPath(); ctx.ellipse(0,-6*s,9*s,4.6*s,0,0,TAU); ctx.fill(); // body
+ ctx.beginPath(); ctx.arc(8*s,-9*s,3.6*s,0,TAU); ctx.fill(); // head
+ ctx.fillRect(9*s,-12*s,4*s,2*s); // snout
+ // legs
+ const lp=Math.sin(S.t*8*S.tempo0)*2*s;
+ ctx.fillRect(-6*s,-2*s,2*s,4*s+lp); ctx.fillRect(4*s,-2*s,2*s,4*s-lp);
+ // tail wag
+ ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
+ ctx.moveTo(-8*s,-8*s); ctx.lineTo(-13*s,-11*s+Math.sin(S.t*12)*2*s); ctx.stroke();
+ ctx.restore();
+ });
+}
+function drawCats(){
+ CATS.forEach(c=>{
+ const t=(Math.sin(S.t*c.sp+c.ph)*0.5+0.5); // 0..1 leap progress
+ const wx=lerp(c.p0[0],c.p1[0],t);
+ const arc=Math.sin(t*Math.PI)*34;
+ const wy=lerp(c.p0[1],c.p1[1],t)-arc-4;
+ const [x,y]=project(wx,wy); const s=cam.scale;
+ if(s<0.5) return;
+ const leaping=arc>6;
+ const col=mix(P.roof,[30,26,40],0.4);
+ ctx.save(); ctx.translate(x,y);
+ const dir=(c.p1[0]-c.p0[0])>=0?1:-1; ctx.scale(dir,1);
+ ctx.fillStyle=rgb(col);
+ if(leaping){
+ ctx.beginPath(); ctx.ellipse(0,0,9*s,3.2*s,-0.2,0,TAU); ctx.fill();
+ } else {
+ ctx.beginPath(); ctx.ellipse(0,-2*s,7*s,3.6*s,0,0,TAU); ctx.fill();
+ }
+ ctx.beginPath(); ctx.arc(7*s,-3*s,2.8*s,0,TAU); ctx.fill(); // head
+ // ears
+ ctx.beginPath(); ctx.moveTo(6*s,-5*s); ctx.lineTo(6.5*s,-8*s); ctx.lineTo(8*s,-5*s); ctx.fill();
+ // tail
+ ctx.strokeStyle=rgb(col); ctx.lineWidth=2*s; ctx.beginPath();
+ ctx.moveTo(-7*s,-2*s); ctx.quadraticCurveTo(-13*s,-6*s,-11*s,-11*s); ctx.stroke();
+ ctx.restore();
+ });
+}
+function drawPigeons(){
+ const t=S.t*0.5*S.tempo0 + PIGEONS.cycle;
+ const phase=(t%8)/8; // 0..1 launch/rise/settle
+ const rise = phase<0.7? (phase/0.7): (1-(phase-0.7)/0.3);
+ const [hx0,hy0]=PIGEONS.home;
+ PIGEONS.birds.forEach(bd=>{
+ const spiral = t*2 + bd.a;
+ const r = bd.r0 + rise*70 + Math.sin(spiral)*6;
+ const wx = hx0 + Math.cos(spiral)*r*0.9;
+ const wy = hy0 - rise*150 + Math.sin(spiral)*r*0.3;
+ const [x,y]=project(wx,wy); const s=cam.scale;
+ const flap=Math.sin(S.t*14 + bd.ph);
+ ctx.save(); ctx.translate(x,y);
+ ctx.strokeStyle=rgb(mix(P.accent,[240,240,255],0.4), 0.9); ctx.lineWidth=1.6*s; ctx.lineCap='round';
+ const wsp=3.4*s*(0.4+0.6*Math.abs(flap));
+ ctx.beginPath();
+ ctx.moveTo(-wsp, -flap*2*s); ctx.lineTo(0,0); ctx.lineTo(wsp, -flap*2*s);
+ ctx.stroke();
+ ctx.restore();
+ });
+}
+function drawMoths(){
+ MOTHS.forEach(m=>{
+ const L=LANTERNS[m.L];
+ const a=m.a + S.t*m.sp;
+ const wx=L.x + Math.cos(a)*m.r + Math.sin(S.t*3+m.ph)*3;
+ const wy=L.y + Math.sin(a*1.3)*m.r*0.7 + Math.cos(S.t*3.4+m.ph)*3;
+ const [x,y]=project(wx,wy); const s=cam.scale;
+ const flap=Math.sin(S.t*18+m.ph);
+ ctx.save(); ctx.translate(x,y);
+ ctx.fillStyle=rgb(mix(P.lantern,[255,240,210],0.5), 0.85);
+ ctx.beginPath();
+ ctx.ellipse(-1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6, -0.5,0,TAU);
+ ctx.ellipse( 1.6*s, 0, 1.8*s, 3*s*Math.abs(flap)+0.6, 0.5,0,TAU);
+ ctx.fill();
+ ctx.restore();
+ });
+}
+
+/* ============================ ROUTE DIAGRAMS ============================ */
+function dashedArc(x0,y0,x1,y1,bend,col,label){
+ const mx=(x0+x1)/2, my=(y0+y1)/2 - bend;
+ ctx.save();
+ ctx.setLineDash([6,7]); ctx.lineDashOffset=-S.t*20;
+ ctx.strokeStyle=col; ctx.lineWidth=1.6;
+ ctx.beginPath(); ctx.moveTo(x0,y0); ctx.quadraticCurveTo(mx,my,x1,y1); ctx.stroke();
+ ctx.setLineDash([]);
+ // node dots
+ ctx.fillStyle=col; ctx.beginPath(); ctx.arc(x0,y0,3,0,TAU); ctx.arc(x1,y1,3,0,TAU); ctx.fill();
+ if(label){
+ ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
+ ctx.fillStyle=rgb(P.accent,0.95);
+ ctx.textAlign='center';
+ ctx.fillText(label, mx, my-4);
+ }
+ ctx.restore();
+}
+function drawDiagrams(){
+ const c=rgb(P.accent,0.6);
+ // procession route
+ ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
+ ctx.strokeStyle=rgb(P.accent,0.55); ctx.lineWidth=1.6; ctx.beginPath();
+ for(let i=0;i<PROC_PATH.length;i++){const [x,y]=project(PROC_PATH[i][0],PROC_PATH[i][1]); i?ctx.lineTo(x,y):ctx.moveTo(x,y);} ctx.stroke();
+ ctx.setLineDash([]); ctx.restore();
+ const midP=pathAt(0.5); const [mpx,mpy]=project(midP[0],midP[1]);
+ labelTag('Ascent · procession', mpx, mpy-10);
+ // bridge crossings
+ BRIDGES.forEach((b,i)=>{
+ const [x0,y0]=project(b.x0,b.y-12),[x1,y1]=project(b.x1,b.y-12);
+ dashedArc(x0,y0,x1,y1,26,c, i===0?'Mirrored crossing':'Mirrored crossing');
+ });
+ // freestyle loops near towers
+ [roofById('westTower'),roofById('eastTower')].forEach(tw=>{
+ const [x,y]=project(tw.x, tw.top-16);
+ ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=-S.t*20;
+ ctx.strokeStyle=c; ctx.lineWidth=1.6;
+ ctx.beginPath(); ctx.ellipse(x,y, 42*cam.scale, 24*cam.scale, 0,0,TAU); ctx.stroke();
+ ctx.setLineDash([]); ctx.restore();
+ labelTag('Spiral · freestyle', x, y-30*cam.scale);
+ });
+ // circle
+ const [rx,ry]=project(ROSE.x,ROSE.y);
+ ctx.save(); ctx.setLineDash([5,6]); ctx.lineDashOffset=S.t*20;
+ ctx.strokeStyle=c; ctx.lineWidth=1.6;
+ ctx.beginPath(); ctx.ellipse(rx,ry, ROSE.R*cam.scale, ROSE.R*cam.scale*(1-0.5*cam.tilt),0,0,TAU); ctx.stroke();
+ ctx.setLineDash([]); ctx.restore();
+ labelTag('Turning circle · the rose', rx, ry - (ROSE.R*cam.scale*(1-0.5*cam.tilt))-8);
+}
+function labelTag(text,x,y){
+ ctx.save();
+ ctx.font='600 11px ui-sans-serif,system-ui,sans-serif';
+ ctx.textAlign='center';
+ const w=ctx.measureText(text).width+12;
+ ctx.fillStyle='rgba(12,10,26,.72)';
+ roundRect(x-w/2,y-13,w,17,8); ctx.fill();
+ ctx.fillStyle=rgb(P.accent,0.98);
+ ctx.fillText(text,x,y-0.5);
+ ctx.restore();
+}
+function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}
+
+/* ============================ NAME LABELS ============================ */
+function drawNames(){
+ const items=[
+ ['Bears', BEARS[0].x, BEARS[0].y-34],
+ ['Cats', CATS[0].p0[0], CATS[0].p0[1]-14],
+ ['Pigeons', PIGEONS.home[0]+30, PIGEONS.home[1]-70],
+ ['Dogs', ROSE.x, ROSE.y+22],
+ ['Moths', LANTERNS[0].x, LANTERNS[0].y-24],
+ ['The Rose', ROSE.x, ROSE.y-ROSE.R-4],
+ ['Water tower', roofById('eastTower').x, roofById('eastTower').top-46],
+ ];
+ items.forEach(([t,wx,wy])=>{ const [x,y]=project(wx,wy); labelTag(t,x,y); });
+}
+
+/* ============================ VIGNETTE ============================ */
+function drawVignette(){
+ const g=ctx.createRadialGradient(VW/2,VH*0.5,Math.min(VW,VH)*0.35, VW/2,VH*0.55, Math.max(VW,VH)*0.75);
+ g.addColorStop(0,'rgba(0,0,0,0)');
+ g.addColorStop(1, 'rgba(6,4,18,0.5)');
+ ctx.fillStyle=g; ctx.fillRect(0,0,VW,VH);
+}
+
+/* ============================ MASTER RENDER ============================ */
+function render(){
+ drawSky();
+ drawSkylineLayer(FAR, 0.34, mix(P.city,P.skyTop,0.4), 0.7);
+ drawSkylineLayer(MID, 0.6, mix(P.city,P.skyMid,0.22), 0.9);
+ drawCityGlow();
+ drawRoofs();
+ if(S.diagrams) drawDiagrams();
+ // depth order: procession (behind), bears, rose+circle+dogs, couples, freestyle, cats, pigeons, moths
+ drawProcession();
+ drawBears();
+ drawRose();
+ drawDogs();
+ drawCircle();
+ drawCouples();
+ drawFreestyle();
+ drawCats();
+ drawPigeons();
+ drawMoths();
+ drawVignette();
+ if(S.names) drawNames();
+}
+
+/* ============================ SCENE / PALETTE UPDATE ============================ */
+function lerpArrTo(cur,to,k){ for(let i=0;i<3;i++) cur[i]=lerp(cur[i],to[i],k); }
+function updatePalette(dt){
+ const to=SCENES[S.targetScene];
+ const k=clamp(dt*0.6,0,1);
+ lerpArrTo(P.skyTop,to.skyTop,k); lerpArrTo(P.skyMid,to.skyMid,k); lerpArrTo(P.skyBot,to.skyBot,k);
+ lerpArrTo(P.orb,to.orb,k); lerpArrTo(P.roof,to.roof,k); lerpArrTo(P.city,to.city,k);
+ lerpArrTo(P.glow,to.glow,k); lerpArrTo(P.accent,to.accent,k); lerpArrTo(P.lantern,to.lantern,k);
+ P.sunY=lerp(P.sunY,to.sunY,k); P.star=lerp(P.star,to.star,k);
+ S.cfrac=lerp(S.cfrac,to.complexity,k);
+}
+function updateCamera(dt){
+ const pr=CAM_PRESETS[S.cam];
+ const k=clamp(dt*3,0,1);
+ // gentle idle drift so parallax reads
+ const driftX = S.reduced?0: Math.sin(S.t*0.13)*22*(1.2-cam.tilt);
+ cam.scale=lerp(cam.scale,pr.scale,k);
+ cam.cx=lerp(cam.cx,pr.cx+driftX,k);
+ cam.cy=lerp(cam.cy,pr.cy,k);
+ cam.tilt=lerp(cam.tilt,pr.tilt,k);
+}
+
+/* ============================ LOOP ============================ */
+let last=performance.now();
+S.tempo0=1; // smoothed tempo used inside figure phase math
+function frame(now){
+ const rawdt=Math.min(0.05,(now-last)/1000); last=now;
+ if(!S.paused && !S.reduced){
+ S.tempo0=lerp(S.tempo0,S.tempo,0.1);
+ S.t += rawdt*S.tempo;
+ // scene drifting
+ if(S.autoScene){
+ S.sceneT += rawdt;
+ if(S.sceneT>SCENE_DUR){ S.sceneT=0; S.targetScene=(S.targetScene+1)%SCENES.length; syncSceneUI(); }
+ }
+ }
+ updatePalette(S.reduced?0.001:rawdt);
+ updateCamera(S.reduced?1:rawdt);
+ render();
+ if(!S.paused) rafId=requestAnimationFrame(frame);
+ else rafId=null;
+}
+let rafId=null;
+function start(){ if(!rafId){ last=performance.now(); rafId=requestAnimationFrame(frame);} }
+function stop(){ if(rafId){ cancelAnimationFrame(rafId); rafId=null; } }
+
+/* reduced-motion: draw a single composed still and stop the loop */
+function renderStill(){
+ stop();
+ // compose a calm "Long Light" frame
+ updatePalette(1); updateCamera(1);
+ render();
+}
+
+/* ============================ UI WIRING ============================ */
+const $=s=>document.querySelector(s);
+const sceneNameEl=$('#sceneName'), sceneDotEl=$('#sceneDot');
+
+function syncSceneUI(){
+ $('#sceneSel').value=String(S.targetScene);
+ sceneNameEl.textContent=SCENES[S.targetScene].name;
+ sceneDotEl.style.background=rgb(SCENES[S.targetScene].glow);
+}
+
+// camera segmented control
+$('#camSeg').addEventListener('click',e=>{
+ const b=e.target.closest('button[data-cam]'); if(!b) return;
+ S.cam=b.dataset.cam;
+ [...$('#camSeg').children].forEach(x=>x.setAttribute('aria-pressed', String(x===b)));
+ if(S.reduced) renderStill();
+});
+function setCam(name){
+ const b=$(`#camSeg button[data-cam="${name}"]`); if(b) b.click();
+}
+
+$('#sceneSel').addEventListener('change',e=>{
+ S.targetScene=+e.target.value; S.sceneT=0; syncSceneUI();
+ if(S.reduced) renderStill();
+});
+$('#autoScene').addEventListener('change',e=>{ S.autoScene=e.target.checked; });
+
+$('#tempo').addEventListener('input',e=>{ S.tempo=+e.target.value; $('#tempoVal').textContent=S.tempo.toFixed(2).replace(/0$/,'')+'×'; });
+$('#density').addEventListener('input',e=>{
+ S.density=+e.target.value;
+ $('#densVal').textContent = S.density<0.6?'Sparse':S.density<1.05?'Full':'Teeming';
+});
+$('#density').addEventListener('change',()=>{ rebuildDensity(); if(S.reduced) renderStill(); });
+
+$('#isolate').addEventListener('change',e=>{ S.isolate=e.target.value; if(S.reduced) renderStill(); });
+$('#tgNames').addEventListener('change',e=>{ S.names=e.target.checked; if(S.reduced) renderStill(); });
+$('#tgTrails').addEventListener('change',e=>{ S.trails=e.target.checked; });
+$('#tgDiag').addEventListener('change',e=>{ S.diagrams=e.target.checked; if(S.reduced) renderStill(); });
+
+function setPaused(p){
+ S.paused=p;
+ const b=$('#btnPause'); b.textContent=p?'Play':'Pause'; b.setAttribute('aria-pressed',String(p));
+ if(p) stop(); else if(!S.reduced) start();
+}
+$('#btnPause').addEventListener('click',()=>setPaused(!S.paused));
+$('#btnRestart').addEventListener('click',restart);
+function restart(){
+ rnd=mulberry32(0x50C1CE|0);
+ S.t=0; S.sceneT=0; S.targetScene=0; syncSceneUI();
+ rebuildDensity(); makeAnimals();
+ FREE.forEach(f=>f.trail.length=0);
+ if(S.reduced) renderStill(); else { setPaused(false); }
+}
+
+function setReduced(v){
+ S.reduced=v; $('#tgReduce').checked=v;
+ if(v){ renderStill(); }
+ else if(!S.paused){ start(); }
+}
+$('#tgReduce').addEventListener('change',e=>setReduced(e.target.checked));
+
+// panel show/hide
+const dock=$('#controls'), dockToggle=$('#dockToggle');
+function setDock(open){
+ dock.hidden=!open;
+ dockToggle.setAttribute('aria-expanded',String(open));
+ dockToggle.textContent=open?'Hide panel':'Controls';
+}
+dockToggle.addEventListener('click',()=>setDock(dock.hidden));
+
+// legend
+(function buildLegend(){
+ const items=[['Bears','#7a5a4a'],['Cats','#3a3450'],['Pigeons','#d8d8ff'],['Dogs','#6fbf9b'],['Moths','#ffce8a']];
+ $('#legend').innerHTML=items.map(([n,c])=>`<span class="tag"><i style="background:${c}"></i>${n}</span>`).join('');
+})();
+
+/* ---------- keyboard shortcuts ---------- */
+window.addEventListener('keydown',e=>{
+ if(e.target.matches('input,select,textarea')) { if(e.key!=='Escape') return; }
+ switch(e.key.toLowerCase()){
+ case ' ': e.preventDefault(); setPaused(!S.paused); break;
+ case '1': setCam('skyline'); break;
+ case '2': setCam('rooftop'); break;
+ case '3': setCam('birdseye'); break;
+ case 'r': restart(); break;
+ case 'n': $('#tgNames').checked=!S.names; S.names=!S.names; if(S.reduced)renderStill(); break;
+ case 't': $('#tgTrails').checked=!S.trails; S.trails=!S.trails; break;
+ case 'd': $('#tgDiag').checked=!S.diagrams; S.diagrams=!S.diagrams; if(S.reduced)renderStill(); break;
+ case 'm': setReduced(!S.reduced); break;
+ case 'c': setDock(dock.hidden); break;
+ }
+});
+
+/* ============================ BOOT ============================ */
+resize();
+syncSceneUI();
+$('#densVal').textContent='Full';
+// honor OS reduced-motion preference on load
+const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+if(prefersReduce){ setReduced(true); }
+else { start(); }
+
+// keep still frame correct if the tab was hidden then shown
+document.addEventListener('visibilitychange',()=>{ if(!document.hidden && !S.paused && !S.reduced) start(); });
+
+})();
+</script>
+</body>
+</html>
(oldest)
·
back to Rose City Rooftop Solstice
·
Add optional live Grateful Dead music streamed from the Inte 632cd63 →