← back to Model Arena
data/artifacts/127317180daf/claude-code.html
918 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rose City Rooftop Solstice</title>
<style>
:root{
--ink:#f6ecff;
--ink-dim:#c9bce0;
--panel:rgba(18,10,38,.72);
--panel-solid:#160a2e;
--edge:rgba(255,214,170,.28);
--gold:#f7c56b;
--rose:#ff7fae;
--violet:#b48bff;
--focus:#7ff0e6;
--shadow:0 18px 50px rgba(0,0,0,.55);
font-synthesis:none;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{
background:#0a0618;
color:var(--ink);
font-family:"Iowan Old Style","Palatino Linotype",Palatino,Georgia,"Times New Roman",serif;
overflow:hidden;
-webkit-font-smoothing:antialiased;
}
#stage-wrap{position:fixed;inset:0}
canvas{display:block;width:100%;height:100%}
/* Title */
.masthead{
position:fixed;top:0;left:0;right:0;padding:18px 22px 40px;
pointer-events:none;
background:linear-gradient(180deg,rgba(6,3,16,.55),rgba(6,3,16,0));
z-index:3;
}
.masthead h1{
margin:0;font-weight:500;letter-spacing:.11em;
font-size:clamp(19px,3.1vw,34px);
text-transform:uppercase;
text-shadow:0 2px 22px rgba(0,0,0,.6);
}
.masthead h1 .rose{color:var(--rose)}
.masthead p{
margin:.35em 0 0;font-size:clamp(11px,1.5vw,14px);
color:var(--ink-dim);letter-spacing:.16em;text-transform:uppercase;
font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
}
.scene-flag{
display:inline-block;margin-top:.5em;padding:.28em .8em;
border:1px solid var(--edge);border-radius:999px;
font-family:ui-sans-serif,system-ui,sans-serif;font-size:12px;
letter-spacing:.22em;color:var(--gold);background:rgba(20,10,40,.4);
}
/* Controls */
.controls{
position:fixed;top:14px;right:14px;z-index:6;
width:min(320px,90vw);max-height:calc(100vh - 28px);overflow:auto;
background:var(--panel);backdrop-filter:blur(11px);
border:1px solid var(--edge);border-radius:16px;
box-shadow:var(--shadow);
font-family:ui-sans-serif,system-ui,"Helvetica Neue",Arial,sans-serif;
scrollbar-width:thin;
}
.controls header{
display:flex;align-items:center;justify-content:space-between;
padding:12px 14px;position:sticky;top:0;background:var(--panel-solid);
border-bottom:1px solid var(--edge);z-index:2;border-radius:16px 16px 0 0;
}
.controls header h2{margin:0;font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--gold);font-weight:600}
.toggle-panel{
background:none;border:1px solid var(--edge);color:var(--ink);
border-radius:8px;padding:4px 9px;cursor:pointer;font-size:12px;
}
.controls .body{padding:6px 14px 16px}
fieldset{border:none;border-top:1px solid rgba(255,255,255,.08);margin:0;padding:14px 0 4px}
fieldset:first-of-type{border-top:none}
legend{
padding:0;font-size:11px;letter-spacing:.2em;text-transform:uppercase;
color:var(--violet);font-weight:600;margin-bottom:8px;
}
.btnrow{display:flex;flex-wrap:wrap;gap:6px}
.seg{
flex:1 1 auto;min-width:64px;text-align:center;
background:rgba(255,255,255,.05);border:1px solid transparent;
color:var(--ink);border-radius:9px;padding:8px 6px;cursor:pointer;
font-size:12px;letter-spacing:.04em;transition:background .18s,border-color .18s;
}
.seg:hover{background:rgba(255,255,255,.12)}
.seg[aria-pressed="true"],.seg.active{
background:linear-gradient(180deg,rgba(255,127,174,.35),rgba(180,139,255,.28));
border-color:var(--edge);color:#fff;font-weight:600;
}
.slider{margin:10px 0}
.slider label{display:flex;justify-content:space-between;font-size:12px;color:var(--ink-dim);margin-bottom:4px}
.slider output{color:var(--gold);font-variant-numeric:tabular-nums}
input[type="range"]{width:100%;accent-color:var(--rose);height:22px;cursor:pointer}
.checks{display:grid;grid-template-columns:1fr 1fr;gap:6px 10px}
.chk{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--ink);cursor:pointer;user-select:none}
.chk input{accent-color:var(--violet);width:15px;height:15px;cursor:pointer}
.transport{display:flex;gap:6px}
.transport .seg{font-weight:600}
.hint{font-size:11px;color:var(--ink-dim);margin:10px 0 0;line-height:1.5}
kbd{
font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;
background:rgba(255,255,255,.1);border:1px solid var(--edge);
border-radius:4px;padding:1px 5px;color:var(--ink);
}
.note{
position:fixed;left:14px;bottom:12px;z-index:5;max-width:min(430px,72vw);
font-family:ui-sans-serif,system-ui,sans-serif;font-size:11.5px;line-height:1.5;
color:var(--ink-dim);background:rgba(10,5,24,.5);backdrop-filter:blur(6px);
border:1px solid rgba(255,255,255,.08);border-left:2px solid var(--gold);
border-radius:0 10px 10px 0;padding:9px 13px;
}
.note b{color:var(--gold);font-weight:600}
/* Focus states */
a:focus-visible,button:focus-visible,input:focus-visible,.chk:focus-within,[tabindex]:focus-visible{
outline:3px solid var(--focus);outline-offset:2px;border-radius:8px;
}
.skip{position:fixed;left:-999px;top:0;z-index:20;background:var(--panel-solid);color:#fff;padding:10px 16px;border-radius:0 0 10px 0}
.skip:focus{left:0}
.controls.collapsed .body{display:none}
@media (max-width:560px){
.controls{width:calc(100vw - 20px);left:10px;right:10px}
.note{max-width:calc(100vw - 28px)}
}
@media (prefers-reduced-motion:reduce){
*{scroll-behavior:auto}
}
</style>
</head>
<body>
<a class="skip" href="#controls">Skip to controls</a>
<div id="stage-wrap">
<canvas id="scene" role="img"
aria-label="An animated urban-fantasy rooftop garden at solstice sunset: dancers spin around a rose-shaped light sculpture, couples cross narrow bridges, freestyle dancers trail curves near water towers, and a quiet procession climbs fire escapes while bears, cats, pigeons, dogs and moths move through the scene."></canvas>
</div>
<header class="masthead" aria-hidden="false">
<h1>Rose City <span class="rose">Rooftop</span> Solstice</h1>
<p>An authored dance-garden above the glowing city</p><br>
<span class="scene-flag" id="sceneFlag">Arrival</span>
</header>
<form class="controls" id="controls" aria-label="Composition controls">
<header>
<h2>Direct the Solstice</h2>
<button type="button" class="toggle-panel" id="panelToggle" aria-expanded="true" aria-controls="ctlBody">Hide</button>
</header>
<div class="body" id="ctlBody">
<fieldset>
<legend>Camera</legend>
<div class="btnrow" role="group" aria-label="Camera view">
<button type="button" class="seg" data-cam="skyline" aria-pressed="false">Skyline</button>
<button type="button" class="seg" data-cam="rooftop" aria-pressed="true">Rooftop</button>
<button type="button" class="seg" data-cam="birdseye" aria-pressed="false">Bird’s-eye</button>
</div>
</fieldset>
<fieldset>
<legend>Scene</legend>
<div class="btnrow" role="group" aria-label="Scene chapter">
<button type="button" class="seg" data-scene="0" aria-pressed="false">Arrival</button>
<button type="button" class="seg" data-scene="1" aria-pressed="false">Long Light</button>
<button type="button" class="seg" data-scene="2" aria-pressed="false">Midnight Bloom</button>
<button type="button" class="seg active" data-scene="auto" aria-pressed="true">Auto ↻</button>
</div>
</fieldset>
<fieldset>
<legend>Tempo & Crowd</legend>
<div class="slider">
<label for="tempo">Visual tempo <output id="tempoOut">1.0×</output></label>
<input type="range" id="tempo" min="0" max="2" step="0.05" value="1">
</div>
<div class="slider">
<label for="density">Crowd density <output id="densityOut">70%</output></label>
<input type="range" id="density" min="0" max="1" step="0.02" value="0.7">
</div>
</fieldset>
<fieldset>
<legend>Isolate Choreography</legend>
<div class="checks">
<label class="chk"><input type="checkbox" data-grp="circle" checked> Rose circle</label>
<label class="chk"><input type="checkbox" data-grp="couples" checked> Bridge couples</label>
<label class="chk"><input type="checkbox" data-grp="freestyle" checked> Freestyle</label>
<label class="chk"><input type="checkbox" data-grp="procession" checked> Procession</label>
<label class="chk"><input type="checkbox" data-grp="animals" checked> Animals</label>
</div>
</fieldset>
<fieldset>
<legend>Overlays</legend>
<div class="checks">
<label class="chk"><input type="checkbox" id="ovNames"> Animal names</label>
<label class="chk"><input type="checkbox" id="ovTrails" checked> Motion trails</label>
<label class="chk"><input type="checkbox" id="ovRoutes"> Route diagrams</label>
<label class="chk"><input type="checkbox" id="ovReduced"> Reduced motion</label>
</div>
</fieldset>
<fieldset>
<legend>Transport</legend>
<div class="transport" role="group" aria-label="Playback">
<button type="button" class="seg" id="btnPause" aria-pressed="false">⏸ Pause</button>
<button type="button" class="seg" id="btnRestart">↺ Restart</button>
</div>
<p class="hint">
<kbd>Space</kbd> pause · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> cameras ·
<kbd>R</kbd> reduced motion · <kbd>N</kbd> names · <kbd>T</kbd> trails · <kbd>D</kbd> routes.
</p>
</fieldset>
</div>
</form>
<p class="note">
<b>An honest note.</b> This piece keeps its own solstice time. Its visual rhythm is
<b>authored in code</b> — every sway, spiral and bloom is choreographed by hand and does
<b>not</b> listen to any audio. Imagery is wholly original, evoking the generous,
improvisational spirit of open-air gatherings.
</p>
<script>
(() => {
"use strict";
/* ---------- setup ---------- */
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const STAGE_W = 1600, STAGE_H = 900;
let W = 0, H = 0, DPR = 1, viewScale = 1;
function resize(){
DPR = Math.min(window.devicePixelRatio||1, 2);
W = window.innerWidth; H = window.innerHeight;
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);
viewScale = Math.max(W/STAGE_W, H/STAGE_H) * 1.02;
if(state.reduced) render();
}
window.addEventListener('resize', resize);
/* ---------- state ---------- */
const state = {
tempo:1, density:0.7, paused:false, reduced:false,
camera:'rooftop',
sceneMode:'auto', scenePinned:0, scenePhase:0,
show:{circle:true,couples:true,freestyle:true,procession:true,animals:true},
names:false, trails:true, routes:false
};
/* ---------- seeded rng ---------- */
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;};}
const rand = mulberry32(20260621);
/* ---------- palettes / scenes ---------- */
const SCENES = [
{ name:'Arrival', top:'#f7b25a', mid:'#c9527d', low:'#3a2350', glow:'#ffd98a', star:0.0, cx:0.30 },
{ name:'Long Light', top:'#ff8f5e', mid:'#b13a80', low:'#2c1e5a', glow:'#ff9ec4', star:0.15, cx:0.70 },
{ name:'Midnight Bloom', top:'#3a2f74', mid:'#6a2c82', low:'#120a2e', glow:'#c39bff', star:1.0, cx:0.95 }
];
const lerp=(a,b,t)=>a+(b-a)*t;
function hx(c){return [parseInt(c.slice(1,3),16),parseInt(c.slice(3,5),16),parseInt(c.slice(5,7),16)];}
function mix(c1,c2,t){const a=hx(c1),b=hx(c2);return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;}
function scenePal(){
const p = state.scenePhase;
const i = Math.floor(p)%3, f = p-Math.floor(p), j=(i+1)%3;
const A=SCENES[i], B=SCENES[j];
return {
top:mix(A.top,B.top,f), mid:mix(A.mid,B.mid,f), low:mix(A.low,B.low,f),
glow:mix(A.glow,B.glow,f), star:lerp(A.star,B.star,f),
complexity:lerp(A.cx,B.cx,f), idx:i, frac:f, name:SCENES[i].name
};
}
/* ---------- static world geometry ---------- */
// skyline buildings (parallax)
const buildings = [];
(function(){
// far layer
for(let i=0;i<26;i++){
const w=40+rand()*70; const x=rand()*STAGE_W;
buildings.push({x, w, h:120+rand()*260, depth:0.25, y:STAGE_H*0.62, hue:rand()});
}
// mid layer
for(let i=0;i<16;i++){
const w=70+rand()*120; const x=rand()*STAGE_W;
buildings.push({x, w, h:200+rand()*320, depth:0.55, y:STAGE_H*0.72, hue:rand()});
}
})();
buildings.sort((a,b)=>a.depth-b.depth);
const clouds = [];
for(let i=0;i<8;i++) clouds.push({x:rand()*STAGE_W, y:80+rand()*260, s:0.6+rand()*1.4, sp:6+rand()*14, d:0.35+rand()*0.4});
// laundry lines between buildings
const laundry=[];
for(let i=0;i<5;i++){
const x=200+rand()*1100, y=180+rand()*180, len=90+rand()*140;
const items=3+Math.floor(rand()*4), pcs=[];
for(let k=0;k<items;k++) pcs.push({t:(k+1)/(items+1), c:['#ff9ec4','#ffe7a8','#9be7e0','#d9c6ff','#ffd0b0'][Math.floor(rand()*5)], w:14+rand()*10, h:20+rand()*16});
laundry.push({x,y,len,pcs,sag:14+rand()*10});
}
// rooftop platforms
const roofs=[
{x:150,y:610,w:520,h:300}, // left roof
{x:640,y:560,w:640,h:340}, // central roof (dance floor)
{x:1270,y:640,w:300,h:280} // right roof
];
const ROSE={x:960,y:610}; // rose sculpture center (on central roof)
const towers=[{x:520,y:470},{x:1180,y:430}]; // water towers
const fireEscapeX=1470; // right building fire escape
/* zone spread by camera (bird's-eye separates zones) */
function camParams(){
switch(state.camera){
case 'skyline': return {zoom:0.66, squash:1.0, panX:0, panY:-40, spread:0.92, floor:0, figScale:0.86};
case 'birdseye': return {zoom:0.94, squash:0.74, panX:0, panY:120, spread:1.24, floor:1, figScale:1.0};
default: return {zoom:1.0, squash:1.0, panX:0, panY:38, spread:1.0, floor:0, figScale:1.06};
}
}
// map a stage point outward from rose center by spread (for bird's-eye separation)
function spr(x,y,cp){ return {x: ROSE.x + (x-ROSE.x)*cp.spread, y: ROSE.y + (y-ROSE.y)*cp.spread}; }
/* ---------- trails buffers ---------- */
const freeTrails=[]; // per freestyle dancer
const MAXTRAIL=26;
/* ---------- clock ---------- */
let clock=0, sceneClock=0, last=performance.now(), rafId=null;
function loop(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
if(!state.paused && !state.reduced){
clock += dt*state.tempo;
if(state.sceneMode==='auto'){
sceneClock += dt*state.tempo;
state.scenePhase = (sceneClock/22)%3; // ~22s per chapter
}
}
render();
rafId=requestAnimationFrame(loop);
}
function startLoop(){ if(!rafId){ last=performance.now(); rafId=requestAnimationFrame(loop);} }
function stopLoop(){ if(rafId){ cancelAnimationFrame(rafId); rafId=null;} }
/* ---------- drawing helpers ---------- */
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();}
function drawDancer(x,y,s,ph,col,energy){
const e=state.reduced?0.18:energy;
const armA=Math.sin(ph)*1.1*e, armB=Math.sin(ph+2.1)*1.1*e;
const legA=Math.sin(ph+0.6)*0.5*e, legB=Math.sin(ph+3.1)*0.5*e;
const bob=Math.sin(ph*2)*2.4*e*s;
const cx=x, cy=y+bob;
ctx.save();
ctx.strokeStyle=col; ctx.lineWidth=2.2*s; ctx.lineCap='round'; ctx.fillStyle=col;
const hip=cy, sh=cy-13*s, hd=sh-8*s;
// legs
ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx-6*s+legA*7*s, hip+15*s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx+6*s+legB*7*s, hip+15*s); ctx.stroke();
// torso
ctx.beginPath(); ctx.moveTo(cx,hip); ctx.lineTo(cx,sh); ctx.stroke();
// arms
ctx.beginPath(); ctx.moveTo(cx,sh+2*s); ctx.lineTo(cx-9*s, sh-6*s-armA*10*s); ctx.stroke();
ctx.beginPath(); ctx.moveTo(cx,sh+2*s); ctx.lineTo(cx+9*s, sh-6*s-armB*10*s); ctx.stroke();
// head
ctx.beginPath(); ctx.arc(cx,hd,4.2*s,0,7); ctx.fill();
ctx.restore();
}
function label(x,y,text,col){
ctx.save();
ctx.font='600 13px ui-sans-serif,system-ui,sans-serif';
ctx.textAlign='center';
const w=ctx.measureText(text).width+12;
ctx.fillStyle='rgba(8,4,20,.6)';
roundRect(x-w/2,y-16,w,17,8); ctx.fill();
ctx.fillStyle=col||'#fff';
ctx.fillText(text,x,y-3);
ctx.restore();
}
/* ---------- background ---------- */
function drawSky(pal){
const g=ctx.createLinearGradient(0,0,0,STAGE_H);
g.addColorStop(0,pal.top); g.addColorStop(0.42,pal.mid); g.addColorStop(1,pal.low);
ctx.fillStyle=g; ctx.fillRect(-400,-400,STAGE_W+800,STAGE_H+800);
// sun / solstice orb sinking with scene
const sunY = lerp(320,540,pal.idx===2?1:pal.frac*0.5+ (pal.idx*0.5));
const sy = 260 + state.scenePhase*90;
const gr=ctx.createRadialGradient(1080,sy,10,1080,sy,220);
gr.addColorStop(0,pal.glow); gr.addColorStop(0.4,'rgba(255,210,150,'+(0.5-pal.star*0.3)+')'); gr.addColorStop(1,'rgba(255,210,150,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(1080,sy,220,0,7); ctx.fill();
ctx.fillStyle=mix(pal.glow,'#ffffff',0.4); ctx.beginPath(); ctx.arc(1080,sy,34-state.scenePhase*4,0,7); ctx.fill();
// stars
if(pal.star>0.02){
ctx.save(); ctx.fillStyle='#fff';
const sr=mulberry32(7);
for(let i=0;i<140;i++){
const x=sr()*STAGE_W, y=sr()*STAGE_H*0.55;
const tw=state.reduced?0.6:(0.4+0.6*Math.abs(Math.sin(clock*1.5+i)));
ctx.globalAlpha=pal.star*tw*0.9; ctx.fillRect(x,y,1.6,1.6);
}
ctx.restore();
}
}
function drawClouds(pal){
ctx.save();
for(const c of clouds){
const dx = state.reduced ? c.x : (c.x + clock*c.sp*c.d)%(STAGE_W+400)-200;
ctx.globalAlpha=0.16+0.12*(1-pal.star);
ctx.fillStyle=mix(pal.glow,'#ffffff',0.3);
for(let k=0;k<4;k++){
ctx.beginPath();
ctx.ellipse(dx+k*36*c.s, c.y+Math.sin(k)*8*c.s, 46*c.s-k*4, 20*c.s, 0,0,7);
ctx.fill();
}
}
ctx.restore();
}
function drawBuildings(pal,cp){
const drift = state.reduced?0:Math.sin(clock*0.06)*10;
for(const b of buildings){
const x=b.x + drift*b.depth;
const base = mix(pal.low, pal.mid, b.depth*0.5);
ctx.fillStyle=mix('#0c0722', base, 0.45+b.depth*0.4);
ctx.fillRect(x, b.y-b.h, b.w, b.h);
// reflective windows
const cols=Math.max(2,Math.floor(b.w/16)), rows=Math.floor(b.h/22);
for(let r=0;r<rows;r++){
for(let cc=0;cc<cols;cc++){
const wx=x+6+cc*16, wy=b.y-b.h+10+r*22;
if(wx>x+b.w-8) continue;
const idn=(r*7+cc*13+Math.floor(b.x));
const lit=(Math.sin(idn)*0.5+0.5);
let a=0.10+lit*0.35*(0.4+pal.star*0.8);
if(!state.reduced) a*= (0.6+0.4*Math.sin(clock*0.8+idn));
ctx.fillStyle= mix(pal.glow, pal.top, (cc+r)%2? .2:.7);
ctx.globalAlpha=Math.max(0,a);
ctx.fillRect(wx,wy,7,10);
}
}
ctx.globalAlpha=1;
}
}
function drawLaundry(pal){
ctx.save();
ctx.strokeStyle='rgba(255,255,255,.22)'; ctx.lineWidth=1.4;
for(const L of laundry){
ctx.beginPath();
ctx.moveTo(L.x,L.y);
ctx.quadraticCurveTo(L.x+L.len/2, L.y+L.sag, L.x+L.len, L.y);
ctx.stroke();
for(const p of L.pcs){
const px=L.x+L.len*p.t;
const py=L.y+L.sag*(1-Math.pow(2*p.t-1,2))*0.9;
const sway=state.reduced?0:Math.sin(clock*1.4+px*0.05)*3;
ctx.save(); ctx.translate(px,py); ctx.rotate(sway*0.03);
ctx.fillStyle=p.c; ctx.globalAlpha=0.85;
roundRect(-p.w/2,0,p.w,p.h,4); ctx.fill();
ctx.restore();
}
}
ctx.restore();
}
function drawRoofs(pal,cp){
for(const r of roofs){
const top=mix(pal.mid,pal.low,0.45);
ctx.fillStyle=mix('#150c30',top,0.5);
roundRect(r.x,r.y,r.w,r.h,10); ctx.fill();
// rooftop deck highlight
ctx.fillStyle=mix(pal.glow,pal.mid,0.7); ctx.globalAlpha=0.10+cp.floor*0.12;
roundRect(r.x+8,r.y+6,r.w-16,26,8); ctx.fill(); ctx.globalAlpha=1;
// parapet
ctx.strokeStyle='rgba(255,220,180,.18)'; ctx.lineWidth=2;
roundRect(r.x,r.y,r.w,r.h,10); ctx.stroke();
}
// bird's-eye floor grid on central roof
if(cp.floor){
ctx.save(); ctx.strokeStyle='rgba(255,210,170,.10)'; ctx.lineWidth=1;
const r=roofs[1];
for(let gx=r.x;gx<=r.x+r.w;gx+=40){ctx.beginPath();ctx.moveTo(gx,r.y);ctx.lineTo(gx,r.y+r.h);ctx.stroke();}
for(let gy=r.y;gy<=r.y+r.h;gy+=40){ctx.beginPath();ctx.moveTo(r.x,gy);ctx.lineTo(r.x+r.w,gy);ctx.stroke();}
ctx.restore();
}
}
/* lanterns + moths */
const lanterns=[{x:720,y:560},{x:1180,y:560},{x:420,y:620},{x:1350,y:640},{x:960,y:470}];
function drawLanterns(pal){
for(const L of lanterns){
const p=spr(L.x,L.y,camParams());
const pulse=state.reduced?1:(0.75+0.25*Math.sin(clock*2+L.x));
const gr=ctx.createRadialGradient(p.x,p.y,2,p.x,p.y,46);
gr.addColorStop(0,'rgba(255,224,150,'+(0.9*pulse)+')');
gr.addColorStop(1,'rgba(255,200,120,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(p.x,p.y,46,0,7); ctx.fill();
ctx.fillStyle='#ffe6a0'; ctx.beginPath(); ctx.arc(p.x,p.y,5,0,7); ctx.fill();
ctx.strokeStyle='rgba(255,255,255,.3)';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(p.x,p.y-12);ctx.lineTo(p.x,p.y-30);ctx.stroke();
}
}
function drawMoths(pal,cp){
ctx.save();
for(let li=0;li<lanterns.length;li++){
const L=lanterns[li]; const c=spr(L.x,L.y,cp);
const n=3;
for(let m=0;m<n;m++){
const t=clock*1.6 + m*2.1 + li;
const rad=14+8*Math.sin(t*1.3+m);
const mx=c.x+Math.cos(t+m*2)*rad;
const my=c.y-8+Math.sin(t*1.4+m*2)*rad*0.7;
ctx.fillStyle='rgba(255,240,200,.9)';
const flut=state.reduced?3:(2.5+1.6*Math.abs(Math.sin(t*9)));
ctx.beginPath();ctx.ellipse(mx-2,my,flut,2.2,0.5,0,7);ctx.fill();
ctx.beginPath();ctx.ellipse(mx+2,my,flut,2.2,-0.5,0,7);ctx.fill();
if(li===0&&m===0&&state.names) label(mx,my-8,'moths','#ffe6a0');
}
}
ctx.restore();
}
/* ---------- the rose light sculpture ---------- */
function drawRose(pal,cp){
const c=spr(ROSE.x,ROSE.y,cp);
const bloom = pal.idx===2 ? (0.6+0.4*pal.frac) : 0.35+pal.frac*0.35;
const rot = state.reduced?0:clock*0.35;
ctx.save(); ctx.translate(c.x,c.y-6);
// glow base
const gr=ctx.createRadialGradient(0,0,4,0,0,120);
gr.addColorStop(0,'rgba(255,150,190,'+(0.5*bloom)+')');
gr.addColorStop(0.5,'rgba(200,120,255,'+(0.28*bloom)+')');
gr.addColorStop(1,'rgba(120,80,200,0)');
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,0,120,0,7); ctx.fill();
// layered petals (rose curves)
const layers=5;
for(let L=0;L<layers;L++){
const k = 3 + L; // petal count varies per layer
const R = (18+L*13)*(0.85+0.25*bloom);
const pulse = state.reduced?1:(0.92+0.08*Math.sin(clock*2 - L));
ctx.save(); ctx.rotate(rot*(L%2?-1:1)*(0.5+L*0.12));
ctx.beginPath();
for(let a=0;a<=Math.PI*2+0.05;a+=0.05){
const r=R*Math.abs(Math.cos(k*a))*pulse + R*0.28;
const x=Math.cos(a)*r, y=Math.sin(a)*r;
a===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
}
ctx.closePath();
ctx.fillStyle = 'rgba('+[255,120+L*20,180-L*10]+','+(0.14+0.06*bloom)+')';
ctx.strokeStyle='rgba(255,190,225,'+(0.35+0.12*bloom)+')'; ctx.lineWidth=1.4;
ctx.fill(); ctx.stroke();
ctx.restore();
}
// core
ctx.fillStyle='#fff3fb'; ctx.beginPath(); ctx.arc(0,0,6*(0.9+bloom*0.4),0,7); ctx.fill();
ctx.restore();
}
/* ---------- choreography groups ---------- */
function drawCircle(pal,cp){
const c=spr(ROSE.x,ROSE.y,cp);
const count=Math.round((7+pal.complexity*7)* (0.4+state.density*0.9));
const spin=state.reduced?0.6:clock*0.5;
const R=92*cp.spread;
for(let i=0;i<count;i++){
const a=spin + i/count*Math.PI*2;
const rr=R*(1+0.05*Math.sin(clock*1.5+i));
const x=c.x+Math.cos(a)*rr, y=c.y-6+Math.sin(a)*rr*0.55*cp.squash;
const s=(0.9+0.2*Math.sin(a))*cp.figScale;
const col=mix(pal.glow,'#ffd1e6',(i%2)?0.2:0.7);
drawDancer(x,y,s,clock*3+i, col, 0.9);
}
}
function drawCouples(pal,cp){
// couples crossing narrow bridges in mirrored steps
const bridges=[
{a:spr(roofs[0].x+roofs[0].w,660,cp), b:spr(roofs[1].x,650,cp)},
{a:spr(roofs[1].x+roofs[1].w,660,cp), b:spr(roofs[2].x,690,cp)}
];
for(let bi=0;bi<bridges.length;bi++){
const B=bridges[bi];
// bridge plank
ctx.strokeStyle='rgba(255,220,180,.35)'; ctx.lineWidth=6*cp.figScale;
ctx.beginPath(); ctx.moveTo(B.a.x,B.a.y); ctx.lineTo(B.b.x,B.b.y); ctx.stroke();
ctx.strokeStyle='rgba(255,220,180,.15)'; ctx.lineWidth=1;
for(let g=0;g<=6;g++){const t=g/6;ctx.beginPath();ctx.moveTo(lerp(B.a.x,B.b.x,t),lerp(B.a.y,B.b.y,t)-14);ctx.lineTo(lerp(B.a.x,B.b.x,t),lerp(B.a.y,B.b.y,t));ctx.stroke();}
const pairs=Math.max(1,Math.round((1+pal.complexity*1.5)*(0.5+state.density)));
for(let p=0;p<pairs;p++){
const base=(clock*0.12 + p/pairs + bi*0.3);
const t=(Math.sin(base*Math.PI*2)*0.5+0.5); // shuttle back & forth mirrored
const x=lerp(B.a.x,B.b.x,t), y=lerp(B.a.y,B.b.y,t)-8;
const ph=clock*2.4+p;
// mirrored partners
const dx=Math.cos(Math.atan2(B.b.y-B.a.y,B.b.x-B.a.x)+Math.PI/2)*10*cp.figScale;
const dy=Math.sin(Math.atan2(B.b.y-B.a.y,B.b.x-B.a.x)+Math.PI/2)*6*cp.figScale;
drawDancer(x+dx,y+dy,0.95*cp.figScale,ph,'#9be7e0',0.7);
drawDancer(x-dx,y-dy,0.95*cp.figScale,-ph,'#fff0d0',0.7);
}
}
}
function drawFreestyle(pal,cp){
const count=Math.round((3+pal.complexity*5)*(0.4+state.density));
for(let i=0;i<count;i++){
while(freeTrails.length<=i) freeTrails.push([]);
const base=towers[i%towers.length];
const bp=spr(base.x,base.y+70,cp);
const t=clock*(0.8+0.2*(i%3));
const x=bp.x+Math.cos(t*1.3+i)*70*cp.spread + Math.sin(t*0.7+i)*24;
const y=bp.y+Math.sin(t*1.9+i)*34*cp.squash + Math.cos(t*0.5)*10;
// trail
const tr=freeTrails[i];
if(!state.reduced){ tr.push({x,y}); if(tr.length>MAXTRAIL) tr.shift(); }
if(state.trails && tr.length>1){
ctx.save(); ctx.lineCap='round';
for(let k=1;k<tr.length;k++){
ctx.globalAlpha=(k/tr.length)*0.5;
ctx.strokeStyle=mix('#b48bff','#7ff0e6',(i%2)?0.2:0.8);
ctx.lineWidth=3*(k/tr.length)*cp.figScale;
ctx.beginPath();ctx.moveTo(tr[k-1].x,tr[k-1].y);ctx.lineTo(tr[k].x,tr[k].y);ctx.stroke();
}
ctx.restore();
}
drawDancer(x,y,0.95*cp.figScale,t*3+i,mix('#d9c6ff','#9be7e0',(i%2)?0.3:0.7),1);
// water tower
const wt=spr(base.x,base.y,cp);
ctx.save();ctx.fillStyle=mix(pal.low,'#2a1c48',0.4);ctx.strokeStyle='rgba(255,220,180,.25)';ctx.lineWidth=2;
roundRect(wt.x-24,wt.y-10,48,52,6);ctx.fill();ctx.stroke();
ctx.beginPath();ctx.moveTo(wt.x-26,wt.y-10);ctx.lineTo(wt.x,wt.y-34);ctx.lineTo(wt.x+26,wt.y-10);ctx.closePath();
ctx.fillStyle=mix(pal.mid,pal.low,0.4);ctx.fill();ctx.stroke();
ctx.strokeStyle='rgba(255,220,180,.2)';ctx.beginPath();ctx.moveTo(wt.x-18,wt.y+42);ctx.lineTo(wt.x-14,wt.y+64);ctx.moveTo(wt.x+18,wt.y+42);ctx.lineTo(wt.x+14,wt.y+64);ctx.stroke();
ctx.restore();
}
}
function drawProcession(pal,cp){
// quiet procession climbing fire escapes into the celebration
const bx=fireEscapeX;
// fire escape structure
const top=spr(bx,560,cp), bot=spr(bx,860,cp);
ctx.save();
ctx.strokeStyle='rgba(255,220,180,.3)';ctx.lineWidth=2;
for(let f=0;f<5;f++){
const y=lerp(bot.y,top.y,f/5);
ctx.beginPath();ctx.moveTo(bx-34,y);ctx.lineTo(bx+18,y);ctx.stroke(); // platform
ctx.beginPath();ctx.moveTo(bx-34,y);ctx.lineTo(bx-34,y-((bot.y-top.y)/5));ctx.stroke();
// zigzag stair
ctx.beginPath();ctx.moveTo(bx+18,y);ctx.lineTo(bx-34,y-((bot.y-top.y)/5));ctx.stroke();
}
ctx.restore();
const count=Math.round((4+pal.complexity*4)*(0.4+state.density*0.8));
for(let i=0;i<count;i++){
const prog=((clock*0.05 + i/count)%1);
const y=lerp(bot.y,top.y,prog);
const zig=(Math.floor(prog*5)%2)? 12 : -20;
const x=bx+zig+Math.sin(prog*30)*6;
const s=0.8*cp.figScale;
ctx.globalAlpha=0.55+prog*0.45;
drawDancer(x,y,s,clock*1.2+i,mix('#ffd98a',pal.glow,0.5),0.35);
ctx.globalAlpha=1;
}
if(state.routes){ label(top.x, top.y-30,'ascending procession','#ffd98a'); }
}
/* ---------- animals ---------- */
function drawBear(x,y,s,ph){
const sway=state.reduced?0:Math.sin(ph)*0.14;
const squash=1+Math.abs(Math.sin(ph))*0.05;
ctx.save();ctx.translate(x,y);ctx.rotate(sway);ctx.scale(1, squash);
ctx.fillStyle='#5a3b2e';
roundRect(-16*s,-30*s,32*s,32*s,12*s);ctx.fill(); // body
ctx.beginPath();ctx.arc(0,-34*s,11*s,0,7);ctx.fill(); // head
ctx.fillStyle='#4a2f24';
ctx.beginPath();ctx.arc(-8*s,-42*s,4*s,0,7);ctx.arc(8*s,-42*s,4*s,0,7);ctx.fill(); //ears
ctx.fillStyle='#c9a06b';ctx.beginPath();ctx.arc(0,-31*s,4*s,0,7);ctx.fill(); //muzzle
ctx.restore();
}
function drawCat(x,y,s,leap){
ctx.save();ctx.translate(x,y);
ctx.fillStyle='#3a3350';
ctx.beginPath();ctx.ellipse(0,0,12*s,6*s,0,0,7);ctx.fill(); // body
ctx.beginPath();ctx.arc(9*s,-4*s,4*s,0,7);ctx.fill(); // head
ctx.beginPath();ctx.moveTo(11*s,-7*s);ctx.lineTo(13*s,-12*s);ctx.lineTo(8*s,-8*s);ctx.fill(); //ear
ctx.strokeStyle='#3a3350';ctx.lineWidth=2*s;ctx.lineCap='round';
ctx.beginPath();ctx.moveTo(-11*s,0);ctx.quadraticCurveTo(-20*s,-6*s-leap*4,-16*s,-12*s);ctx.stroke();//tail
ctx.restore();
}
function drawDog(x,y,s){
ctx.save();ctx.translate(x,y);
ctx.fillStyle='#b5793f';
ctx.beginPath();ctx.ellipse(0,0,13*s,7*s,0,0,7);ctx.fill();
ctx.beginPath();ctx.arc(11*s,-3*s,5*s,0,7);ctx.fill();
ctx.beginPath();ctx.moveTo(9*s,-6*s);ctx.lineTo(8*s,-13*s);ctx.lineTo(13*s,-7*s);ctx.fill();//ear
ctx.strokeStyle='#b5793f';ctx.lineWidth=2*s;ctx.lineCap='round';
const wag=state.reduced?0:Math.sin(clock*8)*0.4;
ctx.beginPath();ctx.moveTo(-12*s,-1*s);ctx.lineTo(-19*s,-6*s+wag*8);ctx.stroke();
ctx.restore();
}
function drawAnimals(pal,cp){
// bears sway at roof edges
const bears=[spr(roofs[0].x+70,700,cp), spr(roofs[2].x+220,760,cp)];
bears.forEach((b,i)=>{ drawBear(b.x,b.y,0.9*cp.figScale, clock*1.1+i); if(state.names) label(b.x,b.y-58,'bear','#c9a06b'); });
// cats leap between ledges
const ledges=[spr(roofs[1].x+40,570,cp), spr(roofs[1].x+300,555,cp), spr(roofs[2].x+40,660,cp)];
for(let i=0;i<2;i++){
const seg=(clock*0.25+i*0.5)% ledges.length;
const a=ledges[Math.floor(seg)], b=ledges[(Math.floor(seg)+1)%ledges.length];
const t=state.reduced?0.5:(seg-Math.floor(seg));
const x=lerp(a.x,b.x,t), arc=Math.sin(t*Math.PI)*40;
const y=lerp(a.y,b.y,t)-arc;
drawCat(x,y,1.0*cp.figScale, state.reduced?0:Math.sin(t*Math.PI));
if(state.names && i===0) label(x,y-16,'cat','#cfc7e6');
}
// dogs weave between circle dancers
const c=spr(ROSE.x,ROSE.y,cp);
for(let i=0;i<2;i++){
const t=clock*0.9+i*3;
const x=c.x+Math.cos(t*0.6)*150*cp.spread;
const y=c.y+70 + Math.sin(t*3)*16*cp.squash;
drawDog(x,y,1.0*cp.figScale);
if(state.names && i===0) label(x,y-16,'dog','#e6b98a');
}
// pigeons rise in coordinated spiral
const pc=Math.round(10+pal.complexity*8);
const origin=spr(880,700,cp);
for(let i=0;i<pc;i++){
const t=(clock*0.6 + i*0.12);
const rise=(t%3)/3; // 0..1 loop
const ang=i*0.5 + t*2;
const rad=(1-rise)*70 + 8;
const x=origin.x+Math.cos(ang)*rad*cp.spread;
const y=origin.y-rise*260*cp.squash + Math.sin(ang)*rad*0.4;
const flap=state.reduced?0.5:Math.abs(Math.sin(clock*10+i));
ctx.save();ctx.translate(x,y);
ctx.strokeStyle=mix('#d9d2e8',pal.glow,0.3);ctx.lineWidth=2;ctx.lineCap='round';
ctx.beginPath();
ctx.moveTo(-7,0);ctx.quadraticCurveTo(0,-6*flap-2,0,0);ctx.quadraticCurveTo(0,-6*flap-2,7,0);
ctx.stroke();ctx.restore();
}
if(state.names) label(origin.x, origin.y-250*cp.squash-8,'pigeons','#e8e2f2');
}
/* ---------- route diagrams overlay ---------- */
function drawRoutes(pal,cp){
ctx.save();
ctx.setLineDash([7,7]); ctx.lineWidth=2;
ctx.strokeStyle='rgba(127,240,230,.85)';
// circle route
const c=spr(ROSE.x,ROSE.y,cp);
ctx.beginPath();ctx.ellipse(c.x,c.y-6,92*cp.spread,92*cp.spread*0.55*cp.squash,0,0,7);ctx.stroke();
label(c.x,c.y-92*cp.spread*0.55-6,'rose circle','#7ff0e6');
// bridges
ctx.strokeStyle='rgba(155,231,224,.85)';
const b1a=spr(roofs[0].x+roofs[0].w,660,cp),b1b=spr(roofs[1].x,650,cp);
const b2a=spr(roofs[1].x+roofs[1].w,660,cp),b2b=spr(roofs[2].x,690,cp);
ctx.beginPath();ctx.moveTo(b1a.x,b1a.y);ctx.lineTo(b1b.x,b1b.y);ctx.moveTo(b2a.x,b2a.y);ctx.lineTo(b2b.x,b2b.y);ctx.stroke();
label((b1a.x+b1b.x)/2,(b1a.y+b1b.y)/2-12,'mirrored crossing','#9be7e0');
// freestyle loops
ctx.strokeStyle='rgba(180,139,255,.85)';
towers.forEach(tw=>{const p=spr(tw.x,tw.y+70,cp);ctx.beginPath();ctx.ellipse(p.x,p.y,70*cp.spread,40*cp.squash,0.3,0,7);ctx.stroke();});
label(spr(towers[1].x,towers[1].y+70,cp).x, spr(towers[1].x,towers[1].y+70,cp).y-44,'freestyle curves','#b48bff');
ctx.restore();
}
/* ---------- main render ---------- */
function render(){
const pal=scenePal();
const cp=camParams();
document.getElementById('sceneFlag').textContent = pal.name;
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,W,H);
// camera transform
ctx.save();
ctx.translate(W/2, H/2);
const idle=state.reduced?0:Math.sin(clock*0.08)*8;
ctx.scale(viewScale*cp.zoom, viewScale*cp.zoom*cp.squash);
ctx.translate(-STAGE_W/2 + cp.panX + idle, -STAGE_H/2 + cp.panY);
drawSky(pal);
drawClouds(pal);
drawBuildings(pal,cp);
drawLaundry(pal);
drawRoofs(pal,cp);
drawLanterns(pal);
drawRose(pal,cp);
if(state.routes) drawRoutes(pal,cp);
// choreography (respect isolation toggles)
if(state.show.procession) drawProcession(pal,cp);
if(state.show.couples) drawCouples(pal,cp);
if(state.show.circle) drawCircle(pal,cp);
if(state.show.freestyle) drawFreestyle(pal,cp);
if(state.show.animals){ drawAnimals(pal,cp); drawMoths(pal,cp); }
ctx.restore();
}
/* ---------- controls wiring ---------- */
function press(sel,val,attr='data-cam'){
document.querySelectorAll('['+attr+']').forEach(b=>{
const on=b.getAttribute(attr)===val;
b.setAttribute('aria-pressed', on?'true':'false');
b.classList.toggle('active',on);
});
}
document.querySelectorAll('[data-cam]').forEach(b=>{
b.addEventListener('click',()=>{ state.camera=b.dataset.cam; press('','','data-cam'); document.querySelectorAll('[data-cam]').forEach(x=>{const on=x.dataset.cam===state.camera;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);}); if(state.reduced)render(); });
});
function setCam(c){state.camera=c;document.querySelectorAll('[data-cam]').forEach(x=>{const on=x.dataset.cam===c;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);});if(state.reduced)render();}
document.querySelectorAll('[data-scene]').forEach(b=>{
b.addEventListener('click',()=>{
const v=b.dataset.scene;
document.querySelectorAll('[data-scene]').forEach(x=>{const on=x===b;x.setAttribute('aria-pressed',on);x.classList.toggle('active',on);});
if(v==='auto'){ state.sceneMode='auto'; }
else { state.sceneMode='pinned'; state.scenePhase=parseInt(v,10); }
if(state.reduced)render();
});
});
const tempo=document.getElementById('tempo'), tempoOut=document.getElementById('tempoOut');
tempo.addEventListener('input',()=>{state.tempo=parseFloat(tempo.value);tempoOut.textContent=state.tempo.toFixed(1)+'×';});
const density=document.getElementById('density'), densityOut=document.getElementById('densityOut');
density.addEventListener('input',()=>{state.density=parseFloat(density.value);densityOut.textContent=Math.round(state.density*100)+'%';if(state.reduced)render();});
document.querySelectorAll('[data-grp]').forEach(c=>{
c.addEventListener('change',()=>{state.show[c.dataset.grp]=c.checked;if(state.reduced)render();});
});
const ovNames=document.getElementById('ovNames'),ovTrails=document.getElementById('ovTrails'),
ovRoutes=document.getElementById('ovRoutes'),ovReduced=document.getElementById('ovReduced');
ovNames.addEventListener('change',()=>{state.names=ovNames.checked;if(state.reduced)render();});
ovTrails.addEventListener('change',()=>{state.trails=ovTrails.checked;if(state.reduced)render();});
ovRoutes.addEventListener('change',()=>{state.routes=ovRoutes.checked;if(state.reduced)render();});
ovReduced.addEventListener('change',()=>setReduced(ovReduced.checked));
const btnPause=document.getElementById('btnPause');
btnPause.addEventListener('click',togglePause);
function togglePause(){
state.paused=!state.paused;
btnPause.setAttribute('aria-pressed',state.paused);
btnPause.textContent=state.paused?'▶ Play':'⏸ Pause';
}
document.getElementById('btnRestart').addEventListener('click',()=>{
clock=0; sceneClock=0; state.scenePhase= state.sceneMode==='pinned'?state.scenePhase:0;
freeTrails.length=0;
if(state.reduced)render();
});
function setReduced(on){
state.reduced=on; ovReduced.checked=on;
if(on){ stopLoop(); render(); }
else { last=performance.now(); startLoop(); }
}
// panel collapse
const panelToggle=document.getElementById('panelToggle'), ctlBody=document.getElementById('ctlBody'), panel=document.getElementById('controls');
panelToggle.addEventListener('click',()=>{
const collapsed=panel.classList.toggle('collapsed');
panelToggle.textContent=collapsed?'Show':'Hide';
panelToggle.setAttribute('aria-expanded',(!collapsed).toString());
});
// keyboard
window.addEventListener('keydown',e=>{
if(e.target.matches('input,textarea')) return;
switch(e.key){
case ' ': e.preventDefault(); togglePause(); break;
case '1': setCam('skyline'); break;
case '2': setCam('rooftop'); break;
case '3': setCam('birdseye'); break;
case 'r': case 'R': setReduced(!state.reduced); break;
case 'n': case 'N': ovNames.checked=!ovNames.checked; state.names=ovNames.checked; if(state.reduced)render(); break;
case 't': case 'T': ovTrails.checked=!ovTrails.checked; state.trails=ovTrails.checked; if(state.reduced)render(); break;
case 'd': case 'D': ovRoutes.checked=!ovRoutes.checked; state.routes=ovRoutes.checked; if(state.reduced)render(); break;
}
});
/* ---------- boot ---------- */
resize();
// honor OS reduced-motion preference on first load
if(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches){
setReduced(true);
} else {
startLoop();
}
})();
</script>
</body>
</html>