my 9 year old asked why we have summer and winter. can you draw it so she gets it?
Here you go — one self-contained page that draws the whole story: Earth leaning over as it travels around the Sun, and a live close-up showing what that lean does to the sunlight where a kid stands. Drag the planet, or press play.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Why do we have summer and winter? — a drawn answer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400..700;1,9..144,400..700&family=Schoolbell&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
:root{
--bg:#0f1118; --ink:#f4eddc; --muted:rgba(244,237,221,.55);
--warm:#ffaf5a; --cool:#8fbfe8; --paper:#f2ecdc; --paper2:#f6e7c6;
}
body{margin:0;background:var(--bg);color:var(--ink);font-family:"Fraunces",Georgia,serif;-webkit-font-smoothing:antialiased}
.wrap{width:min(1230px,calc(100% - 36px));margin-inline:auto}
::selection{background:var(--warm);color:#181c26}
:focus-visible{outline:2px dashed var(--warm);outline-offset:3px}
header{padding:60px 0 0}
.overline{font-family:"Schoolbell",cursive;font-size:16px;color:var(--warm);letter-spacing:.05em;display:flex;align-items:center;gap:10px}
.overline svg{flex:none}
h1{font-size:clamp(33px,5.2vw,58px);font-weight:560;line-height:1.1;margin:14px 0 4px;letter-spacing:-.01em}
h1 .sum{color:var(--warm);font-style:italic}
h1 .win{color:var(--cool);font-style:italic}
.lede{max-width:66ch;font-size:clamp(15.5px,1.6vw,18px);line-height:1.66;color:rgba(244,237,221,.82);margin:16px 0 0}
.lede strong{color:var(--warm);font-weight:600}
.stage{margin-top:32px}
.canvas-wrap{position:relative;padding:11px}
canvas{display:block;width:100%;touch-action:pan-y;border-radius:6px}
svg.frame{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.now-line{margin-top:14px;text-align:center;font-family:"Schoolbell",cursive;min-height:66px}
.now-main{font-size:clamp(16px,2vw,21px)}
.now-stat{font-size:14px;color:var(--muted);margin-top:4px}
.now-line.pop .now-main{animation:pop .45s ease}
@keyframes pop{0%{opacity:0;transform:translateY(4px)}100%{opacity:1}}
.controls{display:flex;align-items:center;gap:22px;flex-wrap:wrap;margin-top:12px}
.btn-play{width:50px;height:50px;border-radius:47% 53% 50% 50%/52% 48% 55% 45%;border:1.5px solid rgba(244,237,221,.75);background:transparent;color:var(--ink);display:grid;place-items:center;cursor:pointer;transition:background .18s,color .18s,transform .12s}
.btn-play:hover{background:var(--ink);color:#161a24}
.btn-play:active{transform:scale(.94)}
.btn-play svg{width:19px;height:19px;fill:currentColor}
.timeline{flex:1;min-width:280px;height:62px;position:relative;cursor:pointer;touch-action:none;outline-offset:6px}
.tl-track{position:absolute;left:0;right:0;top:22px;height:2px;background:rgba(244,237,221,.3);border-radius:2px}
.tl-tick{position:absolute;top:17px;width:1.5px;height:12px;background:rgba(244,237,221,.4)}
.tl-month{position:absolute;top:37px;transform:translateX(-50%);font-family:"Schoolbell",cursive;font-size:13.5px;color:var(--muted)}
.tl-mark{position:absolute;top:19px;width:9px;height:9px;transform:translateX(-50%) rotate(45deg)}
.tl-mark.eq{border-radius:50%;transform:translateX(-50%)}
.tl-thumb{position:absolute;top:11px;width:26px;height:26px;transform:translateX(-50%);pointer-events:none}
.hemi{display:flex;align-items:center;gap:12px}
.seg-label{font-family:"Schoolbell",cursive;font-size:13.5px;color:var(--muted)}
.seg{display:flex;border:1.5px solid rgba(244,237,221,.7);border-radius:26px 22px 26px 24px/24px 28px 22px 30px}
.seg button{font-family:"Schoolbell",cursive;font-size:15.5px;padding:9px 16px;background:transparent;border:0;color:var(--muted);cursor:pointer;transition:.18s}
.seg button[aria-pressed="true"]{background:var(--ink);color:#161a24}
.hint{font-family:"Schoolbell",cursive;font-size:14px;color:var(--muted);text-align:center;margin-top:10px}
.story{margin:84px 0 0;display:grid;grid-template-columns:1.45fr .95fr;gap:56px;align-items:start}
.step{display:grid;grid-template-columns:62px 1fr;gap:22px;margin-bottom:42px}
.step .num{width:56px;height:56px;border:2px solid rgba(244,237,221,.8);border-radius:51% 49% 48% 52%/49% 53% 47% 51%;display:grid;place-items:center;font-size:26px;font-weight:600;font-style:italic;color:var(--warm)}
.step h3{margin:4px 0 10px;font-size:clamp(18px,1.8vw,21.5px);font-weight:600}
.step p{margin:0;font-size:16.5px;line-height:1.7;color:rgba(244,237,221,.85)}
.step strong{color:var(--warm);font-weight:600}
.step .cold{color:var(--cool)}
.note{position:relative;background:var(--paper);color:#232730;font-family:"Schoolbell",cursive;font-size:17px;line-height:1.55;padding:26px 28px 30px;border-radius:14px 255px 18px 225px/255px 14px 225px 18px;box-shadow:0 18px 40px rgba(0,0,0,.45);transform:rotate(-1.6deg)}
.note.alt{background:var(--paper2);transform:rotate(1.3deg);margin-top:32px}
.note::before{content:"";position:absolute;top:-13px;left:50%;width:96px;height:26px;transform:translateX(-50%) rotate(-2.5deg);background:rgba(255,255,255,.35);border:1px solid rgba(255,255,255,.5);border-radius:2px}
.note h4{margin:0 0 8px;font-size:20px}
.note strong{color:#c2591f}
footer{margin:88px 0 64px;padding-top:26px;border-top:1.5px dashed rgba(244,237,221,.22);font-family:"Schoolbell",cursive;font-size:15px;color:var(--muted);line-height:1.75;max-width:76ch}
@media(max-width:880px){.story{grid-template-columns:1fr;gap:48px}}
@media(max-width:560px){
.controls{gap:14px}
.step{grid-template-columns:50px 1fr;gap:16px}
.step .num{width:46px;height:46px;font-size:22px}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="overline">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 0 L8.4 5.6 L14 7 L8.4 8.4 L7 14 L5.6 8.4 L0 7 L5.6 5.6 Z" fill="currentColor"/></svg>
a picture answer — for a certain nine-year-old, and her grown-up
</div>
<h1>Why do we have <span class="sum">summer</span> and <span class="win">winter</span>?</h1>
<svg width="300" height="12" viewBox="0 0 300 12" aria-hidden="true" style="display:block;margin:8px 0 0">
<path d="M2 7 C 40 2, 80 10, 120 6 S 200 3, 240 8 S 285 5, 298 6" stroke="var(--warm)" stroke-width="3" fill="none" stroke-linecap="round"/>
</svg>
<p class="lede">The short answer: the Earth is tipped over like a spinning top that <strong>never straightens up</strong>. Once a year your half of the planet leans toward the Sun — that's summer. Half a year later it leans away — winter. Here's the whole story, drawn. <strong>Drag the little planet</strong> around its ring and watch what the sunshine does.</p>
</header>
<section class="stage" aria-label="interactive drawing of the seasons">
<div class="canvas-wrap">
<canvas id="sky" aria-label="A drawing you can drag: Earth travelling around the Sun while staying tilted, plus a close-up panel showing how steeply sunlight lands and how long the days are."></canvas>
<svg class="frame" id="frame" aria-hidden="true"></svg>
</div>
<div class="now-line" id="nowline" aria-live="polite">
<div class="now-main" id="nowMain"></div>
<div class="now-stat" id="nowStat"></div>
</div>
<div class="controls">
<button class="btn-play" id="playBtn" aria-label="play or pause the year">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path id="icoPlay" d="M7 4 L20 12 L7 20 Z"/>
<g id="icoPause" style="display:none">
<rect x="6" y="4.5" width="4.4" height="15" rx="1.2"/>
<rect x="13.6" y="4.5" width="4.4" height="15" rx="1.2"/>
</g>
</svg>
</button>
<div class="timeline" id="tl" role="slider" tabindex="0" aria-label="time of year" aria-valuemin="0" aria-valuemax="365"></div>
<div class="hemi">
<span class="seg-label">watch from:</span>
<div class="seg">
<button id="hemiN" aria-pressed="true">the northern half</button>
<button id="hemiS" aria-pressed="false">the southern half</button>
</div>
</div>
</div>
<p class="hint">drag the Earth around the ring · space = play / pause · arrow keys nudge the days</p>
</section>
<section class="story">
<div class="steps">
<div class="step">
<div class="num">1</div>
<div>
<h3>The Earth leans — and it never straightens up.</h3>
<p>Earth spins like a top, but a top that stays tipped over: it leans by <strong>23½ degrees</strong>, aimed at the North Star, <strong>all year long</strong>. It doesn't nod back and forth. Watch the little arrows in the drawing — at every point of the trip they point the same way.</p>
</div>
</div>
<div class="step">
<div class="num">2</div>
<div>
<h3>Leaning in → sunshine lands steeply → summer.</h3>
<p>When your half leans <strong>toward</strong> the Sun, its light arrives almost straight-on. The very same sunshine gets packed onto a <strong>smaller patch of ground</strong>, so every patch gets more heat. Longer days pile on top of that: your half of the spinning Earth spends more of each day in the light.</p>
</div>
</div>
<div class="step">
<div class="num">3</div>
<div>
<h3>Leaning away → sunshine smears out → winter.</h3>
<p>Half a year later the Earth is around the other side — the lean hasn't moved, but now your half tilts <span class="cold">away</span>. The same sunshine lands at a <strong>shallow slant</strong> and spreads across a much bigger patch, so every patch gets less. Short days finish the job: <span class="cold">winter</span>.</p>
</div>
</div>
</div>
<aside>
<div class="note">
<h4>Try it tonight — winter on your desk</h4>
Turn off the lights and hold a flashlight straight down over a piece of paper: a small, super-bright circle. Now tilt the flashlight so the beam lands at a slant. Same flashlight, same beam — but the spot stretches into a long, pale oval. <strong>Same light, more paper, less heat for each bit.</strong> You just made winter. Straighten the flashlight back up — summer.
</div>
<div class="note alt">
<h4>The surprise: it's not about being closer to the Sun</h4>
Earth is actually <strong>farthest</strong> from the Sun in early July — right in the middle of the north's summer! If distance caused the seasons, the whole planet would warm and cool together. Instead, when Canada is baking, Australia is digging out scarves — that's the lean, not the distance.
</div>
</aside>
</section>
<footer>
P.S. — every angle in the drawing is real (the lean really is 23½°), but I cheated on the sizes: at this Earth's size, the true Sun would be about three of these screens wide, and roughly a hundred metres away. Some things a drawing simply has to shrink.<br><br>
Drag the Earth around again and watch the right-hand panel: steeper light, shorter shadow, longer day. That's the whole story of the seasons — for a kid who asks good questions.
</footer>
</div>
<script>
(() => {
'use strict';
const TAU = Math.PI*2, RAD = Math.PI/180, DEG = 180/Math.PI;
const EPS = 23.44*RAD; // Earth's real axial tilt
const YEAR_MS = 14000; // one lap of the orbit per 14 s
const ER = 30, R_SUN = 26; // drawn radii: Earth, Sun
const RM = matchMedia('(prefers-reduced-motion: reduce)').matches;
const C = {
ink:'#f4eddc', inkSoft:'rgba(244,237,221,.55)',
warm:'#ffaf5a', warmSoft:'rgba(255,175,90,.78)', cool:'#8fbfe8',
ray:'rgba(255,183,86,.45)',
};
// ---------------- state ----------------
const state = { day:172, playing:!RM, hemi:'north' }; // start at the June solstice
// ---------------- little utilities ----------------
function makeRand(s){ let x=s%2147483647; if(x<=0)x+=2147483646; return ()=>{ x=x*16807%2147483647; return x/2147483647; }; }
const MONTHS=[['January',0],['February',31],['March',59],['April',90],['May',120],['June',151],
['July',181],['August',212],['September',243],['October',273],['November',304],['December',334]];
function monthName(d){ let m='January'; for(const [n,s] of MONTHS){ if(d>=s) m=n; } return m; }
function thetaOf(d){ return (90+(d-80)*360/365.25)*RAD; } // orbit angle for a day of year
function astro(){
const th=thetaOf(state.day);
const u=-Math.cos(th); // −1 (Dec) … +1 (June): is the north leaning sunward?
const delta=u*23.44; // solar declination in degrees
const w=state.hemi==='north'?u:-u; // warmth of the watched half
const phi=(state.hemi==='north'?45:-45)*RAD; // the kid's latitude
const h=(90-Math.abs((phi-delta*RAD)*DEG))*RAD; // sun altitude at noon
const H=Math.acos(Math.min(1,Math.max(-1,-Math.tan(phi)*Math.tan(delta*RAD))));
const dayH=2*H*DEG/15; // hours of daylight
return {th,u,delta,w,h,dayH};
}
function fmtDur(hrs){
const h=Math.floor(hrs), m=Math.round((hrs-h)*60);
return m===60? `${h+1} h 00 min` : `${h} h ${String(m).padStart(2,'0')} min`;
}
// ---------------- canvas & layout ----------------
const cvs=document.getElementById('sky'), ctx=cvs.getContext('2d');
const wrap=cvs.parentElement;
let LW=1200, LH=620, scale=1, dpr=1, tall=false, A=null, B=null, starsA=[];
function sceneAParams(){
const cx=A.x+A.w/2, cy=A.y+342, rx=A.w/2-82, ry=rx*0.44;
return {cx,cy,rx,ry};
}
function orbitPoint(P,a){ return {x:P.cx+P.rx*Math.cos(a), y:P.cy+P.ry*Math.sin(a)}; }
function layout(){
const w=wrap.clientWidth-22;
tall = w<900;
if(tall){ LW=760; LH=1240; A={x:8,y:4,w:744,h:600}; B={x:10,y:660,w:740,h:570}; }
else { LW=1200; LH=620; A={x:10,y:6,w:724,h:606}; B={x:748,y:16,w:446,h:580}; }
scale=w/LW; dpr=Math.min(window.devicePixelRatio||1,2);
cvs.width=Math.round(LW*scale*dpr); cvs.height=Math.round(LH*scale*dpr);
cvs.style.height=(LH*scale)+'px';
const r=makeRand(11); starsA=[];
for(let i=0;i<26;i++) starsA.push({x:A.x+10+r()*(A.w-20), y:A.y+30+r()*(A.h-40),
r:0.7+r()*0.9, a:0.22+r()*0.4, spark:r()<0.12});
buildFrame();
}
function buildFrame(){
const svg=document.getElementById('frame');
const W=wrap.clientWidth, H=wrap.clientHeight;
svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
const r=makeRand(5); const j=a=>(r()-0.5)*a;
const x=2,y=2,w2=W-4,h2=H-4;
let d=`M ${x+j(4)} ${y+j(4)}`;
d+=` Q ${x+w2/2+j(8)} ${y+j(6)} ${x+w2+j(4)} ${y+j(4)}`;
d+=` Q ${x+w2+j(8)} ${y+h2/2+j(6)} ${x+w2+j(4)} ${y+h2+j(4)}`;
d+=` Q ${x+w2/2+j(8)} ${y+h2+j(6)} ${x+j(4)} ${y+h2+j(4)}`;
d+=` Q ${x+j(8)} ${y+h2/2+j(6)} ${x+j(4)} ${y+j(4)} Z`;
svg.innerHTML=`<path d="${d}" fill="none" stroke="rgba(244,237,221,.4)" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>`;
}
// ---------------- drawing helpers ----------------
function txt(g,s,x,y,o={}){
const size=o.size||14, color=o.color||C.ink, align=o.align||'left';
const fam=o.serif?'Fraunces, Georgia, serif':'Schoolbell, cursive';
g.save();
g.font=`${o.italic?'italic ':''}${o.weight||'400'} ${size}px ${fam}`;
if(o.alpha!=null) g.globalAlpha=o.alpha;
g.translate(x,y); if(o.rot) g.rotate(o.rot*RAD);
if(o.bg){ const w=g.measureText(s).width;
g.fillStyle=o.bg;
g.fillRect(align==='center'?-w/2-6:align==='right'?-w-7:-3,-size*0.85,w+10,size*1.18); }
g.fillStyle=color; g.textAlign=align; g.textBaseline='alphabetic';
g.fillText(s,0,0);
g.restore();
}
function arrowHead(g,x,y,ang,s,color){
g.save(); g.translate(x,y); g.rotate(ang); g.fillStyle=color;
g.beginPath(); g.moveTo(0,0); g.lineTo(-s,-s*0.5); g.lineTo(-s,s*0.5); g.closePath(); g.fill();
g.restore();
}
function star4(g,x,y,r){
g.beginPath();
g.moveTo(x,y-r); g.quadraticCurveTo(x,y,x+r,y); g.quadraticCurveTo(x,y,x,y+r);
g.quadraticCurveTo(x,y,x-r,y); g.quadraticCurveTo(x,y,x,y-r); g.closePath();
}
function rrect(g,x,y,w,h,r){
g.beginPath();
if(g.roundRect){ g.roundRect(x,y,w,h,r); return; }
g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r);
g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath();
}
function handRectPanel(g,x,y,w,h){
const r=makeRand(31); const j=a=>(r()-0.5)*a;
g.beginPath();
g.moveTo(x+j(4),y+j(4));
g.quadraticCurveTo(x+w/2+j(6),y+j(4),x+w+j(4),y+j(4));
g.quadraticCurveTo(x+w+j(4),y+h/2+j(6),x+w+j(4),y+h+j(4));
g.quadraticCurveTo(x+w/2+j(6),y+h+j(4),x+j(4),y+h+j(4));
g.quadraticCurveTo(x+j(4),y+h/2+j(6),x+j(4),y+j(4));
g.closePath();
}
// hand-authored "continents" in unit coordinates
const LAND=[
{pts:[[-0.62,-0.12],[-0.32,-0.22],[-0.12,-0.02],[-0.2,0.32],[-0.5,0.28]]},
{pts:[[0.18,-0.68],[0.5,-0.6],[0.62,-0.38],[0.34,-0.28],[0.12,-0.38]]},
{pts:[[0.16,0.18],[0.4,0.12],[0.55,0.34],[0.36,0.55],[0.14,0.42]]},
{pts:[[-0.28,0.38],[-0.05,0.35],[0.02,0.55],[-0.18,0.66]]},
{pts:[[-0.3,-0.65],[-0.02,-0.6],[0.05,-0.42],[-0.22,-0.4]]},
];
function drawLand(g,r){
for(const b of LAND){
g.beginPath();
b.pts.forEach((p,i)=>{ const X=p[0]*r, Y=p[1]*r; i?g.lineTo(X,Y):g.moveTo(X,Y); });
g.closePath();
g.fillStyle='#7ca868'; g.fill();
g.strokeStyle='rgba(60,100,55,.5)'; g.lineWidth=1; g.stroke();
}
}
function drawSun(g,x,y,T){
const R=R_SUN;
const gr=g.createRadialGradient(x,y,R*0.5,x,y,R*2.1);
gr.addColorStop(0,'rgba(255,170,70,.3)'); gr.addColorStop(1,'rgba(255,170,70,0)');
g.fillStyle=gr; g.beginPath(); g.arc(x,y,R*2.1,0,TAU); g.fill();
g.save(); g.translate(x,y); g.rotate(RM?0:T*0.03);
g.strokeStyle='rgba(255,176,84,.5)'; g.lineWidth=2; g.lineCap='round';
for(let i=0;i<14;i++){
const a=i/14*TAU+(i%3)*0.05, l=R+8+((i*7)%5)*2.2;
g.beginPath(); g.moveTo(Math.cos(a)*(R+4),Math.sin(a)*(R+4)); g.lineTo(Math.cos(a)*l,Math.sin(a)*l); g.stroke();
}
g.restore();
g.beginPath(); g.arc(x,y,R,0,TAU); g.fillStyle='#ffc35c'; g.fill();
g.beginPath(); g.arc(x,y,R-7,0,TAU); g.fillStyle='#ffe2a8'; g.fill();
g.beginPath(); g.arc(x,y,R,0,TAU); g.strokeStyle='rgba(255,140,50,.7)'; g.lineWidth=1.5; g.stroke();
}
// faint "ghost" Earths at the four quarters — all axes parallel, that's the point
function drawGhost(g,P,x,y,r){
const ax={x:Math.sin(EPS),y:-Math.cos(EPS)};
let sdx=P.cx-x, sdy=P.cy-y; const l=Math.hypot(sdx,sdy)||1; sdx/=l; sdy/=l;
const sdAng=Math.atan2(sdy,sdx);
g.save(); g.globalAlpha=.85;
g.beginPath(); g.arc(x,y,r,0,TAU); g.fillStyle='rgba(47,95,146,.65)'; g.fill();
g.strokeStyle='rgba(244,237,221,.35)'; g.lineWidth=1; g.stroke();
g.save(); g.beginPath(); g.arc(x,y,r,0,TAU); g.clip();
g.translate(x,y); g.rotate(sdAng);
g.fillStyle='rgba(9,12,20,.45)'; g.fillRect(-r-1,-r-1,r+1,2*r+2);
g.restore();
const L=r+14;
g.strokeStyle='rgba(244,237,221,.55)'; g.lineWidth=1.2;
g.beginPath(); g.moveTo(x-ax.x*L,y-ax.y*L); g.lineTo(x+ax.x*L,y+ax.y*L); g.stroke();
arrowHead(g,x+ax.x*L,y+ax.y*L,Math.atan2(ax.y,ax.x),5,'rgba(255,175,90,.8)');
g.restore();
}
let spin=0; // Earth's daily spin (visual only)
function drawEarthFull(g,x,y,r,as,sun){
const ax={x:Math.sin(EPS),y:-Math.cos(EPS)}; // axis direction on screen, fixed forever
let sdx=sun.cx-x, sdy=sun.cy-y; const sl=Math.hypot(sdx,sdy)||1; sdx/=sl; sdy/=sl;
const axAng=Math.atan2(ax.y,ax.x), sdAng=Math.atan2(sdy,sdx);
g.beginPath(); g.arc(x,y,r,0,TAU); g.fillStyle='#2f5f92'; g.fill();
g.save(); g.beginPath(); g.arc(x,y,r-0.6,0,TAU); g.clip();
g.save(); g.translate(x,y); g.rotate(spin); drawLand(g,r); g.restore(); // spinning continents
// warm / cool wash: which half leans sunward
g.save(); g.translate(x,y); g.rotate(axAng);
const warmN=as.u>0;
g.fillStyle=warmN?'rgba(255,160,70,.12)':'rgba(120,170,230,.11)'; g.fillRect(0,-r,r,2*r);
g.fillStyle=warmN?'rgba(120,170,230,.11)':'rgba(255,160,70,.12)'; g.fillRect(-r,-r,r,2*r);
g.strokeStyle='rgba(244,237,221,.5)'; g.lineWidth=1; g.setLineDash([4,4]);
g.beginPath(); g.moveTo(0,-r); g.lineTo(0,r); g.stroke(); g.setLineDash([]);
g.restore();
// night side + sunlit rim
g.save(); g.translate(x,y); g.rotate(sdAng);
g.fillStyle='rgba(9,12,20,.55)'; g.fillRect(-r-1,-r-1,r+1,2*r+2);
g.fillStyle='rgba(9,12,20,.18)'; g.fillRect(0,-r-1,r*0.28,2*r+2);
g.strokeStyle='rgba(255,214,140,.4)'; g.lineWidth=3; g.lineCap='round';
g.beginPath(); g.arc(0,0,r-1.4,-0.9,0.9); g.stroke();
g.restore();
g.restore();
g.beginPath(); g.arc(x,y,r+1,0,TAU); g.strokeStyle='rgba(150,190,255,.28)'; g.lineWidth=1.2; g.stroke();
// the axis, always the same screen direction
const a1={x:x-ax.x*(r+26),y:y-ax.y*(r+26)}, a2={x:x+ax.x*(r+26),y:y+ax.y*(r+26)};
g.strokeStyle='rgba(244,237,221,.85)'; g.lineWidth=1.6;
g.beginPath(); g.moveTo(a1.x,a1.y); g.lineTo(a2.x,a2.y); g.stroke();
arrowHead(g,a2.x,a2.y,Math.atan2(ax.y,ax.x),6,'rgba(244,237,221,.9)');
// the real 23½° annotation against straight-up-and-down
g.save();
g.strokeStyle='rgba(244,237,221,.28)'; g.lineWidth=1; g.setLineDash([3,5]);
g.beginPath(); g.moveTo(x,y-r-30); g.lineTo(x,y+r+30); g.stroke(); g.setLineDash([]);
g.strokeStyle='rgba(255,175,90,.65)'; g.lineWidth=1.4;
g.beginPath(); g.arc(x,y,r+13,-90*RAD,(-90+23.44)*RAD); g.stroke();
g.restore();
const aa=(-90+11.7)*RAD;
txt(g,'23½°',x+Math.cos(aa)*(r+27),y+Math.sin(aa)*(r+27)+4,{size:12,color:'rgba(255,175,90,.9)'});
// equator label
const eq={x:-ax.y,y:ax.x};
txt(g,'equator',x+eq.x*(r+9)-6,y+eq.y*(r+9)+10,{size:11,color:C.inkSoft,italic:true});
// "you" dot at 45° on the watched half
const sgn=state.hemi==='north'?1:-1;
const yp={x:x+ax.x*r*0.707*sgn,y:y+ax.y*r*0.707*sgn};
g.beginPath(); g.arc(yp.x,yp.y,3,0,TAU); g.fillStyle=C.warm; g.fill();
g.beginPath(); g.arc(yp.x,yp.y,5.5,0,TAU); g.strokeStyle='rgba(244,237,221,.85)'; g.lineWidth=1.2; g.stroke();
txt(g,'you',yp.x+8,yp.y-6,{size:12.5,color:'rgba(244,237,221,.95)'});
// polar midnight-sun / polar-night note
const lit=as.u*sgn>0.72, dark=as.u*sgn<-0.72;
if(lit||dark){
const pt={x:x+ax.x*(r+30)*sgn,y:y+ax.y*(r+30)*sgn};
const side=x<sun.cx?1:-1;
const msg=lit?'the Sun never even sets here!':"the Sun doesn't rise here at all";
const col=lit?C.warmSoft:'rgba(143,191,232,.85)';
if(side>0) txt(g,msg,pt.x+10,pt.y-8,{size:13,color:col,rot:1.5});
else txt(g,msg,pt.x-10,pt.y-8,{size:13,color:col,rot:-1.5,align:'right'});
}
}
// ---------------- scene A: the trip around the Sun ----------------
function drawSceneA(g,T,as){
const P=sceneAParams();
const {cx,cy,rx,ry}=P;
const th=as.th, E=orbitPoint(P,th);
const ax={x:Math.sin(EPS),y:-Math.cos(EPS)};
for(const s of starsA){
g.globalAlpha=s.a*0.8; g.fillStyle=C.ink;
g.beginPath(); g.arc(s.x,s.y,s.r,0,TAU); g.fill();
if(s.spark){ g.globalAlpha=s.a; star4(g,s.x,s.y,s.r*3.2); g.fill(); }
}
g.globalAlpha=1;
// dotted orbit
g.save(); g.strokeStyle='rgba(244,237,221,.34)'; g.lineWidth=1.4; g.setLineDash([1.6,7]); g.lineCap='round';
g.beginPath(); g.ellipse(cx,cy,rx,ry,0,0,TAU); g.stroke(); g.restore();
drawSun(g,cx,cy,T);
txt(g,'the Sun',cx,cy+R_SUN+22,{size:13.5,color:C.inkSoft,align:'center'});
// the four quarters, with parallel axis stubs
const quarters=[
{a:180*RAD,t:'JUNE',lines:[['north: summer',C.warmSoft],['south: winter','rgba(143,191,232,.65)']],lx:cx-rx,ly:cy+80},
{a:0,t:'DECEMBER',lines:[['north: winter','rgba(143,191,232,.65)'],['south: summer',C.warmSoft]],lx:cx+rx,ly:cy+80},
{a:90*RAD,t:'MARCH',lines:[['day and night are equal',C.inkSoft]],lx:cx+120,ly:cy+ry+34},
{a:270*RAD,t:'SEPTEMBER',lines:[['day and night are equal',C.inkSoft]],lx:cx-120,ly:cy-ry-56},
];
for(const q of quarters){
const gp=orbitPoint(P,q.a);
drawGhost(g,P,gp.x,gp.y,9);
txt(g,q.t,q.lx,q.ly,{size:20,align:'center',color:'rgba(244,237,221,.92)'});
q.lines.forEach((ln,i)=>txt(g,ln[0],q.lx,q.ly+19+i*16,{size:12.5,align:'center',color:ln[1]}));
}
// direction-of-travel arrow, just ahead of Earth
const a2=th+32*RAD, p1=orbitPoint(P,th+10*RAD), p2=orbitPoint(P,a2);
g.save(); g.strokeStyle='rgba(244,237,221,.5)'; g.lineWidth=1.6; g.lineCap='round';
g.beginPath(); g.moveTo(p1.x,p1.y); g.quadraticCurveTo((p1.x+p2.x)/2,(p1.y+p2.y)/2-6,p2.x,p2.y); g.stroke();
const tg={x:-rx*Math.sin(a2),y:ry*Math.cos(a2)}, tn=Math.hypot(tg.x,tg.y)||1;
arrowHead(g,p2.x,p2.y,Math.atan2(tg.y/tn,tg.x/tn),7,'rgba(244,237,221,.5)');
g.restore();
// sunlight travelling to Earth
const dist=Math.hypot(E.x-cx,E.y-cy)||1;
const dir={x:(E.x-cx)/dist,y:(E.y-cy)/dist};
g.save(); g.strokeStyle=C.ray; g.lineWidth=2; g.lineCap='round'; g.setLineDash([10,13]);
const dashOff=RM?0:-(T*46)%23;
for(let k=-2;k<=2;k++){
const phi=k*0.085;
const d={x:dir.x*Math.cos(phi)-dir.y*Math.sin(phi),y:dir.x*Math.sin(phi)+dir.y*Math.cos(phi)};
const fx=E.x-cx, fy=E.y-cy;
const b=d.x*fx+d.y*fy, c=fx*fx+fy*fy-ER*ER, disc=b*b-c;
let tEnd;
if(disc>0){ tEnd=b-Math.sqrt(disc); if(tEnd<0) tEnd=b; } else tEnd=Math.hypot(fx,fy)+20;
g.lineDashOffset=dashOff;
g.beginPath(); g.moveTo(cx+d.x*(R_SUN+10),cy+d.y*(R_SUN+10)); g.lineTo(cx+d.x*tEnd,cy+d.y*tEnd); g.stroke();
}
g.setLineDash([]); g.restore();
if(dist>190){
txt(g,'sunlight',cx+dir.x*dist*0.55+(-dir.y)*14,cy+dir.y*dist*0.55+dir.x*14+5,
{size:15,color:'rgba(255,190,100,.85)',align:'center',italic:true});
}
drawEarthFull(g,E.x,E.y,ER,as,{cx,cy});
// the North Star, sitting exactly along the axis direction
const st={x:cx+ax.x*252,y:cy+ax.y*252};
g.fillStyle='#ffd98f'; star4(g,st.x,st.y,10); g.fill();
g.fillStyle='#fff3d8'; g.beginPath(); g.arc(st.x,st.y,1.6,0,TAU); g.fill();
txt(g,'the North Star',st.x+16,st.y-8,{size:16,color:C.ink});
txt(g,'the lean always points here — it never changes',st.x-8,st.y+18,{size:13,color:C.inkSoft,align:'center'});
txt(g,'one trip around the Sun = one year',A.x+26,A.y+46,{size:20,color:'rgba(244,237,221,.9)',rot:-1.2});
txt(g,'angles are the real ones — sizes, kindly shrunk',A.x+26,A.y+68,{size:13,color:C.inkSoft,rot:-1.2});
}
// ---------------- scene B: what the sunshine does at the kid's feet ----------------
const flakes=Array.from({length:64},()=>({x:Math.random(),y:Math.random(),
v:0.05+Math.random()*0.08,r:1+Math.random()*1.5,fr:1+Math.random()*2,ph:Math.random()*6}));
function kidLine(w){
if(w>0.55) return 'Packed-in sunshine.\nHot!';
if(w>0.15) return 'Nice and steep\nout there today.';
if(w>-0.15) return 'In-between light.\nJust right.';
if(w>-0.55) return 'Brrr — the light\nis going shallow…';
return 'The same light, all\nsmeared out. Cold!';
}
function drawMiniSun(g,x,y){
const r=13;
const gr=g.createRadialGradient(x,y,4,x,y,34);
gr.addColorStop(0,'rgba(255,170,70,.4)'); gr.addColorStop(1,'rgba(255,170,70,0)');
g.fillStyle=gr; g.beginPath(); g.arc(x,y,34,0,TAU); g.fill();
g.strokeStyle='rgba(255,176,84,.65)'; g.lineWidth=1.8; g.lineCap='round';
for(let i=0;i<10;i++){
const a=i/10*TAU+0.2;
g.beginPath(); g.moveTo(x+Math.cos(a)*(r+2),y+Math.sin(a)*(r+2)); g.lineTo(x+Math.cos(a)*(r+6),y+Math.sin(a)*(r+6)); g.stroke();
}
g.beginPath(); g.arc(x,y,r,0,TAU); g.fillStyle='#ffc35c'; g.fill();
g.beginPath(); g.arc(x,y,r-4,0,TAU); g.fillStyle='#ffe2a8'; g.fill();
}
function drawKid(g,x,feet,w,T){
const phase=w>0.35?'summer':w<-0.35?'winter':'mid';
const bob=RM?0:Math.sin(T*1.8)*1.2;
g.save(); g.translate(x,feet+bob);
const skin='#f0c6a0', outline='rgba(16,20,30,.6)';
const body=phase==='summer'?'#e8735a':phase==='winter'?'#6c8fbf':'#a89b7e';
g.lineCap='round';
g.strokeStyle=phase==='winter'?'#3f4654':'#c99b6f'; g.lineWidth=4.5;
g.beginPath(); g.moveTo(-3,-19); g.lineTo(-4.5,-1); g.stroke();
g.beginPath(); g.moveTo(3,-19); g.lineTo(4.5,-1); g.stroke();
g.fillStyle=body; rrect(g,-8.5,-42,17,25,6); g.fill();
g.strokeStyle=outline; g.lineWidth=1; rrect(g,-8.5,-42,17,25,6); g.stroke();
g.strokeStyle=body; g.lineWidth=3.5;
g.beginPath(); g.moveTo(-8,-37); g.lineTo(-11,-23); g.stroke();
g.beginPath(); g.moveTo(8,-37); g.lineTo(11,-23); g.stroke();
g.fillStyle=skin;
g.beginPath(); g.arc(-11,-22,2,0,TAU); g.fill();
g.beginPath(); g.arc(11,-22,2,0,TAU); g.fill();
if(phase==='winter'){
g.fillStyle='#e0b54c'; rrect(g,-8,-45,16,5.5,2.5); g.fill();
rrect(g,1,-42,5,11,2); g.fill();
} else {
g.fillStyle='rgba(16,20,30,.35)'; rrect(g,-7,-44.5,14,3,1.5); g.fill();
}
g.beginPath(); g.arc(0,-51,8.5,0,TAU); g.fillStyle=skin; g.fill();
g.strokeStyle=outline; g.lineWidth=1; g.stroke();
if(phase==='winter'){
g.fillStyle='#c65b4e'; g.beginPath(); g.arc(0,-51,8.7,Math.PI,0); g.closePath(); g.fill();
g.fillStyle='#a94b3f'; rrect(g,-9.5,-52.5,19,3,1.5); g.fill();
g.fillStyle='#e8dcc4'; g.beginPath(); g.arc(0,-58,3.2,0,TAU); g.fill();
} else {
g.fillStyle=phase==='summer'?'#d9a441':'#8a7d5e';
g.beginPath(); g.arc(0,-51,8.7,Math.PI,0); g.closePath(); g.fill();
g.fillStyle=phase==='summer'?'#c08a2e':'#756a50';
rrect(g,-14,-52.5,7,2.6,1.3); g.fill();
}
const blink=(T%3.7)<0.14;
g.fillStyle='#20242e';
if(blink){ g.fillRect(-4.8,-52,3,1.2); g.fillRect(0.2,-52,3,1.2); }
else{ g.beginPath(); g.arc(-3.3,-51.6,1.15,0,TAU); g.fill();
g.beginPath(); g.arc(1.7,-51.6,1.15,0,TAU); g.fill(); }
g.strokeStyle='#20242e'; g.lineWidth=1.1;
g.beginPath(); g.arc(-1,-48.5,2.8,0.2*Math.PI,0.8*Math.PI); g.stroke();
if(phase==='winter'){
g.fillStyle='rgba(224,120,90,.5)';
g.beginPath(); g.arc(-5.5,-49,1.3,0,TAU); g.fill();
g.beginPath(); g.arc(3.8,-49,1.3,0,TAU); g.fill();
}
g.restore();
}
function drawBubble(g,kx,headTopY,w){
const lines=kidLine(w).split('\n');
const bw=182, bh=22+lines.length*17;
const x0=kx-bw/2, y0=headTopY-bh-14, cy0=y0+bh/2;
g.save();
g.translate(kx,cy0); g.rotate(-1.2*RAD); g.translate(-kx,-cy0);
g.fillStyle='rgba(244,237,221,.96)';
rrect(g,x0,y0,bw,bh,10); g.fill();
g.beginPath(); g.moveTo(kx-10,y0+bh-2); g.lineTo(kx-2,y0+bh+9); g.lineTo(kx+4,y0+bh-2); g.closePath(); g.fill();
g.fillStyle='#20242e'; g.font='14.5px Schoolbell, cursive'; g.textAlign='center';
lines.forEach((ln,i)=>g.fillText(ln,kx,y0+24+i*17));
g.restore();
}
function drawSceneB(g,T,as,dt){
const w=as.w, alt=as.h, dayH=as.dayH;
const px=B.x+6, py=B.y+6, pw=B.w-12, ph=B.h-12;
handRectPanel(g,px,py,pw,ph);
g.strokeStyle='rgba(244,237,221,.3)'; g.lineWidth=1.5; g.stroke();
g.save(); g.beginPath(); g.rect(px,py,pw,ph); g.clip();
const yg=B.y+340;
const hitX=B.x+B.w*0.5;
const dx=Math.cos(alt), dyv=Math.sin(alt);
const pxv=-Math.sin(alt), pyv=Math.cos(alt);
const d=190;
const src={x:hitX-d*dx,y:yg-d*dyv};
txt(g,'…and what the lean does to your sunshine',B.x+26,B.y+44,{size:19.5,color:'rgba(244,237,221,.95)'});
txt(g,state.hemi==='north'
?'a kid at 45° north — like Chicago, Rome, or Sendai'
:'a kid at 45° south — like Patagonia or southern New Zealand',
B.x+26,B.y+66,{size:13,color:C.inkSoft});
// ground
const gx=px+16, gw=pw-32;
const snowy=w<-0.4;
g.fillStyle=snowy?'#232b38':'#1b2029'; g.fillRect(gx,yg,gw,24);
g.strokeStyle='rgba(244,237,221,.12)'; g.lineWidth=1;
for(let x=gx+8;x<gx+gw;x+=16){ g.beginPath(); g.moveTo(x,yg+4); g.lineTo(x-9,yg+22); g.stroke(); }
g.strokeStyle=snowy?'rgba(240,244,252,.8)':'rgba(244,237,221,.75)'; g.lineWidth=2; g.lineCap='round';
g.beginPath(); g.moveTo(gx,yg); g.lineTo(gx+gw,yg); g.stroke();
if(snowy){
const r=makeRand(44); g.fillStyle='rgba(240,244,252,.7)';
for(let i=0;i<26;i++){ g.beginPath(); g.arc(gx+r()*gw,yg+2+r()*18,1.2+r()*1.3,0,TAU); g.fill(); }
}
if(!snowy){
const green=w>0.05;
[0.07,0.18,0.3,0.62,0.74,0.9].forEach(f=>{
const x=gx+gw*f;
g.strokeStyle=green?'rgba(124,168,104,.85)':'rgba(160,150,120,.4)'; g.lineWidth=1.4;
for(let k=-1;k<=1;k++){
g.beginPath(); g.moveTo(x+k*2,yg); g.quadraticCurveTo(x+k*3,yg-4,x+k*4.5,yg-7); g.stroke();
}
});
if(w>0.55){
[0.12,0.26,0.83].forEach(f=>{
const x=gx+gw*f;
g.strokeStyle='rgba(124,168,104,.9)'; g.lineWidth=1.3;
g.beginPath(); g.moveTo(x,yg); g.quadraticCurveTo(x+1,yg-5,x,yg-9); g.stroke();
g.fillStyle=C.warm; g.beginPath(); g.arc(x,yg-11,3,0,TAU); g.fill();
g.fillStyle='#ffd98f'; g.beginPath(); g.arc(x,yg-11,1.2,0,TAU); g.fill();
});
}
}
// the beam (same width, same light — only the angle changes)
g.save(); g.strokeStyle='rgba(255,183,86,.5)'; g.lineWidth=1.8; g.lineCap='round';
g.setLineDash([8,12]); g.lineDashOffset=RM?0:-(T*52)%20;
for(let k=-2;k<=2;k++){
const off=k*19, sx=src.x+pxv*off, sy=src.y+pyv*off;
const tt=(yg-sy)/dyv;
g.beginPath(); g.moveTo(sx,sy); g.lineTo(sx+dx*tt,yg); g.stroke();
}
g.setLineDash([]); g.restore();
drawMiniSun(g,src.x,src.y);
// footprint glow + how much ground it covers
const groundX=off=>{ const sy=src.y+pyv*off; return src.x+pxv*off+dx*(yg-sy)/dyv; };
const xmin=Math.min(groundX(-38),groundX(38)), xmax=Math.max(groundX(-38),groundX(38));
const inten=Math.sin(alt)/Math.sin(68.5*RAD);
g.save(); g.lineCap='round';
g.strokeStyle=`rgba(255,178,86,${(0.22+0.6*inten).toFixed(3)})`; g.lineWidth=10;
g.beginPath(); g.moveTo(xmin+4,yg); g.lineTo(xmax-4,yg); g.stroke();
g.strokeStyle=`rgba(255,214,140,${(0.4+0.5*inten).toFixed(3)})`; g.lineWidth=4;
g.beginPath(); g.moveTo(xmin+4,yg); g.lineTo(xmax-4,yg); g.stroke();
g.restore();
g.strokeStyle='rgba(244,237,221,.6)'; g.lineWidth=1.2;
[xmin,xmax].forEach(x=>{ g.beginPath(); g.moveTo(x,yg-5); g.lineTo(x,yg+5); g.stroke(); });
const by=yg+36;
g.strokeStyle='rgba(244,237,221,.45)'; g.lineWidth=1;
g.beginPath(); g.moveTo(xmin,by-5); g.lineTo(xmin,by); g.lineTo(xmax,by); g.lineTo(xmax,by-5); g.stroke();
const ratio=(1/Math.sin(alt))/(1/Math.sin(68.5*RAD));
txt(g,ratio<1.25?'packed in tight — every bit of ground gets lots of heat'
:`smeared over ${ratio.toFixed(1)} × more ground — each bit gets less`,
(xmin+xmax)/2,by+16,{size:13.5,align:'center',color:'rgba(244,237,221,.8)'});
txt(g,'same sunshine in every beam — only the angle changes',(xmin+xmax)/2,by+33,
{size:12.5,align:'center',color:C.inkSoft,italic:true});
// sun-angle arc
g.strokeStyle='rgba(255,175,90,.7)'; g.lineWidth=1.5;
g.beginPath(); g.arc(hitX,yg,32,0,-alt,true); g.stroke();
txt(g,`${Math.round(alt*DEG)}° above the horizon`,hitX-14,yg-14,
{size:13.5,color:'rgba(255,190,110,.95)',align:'right',bg:'rgba(15,17,24,.75)'});
// the kid, and a shadow that tells the truth
const kidX=hitX+2;
const shLen=Math.min(160,50/Math.tan(alt));
g.fillStyle='rgba(4,7,12,.5)';
g.beginPath(); g.ellipse(kidX+shLen/2+3,yg+6,shLen/2+3,4.5,0,0,TAU); g.fill();
txt(g,alt>45*RAD?'short shadow — the sun is high':'long shadow — the sun is low',
kidX+shLen/2+3,yg+19,{size:12,align:'center',color:'rgba(244,237,221,.6)',italic:true,bg:'rgba(15,17,24,.6)'});
drawKid(g,kidX,yg,w,T);
drawBubble(g,kidX,yg-58,w);
if(w<-0.45&&!RM){
const bp=T%3.2;
for(let k=0;k<3;k++){
const p=(bp-k*0.3)/1.4;
if(p>0&&p<1){
g.fillStyle=`rgba(200,220,240,${(0.35*(1-p)).toFixed(3)})`;
g.beginPath(); g.arc(kidX-12-p*16,yg-48-p*6,2+p*4.5,0,TAU); g.fill();
}
}
}
// the day-length bar
const bx=px+18, bw2=pw-36, barY=yg+106, bh=13;
txt(g,'how much of the day is sunlit',bx,barY-10,{size:12.5,color:C.inkSoft});
txt(g,`${fmtDur(dayH)} of daylight`,px+pw-18,barY-10,{size:14,color:C.warmSoft,align:'right'});
rrect(g,bx,barY,bw2,bh,6); g.fillStyle='rgba(244,237,221,.1)'; g.fill();
const f0=(12-dayH/2)/24, f1=(12+dayH/2)/24;
rrect(g,bx+bw2*f0+1,barY+1.5,Math.max(6,bw2*(f1-f0)-2),bh-3,4);
g.fillStyle='rgba(255,175,90,.85)'; g.fill();
g.fillStyle='#ffd98f'; g.beginPath(); g.arc(bx+bw2*0.5,barY+bh/2,2.2,0,TAU); g.fill();
g.strokeStyle='rgba(244,237,221,.35)'; g.lineWidth=1;
[0,0.25,0.5,0.75,1].forEach(f=>{ const x=bx+bw2*f; g.beginPath(); g.moveTo(x,barY-4); g.lineTo(x,barY+bh+4); g.stroke(); });
txt(g,'midnight',bx,barY+bh+17,{size:11,color:C.inkSoft});
txt(g,'6 am',bx+bw2*0.25,barY+bh+17,{size:11,color:C.inkSoft,align:'center'});
txt(g,'noon',bx+bw2*0.5,barY+bh+17,{size:11,color:C.inkSoft,align:'center'});
txt(g,'6 pm',bx+bw2*0.75,barY+bh+17,{size:11,color:C.inkSoft,align:'center'});
txt(g,'midnight',bx+bw2,barY+bh+17,{size:11,color:C.inkSoft,align:'right'});
// snowfall when the watched half is in deep winter
const snowA=Math.max(0,Math.min(1,(-w-0.4)/0.3));
if(snowA>0.02){
g.fillStyle=`rgba(235,240,252,${(snowA*0.8).toFixed(3)})`;
for(const f of flakes){
if(!RM){
f.y+=dt*f.v; f.x+=Math.sin(T*f.fr+f.ph)*0.0015;
if(f.y>0.99){ f.y=-0.02; f.x=Math.random(); }
}
g.beginPath(); g.arc(px+f.x*pw,py+f.y*(yg-py),f.r,0,TAU); g.fill();
}
}
g.restore();
}
function drawTallConnector(g){
const cx=A.x+A.w/2, y0=A.y+A.h+8;
g.strokeStyle='rgba(244,237,221,.55)'; g.lineWidth=1.6; g.lineCap='round';
g.beginPath(); g.moveTo(cx,y0+4); g.lineTo(cx,y0+30); g.stroke();
arrowHead(g,cx,y0+34,Math.PI/2,7,'rgba(244,237,221,.55)');
txt(g,'what does that mean down at your spot?',cx+16,y0+24,{size:14.5,color:C.inkSoft});
}
// ---------------- timeline + controls ----------------
const tl=document.getElementById('tl');
const nowline=document.getElementById('nowline'), nowMain=document.getElementById('nowMain'), nowStat=document.getElementById('nowStat');
const playBtn=document.getElementById('playBtn');
const icoPlay=document.getElementById('icoPlay'), icoPause=document.getElementById('icoPause');
const hemiN=document.getElementById('hemiN'), hemiS=document.getElementById('hemiS');
let tlThumb=null;
function buildTimeline(){
const letters='JFMAMJJASOND';
let html='<div class="tl-track"></div>';
MONTHS.forEach((m,i)=>{
const f=m[1]/365*100;
html+=`<div class="tl-tick" style="left:${f}%"></div><div class="tl-month" style="left:${f+1.35}%">${letters[i]}</div>`;
});
html+=`<div class="tl-mark" style="left:${(172/365*100).toFixed(2)}%;background:var(--warm)"></div>`;
html+=`<div class="tl-mark" style="left:${(355/365*100).toFixed(2)}%;background:var(--cool)"></div>`;
html+=`<div class="tl-mark eq" style="left:${(80/365*100).toFixed(2)}%;background:rgba(244,237,221,.6);height:8px"></div>`;
html+=`<div class="tl-mark eq" style="left:${(266/365*100).toFixed(2)}%;background:rgba(244,237,221,.6);height:8px"></div>`;
let rays='';
for(let i=0;i<8;i++){
const a=i/8*TAU;
rays+=`<line x1="${13+Math.cos(a)*9.5}" y1="${13+Math.sin(a)*9.5}" x2="${13+Math.cos(a)*13.2}" y2="${13+Math.sin(a)*13.2}" stroke="var(--warm)" stroke-width="1.6" stroke-linecap="round"/>`;
}
html+=`<svg class="tl-thumb" id="tlThumb" viewBox="0 0 26 26">${rays}<circle cx="13" cy="13" r="7" fill="var(--warm)"/></svg>`;
tl.innerHTML=html;
tlThumb=document.getElementById('tlThumb');
}
let scrubbing=false, wasPlaying=false;
function dayFromEvent(e){
const r=tl.getBoundingClientRect();
const f=Math.min(1,Math.max(0,(e.clientX-r.left)/r.width));
state.day=Math.round(f*365*4)/4%365;
}
tl.addEventListener('pointerdown',e=>{ scrubbing=true; wasPlaying=state.playing; state.playing=false;
tl.setPointerCapture(e.pointerId); dayFromEvent(e); syncPlayIcon(); });
tl.addEventListener('pointermove',e=>{ if(scrubbing) dayFromEvent(e); });
tl.addEventListener('pointerup',()=>{ scrubbing=false; state.playing=wasPlaying; syncPlayIcon(); });
tl.addEventListener('keydown',e=>{
if(e.key==='ArrowRight'){ state.day=(state.day+5)%365; e.preventDefault(); }
if(e.key==='ArrowLeft'){ state.day=(state.day-5+365)%365; e.preventDefault(); }
});
function syncPlayIcon(){
icoPlay.style.display=state.playing?'none':'';
icoPause.style.display=state.playing?'':'none';
}
playBtn.addEventListener('click',()=>{ state.playing=!state.playing; syncPlayIcon(); });
window.addEventListener('keydown',e=>{
if(e.code==='Space' && document.activeElement && document.activeElement.tagName!=='BUTTON'){
e.preventDefault(); state.playing=!state.playing; syncPlayIcon();
}
});
function setHemi(h){
state.hemi=h;
hemiN.setAttribute('aria-pressed',String(h==='north'));
hemiS.setAttribute('aria-pressed',String(h==='south'));
lastKey='';
}
hemiN.addEventListener('click',()=>setHemi('north'));
hemiS.addEventListener('click',()=>setHemi('south'));
// dragging the Earth itself on the canvas
let dragging=false, dragWasPlaying=false;
function toLocal(e){
const r=cvs.getBoundingClientRect();
return {x:(e.clientX-r.left)/scale,y:(e.clientY-r.top)/scale};
}
function setDayFromLocal(p){
const P=sceneAParams();
if(Math.abs(p.x-P.cx)<2 && Math.abs(p.y-P.cy)<2) return;
const th=Math.atan2((p.y-P.cy)/P.ry,(p.x-P.cx)/P.rx)*DEG;
let d=((th-90)*365.25/360+80)%365;
if(d<0)d+=365;
state.day=Math.round(d*4)/4;
}
cvs.addEventListener('pointerdown',e=>{
const p=toLocal(e), P=sceneAParams();
const th=thetaOf(state.day), E=orbitPoint(P,th);
const dE=Math.hypot(p.x-E.x,p.y-E.y);
const pth=Math.atan2((p.y-P.cy)/P.ry,(p.x-P.cx)/P.rx);
const op=orbitPoint(P,pth);
const dO=Math.hypot(p.x-op.x,p.y-op.y);
if(dE<46||dO<18){
dragging=true; dragWasPlaying=state.playing; state.playing=false;
cvs.setPointerCapture(e.pointerId); setDayFromLocal(p); syncPlayIcon();
}
});
cvs.addEventListener('pointermove',e=>{
if(dragging){ setDayFromLocal(toLocal(e)); return; }
const p=toLocal(e), P=sceneAParams();
const E=orbitPoint(P,thetaOf(state.day));
cvs.style.cursor=Math.hypot(p.x-E.x,p.y-E.y)<46?'grab':'default';
});
const endDrag=()=>{ if(dragging){ dragging=false; state.playing=dragWasPlaying; syncPlayIcon(); } };
cvs.addEventListener('pointerup',endDrag);
cvs.addEventListener('pointercancel',endDrag);
// ---------------- the live sentence under the drawing ----------------
let lastKey='';
function updateDOM(){
const as=astro(), w=as.w;
const m=monthName(state.day);
const half=state.hemi==='north'?'northern':'southern';
const rising=Math.sin(as.th)>0;
const season=w>0.55?'summer':w<-0.55?'winter':
(state.hemi==='north'?(rising?'spring':'autumn'):(rising?'autumn':'spring'));
let main;
if(Math.abs(w)<0.09) main=`It's ${m} — the lean is neither in nor out, so day ≈ night everywhere.`;
else main=`It's ${m}. The ${half} half is leaning ${w>0?'toward':'away from'} the Sun — ${season} there!`;
const stat=`the sun climbs ${Math.round(as.h*DEG)}° above the horizon · ${fmtDur(as.dayH)} of daylight where the kid stands`;
const key=m+'|'+half+'|'+season+'|'+(w>0.09?'in':w<-0.09?'out':'mid');
if(key!==lastKey){
lastKey=key;
nowMain.textContent=main; nowStat.textContent=stat;
nowline.classList.remove('pop'); void nowline.offsetWidth; nowline.classList.add('pop');
}
if(tlThumb) tlThumb.style.left=(state.day/365*100)+'%';
tl.setAttribute('aria-valuenow',String(Math.round(state.day)));
tl.setAttribute('aria-valuetext',m);
}
// ---------------- main loop ----------------
function draw(T,dt){
ctx.setTransform(dpr*scale,0,0,dpr*scale,0,0);
ctx.fillStyle='#0f1118'; ctx.fillRect(0,0,LW,LH);
const as=astro();
drawSceneA(ctx,T,as);
drawSceneB(ctx,T,as,dt);
if(tall) drawTallConnector(ctx);
}
layout();
buildTimeline();
syncPlayIcon();
new ResizeObserver(()=>layout()).observe(wrap);
let last=performance.now()/1000, T=0;
function loop(now){
const nowS=now/1000;
const dt=Math.min(0.05,nowS-last); last=nowS;
T+=dt;
if(state.playing&&!dragging&&!scrubbing) state.day=(state.day+dt*365/(YEAR_MS/1000))%365;
spin+=RM?0:dt*TAU/6.5;
draw(T,dt);
updateDOM();
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();
</script>
</body>
</html>
What this gives her, and how to use it together:
- The one gesture that carries the whole lesson: grab the little Earth and drag it around the ring. The left side draws the real geometry — Earth tilted a true 23½°, axis always pointing at the North Star (all the little ghost arrows stay parallel, which is the entire secret). The right panel is the payoff: as she drags, the sun visibly climbs or sinks, the same beam gets packed tight or smeared long across the ground, the kid's shadow stretches or shrinks, the daylight bar grows and shrinks, and the kid changes clothes (and complains about the cold).
- The numbers are honest. Sun angle, footprint spread (~2.5× in winter vs. midsummer), and day length (about 15¼ h vs. 8½ h at 45°) are all computed live from the actual tilt, so her "but why?" questions get real answers, not vibes.
- A "watch from the southern half" toggle shows why Australia has Christmas on the beach.
- At the bottom, three short numbered steps tell the story in words, plus the two things a nine-year-old loves most: a flashlight experiment to run tonight, and the counter-intuitive fact that Earth is farthest from the Sun in July.
Save it as an .html file and open it in any browser — it runs entirely locally (only the two fonts come from Google Fonts).
The catch: The Earth is closest to the sun in early January, in the northern winter: the seasons come from its tilt.
Run details
- Model
- z-ai/glm-5.3-prime
- Route
- OpenRouter API, one user message
- Served by
- Alibaba
- Effort
- max, OpenRouter's default for this model
- Limits
- 60 min · 131,072 output tokens (the model's limit)
- Date
- 24 Oct 2026
- Time
- 17 min 3 s
- Words
- 3,195
- Tokens
- 34 in, 96,390 out (80,395 of them thinking)
- List price
- $0.85 at today's price ($2.8 in / $8.8 out per M)
- Paid
- $0.85
Exact prompt
my 9 year old asked why we have summer and winter. can you draw it so she gets it?