GLM 5.3 PrimeZ.ai
Share
Link: https://hypeorcode.com/
i have 4 eggs, half a leek, stale bread, some parmesan and butter. no oven. dinner for 2 in 20 min? my gf is vegetarian
Yes — frittata di pane (bread frittata). It's an Italian thing made exactly for stale bread, and it all happens in one pan. Everything you have is vegetarian, so you're set.
Stovetop bread & leek frittata (~20 min)
(0–3 min) Slice the leek into half-moons and rinse it well — leeks hide dirt. Beat the 4 eggs with a good pinch of salt and pepper. Tear or cube the bread (about 2 cups' worth) into the eggs so it soaks.
(3–10 min) Melt a generous knob of butter in a nonstick skillet (~9") over medium. Add the leeks with a pinch of salt and cook, stirring, until soft and just golden.
(10–18 min) Spread the leeks evenly, pour in the egg-bread mixture, scatter most of the parmesan on top. Cover, drop the heat to medium-low, and cook until the edges are set and the center barely jiggles (~7–8 min). Lift an edge occasionally to make sure the bottom isn't browning too fast.
(18–20 min) Slide it onto a plate, cut into wedges, finish with the rest of the parmesan and lots of black pepper.
If you have them: a squeeze of lemon or a few drops of vinegar/hot sauce at the end cuts through the richness nicely.
Backup option: fry the leeks in butter until silky, then do savory French toast — dip bread slices in egg (parmesan mixed in), fry in butter, pile the leeks on top. Equally good, slightly more "breakfast for dinner."
The catch: Parmesan is usually made with animal rennet, so many vegetarians skip it.
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
- 20 s
- Words
- 249
- Tokens
- 49 in, 1,647 out (1,282 of them thinking)
- List price
- $0.015 at today's price ($2.8 in / $8.8 out per M)
- Paid
- $0.015
- Script
- It calls the dish vegetarian without a word about the rennet in parmesan.
Exact prompt
i have 4 eggs, half a leek, stale bread, some parmesan and butter. no oven. dinner for 2 in 20 min? my gf is vegetarian
GLM 5.3 Prime: other Everyday prompts
- Letter6 s$0.006
Yes — you need to act by 28 Nov 2026, or your rate jumps ~39%.
TL;DR:
- Your current tariff (Fixed Saver 24) is being scrapped on 31 Dec 2026
- If you do nothing, you'll be auto-moved to their Standard Variable tariff at 34.1p/kWh (you're currently paying 24.6p)
- To stay on a fixed rate, log into your account/app and pick Fixed Saver 27 before 28 Nov 2026
Don't be fooled by the "there is nothing else you need to do" line — doing nothing means paying more. Also worth noting: since you're being forced to switch tariffs anyway, this is a good moment to compare other suppliers' fixed deals before committing.
- Landlord10 s$0.007
Subject: Security Deposit Return – [Property Address]
Dear [Landlord's Name],
I hope you're doing well. I'm writing regarding my security deposit of $1,400 for the property at [address], which I moved out of on [date] — seven weeks ago today.
I've reached out several times by text over the past few weeks but haven't received a response, so I wanted to follow up in writing to make sure my request isn't overlooked.
As of now, I haven't received the deposit or any itemized statement of deductions. If there are any deductions, I'd ask that you provide a written, itemized list as required. Otherwise, I'd appreciate the full $1,400 being returned promptly.
Please send the deposit, or an itemized statement with an expected refund date, within 10 business days of this email. I'd much rather resolve this directly and amicably than pursue other options.
My forwarding address for the refund is:
[Your new address]
Please let me know if you need anything further from me to process this. I can be reached at this email or by phone at [your number].
Thank you, Sam
A few notes: fill in the bracketed details before sending. If you don't hear back within your stated deadline, your next steps would typically be a formal demand letter and then small claims court, where deposits like this are usually straightforward to recover. Want me to draft that follow-up letter too, just in case?
- Scam17 s$0.013
No, this is a scam — and the URL gives it away immediately.
The trick in the link: The actual domain in
dhl.com-redelivery.euiscom-redelivery.eu. The scammer registered that domain and added "dhl.com" as a prefix so it looks official at a glance. A real DHL link would go todhl.com,dhl.de, ordhl.com/...— nothing else.Other red flags:
- The tiny fee (1.99 EUR) — customs fees are rarely that small. The point isn't your 2 euros; it's getting you to enter your card number, expiry, and CVV on the fake payment page so they can steal from you later.
- 24-hour deadline + threat of return-to-sender — classic pressure tactics to stop you from verifying anything.
- Unsolicited text with no matching tracking number or order details.
What to do:
- Don't click the link, don't reply, don't pay anything.
- Delete it (or report it as spam/junk on your phone).
- If you're genuinely expecting a parcel, check its status by going to dhl.com yourself (type it in — don't use the link) or through the tracking info from your original order confirmation.
- Optionally report it — forwarding to 7726 (SPAM) works in many countries, and DHL has a phishing report page if you want to flag the URL.
If you already clicked and entered card details: contact your bank now and ask them to block/replace the card. Acting fast usually prevents any actual charges.
This particular scam (fake DHL customs fee texts) has been making the rounds heavily across Europe — you're far from the only one getting these.
- Book13 s$0.009
I have to be honest with you here: I'm not familiar with a book called Nineteen Quiet Rooms by Ottilie Marsh. It's not something I have reliable knowledge of, and I don't want to guess at a twist and send you into book club with made-up plot details — that could go badly for you!
A few possibilities:
- It might be very recent or from a small press — my knowledge has a cutoff, so newer releases sometimes aren't on my radar.
- The title or author might be slightly off — worth double-checking, since book club announcements sometimes get garbled.
Since your meeting is tomorrow, here are your fastest options:
- Goodreads reviews — reviewers often discuss endings, and you can sort by spoilers. Sometimes people explicitly write "the twist is..."
- Search "Nineteen Quiet Rooms ending explained" — thriller-ish books often get ending-explainer articles or Reddit threads.
- Ask a club member — even a "hey, I want to come in fresh, can you give me the gist of the last few chapters?" to someone who finished it.
- Skim the last 30–40 pages yourself — often enough to catch a twist even if you missed the buildup.
If you can tell me roughly where you stopped reading or what the book's premise is, I'm happy to help you think through what you've got so far — or if the title turns out to be something else, let me know and I'll see if I actually know it!
- Dinner20 s$0.015
Yes — frittata di pane (bread frittata). It's an Italian thing made exactly for stale bread, and it all happens in one pan. Everything you have is vegetarian, so you're set.
Stovetop bread & leek frittata (~20 min)
(0–3 min) Slice the leek into half-moons and rinse it well — leeks hide dirt. Beat the 4 eggs with a good pinch of salt and pepper. Tear or cube the bread (about 2 cups' worth) into the eggs so it soaks.
(3–10 min) Melt a generous knob of butter in a nonstick skillet (~9") over medium. Add the leeks with a pinch of salt and cook, stirring, until soft and just golden.
(10–18 min) Spread the leeks evenly, pour in the egg-bread mixture, scatter most of the parmesan on top. Cover, drop the heat to medium-low, and cook until the edges are set and the center barely jiggles (~7–8 min). Lift an edge occasionally to make sure the bottom isn't browning too fast.
(18–20 min) Slide it onto a plate, cut into wedges, finish with the rest of the parmesan and lots of black pepper.
If you have them: a squeeze of lemon or a few drops of vinegar/hot sauce at the end cuts through the richness nicely.
Backup option: fry the leeks in butter until silky, then do savory French toast — dip bread slices in egg (parmesan mixed in), fry in butter, pile the leeks on top. Equally good, slightly more "breakfast for dinner."
- Toast23 s$0.018
Here's a ~50-second toast:
"Hi, I'm [name], the sister — and for about three months, the only person who knew there was 'a guy at the climbing gym.' Not his name. Just 'the guy.'
Elliot — it took her a while, but I'm glad she finally let you down off that wall.
Here's what sold me. My sister has hung off cliffs by her fingertips without blinking. But the first time she brought you to dinner, she couldn't stop smiling. She was nervous. I'd never seen that before.
So please raise a glass — to my sister, who finally found someone worth being nervous about. And to Elliot: you caught her. She'll tell you that's a climbing thing. It's not.
To the bride and groom."
A few notes:
- Swap the "first dinner" detail for a real moment if you have a better one — a true specific beat always lands harder than a generic one.
- Pause after "It's not." That's the laugh line, let it breathe.
- "Let you down" and "caught her" are quiet belay references — climbers will get them, everyone else just hears a normal sentence. That's what keeps it from being cringe.
- GLM 5.3 Prime, Menu: Not run: the prompt comes with a photo, and this model reads no imagestext onlyMenu
- Chores6 min 3 s$0.33
Here's a printable chore chart built for the fridge — warm, hand-drawn feel, Ava gets coral, Leo gets teal, and everything on it is editable before you print.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <title>Chore Chart — Ava & Leo</title> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@400;500;600;700;800&family=Caveat:wght@500;600;700&display=swap" rel="stylesheet"> <style> :root{ --ink:#2c2620; --paper:#fffcf4; --desk:#e9e1d1; --ava:#e4572e; --leo:#0f766e; --ava-tint:rgba(228,87,46,.08); --leo-tint:rgba(15,118,110,.08); --line:#ddd2bc; --muted:#8a7f6d; } *{box-sizing:border-box;-webkit-print-color-adjust:exact;print-color-adjust:exact} html,body{margin:0} ::selection{background:var(--ink);color:var(--paper)} body{ font-family:'Baloo 2','Trebuchet MS',sans-serif; background:var(--desk); color:var(--ink); padding:26px 14px 60px; overflow-x:auto; min-height:100vh; } /* ---------- screen-only controls ---------- */ .toolbar{display:flex;justify-content:center;gap:10px;flex-wrap:wrap;margin-bottom:8px} .tb{ display:inline-flex;align-items:center;gap:8px; font:600 14px/1 'Baloo 2';padding:11px 17px;border-radius:999px; border:1.75px solid var(--ink);background:var(--paper);color:var(--ink); cursor:pointer;box-shadow:0 3px 0 rgba(44,38,32,.85); transition:transform .12s,box-shadow .12s; } .tb:hover{transform:translateY(-2px);box-shadow:0 5px 0 rgba(44,38,32,.85)} .tb:active{transform:translateY(1px);box-shadow:0 2px 0 rgba(44,38,32,.85)} .tb svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2.1;stroke-linecap:round;stroke-linejoin:round} .tb.primary{background:var(--ink);color:var(--paper);box-shadow:0 3px 0 rgba(44,38,32,.35)} .tb.primary:hover{box-shadow:0 5px 0 rgba(44,38,32,.35)} .tb.primary:active{box-shadow:0 2px 0 rgba(44,38,32,.35)} .hint{text-align:center;font:600 18px/1.3 'Caveat',cursive;color:#7c7161;margin:0 0 20px} .hint b{color:var(--ink)} /* ---------- the sheet ---------- */ .sheet{ position:relative;width:9.4in;min-width:780px;margin:0 auto; background:var(--paper);border:1px solid #e3dbc8;border-radius:14px; box-shadow:0 20px 45px rgba(60,50,30,.22); padding:38px 42px 26px; } .tape{position:absolute;width:86px;height:24px;top:-11px;opacity:.85} .tape.l{left:40px;transform:rotate(-5deg);background:rgba(228,87,46,.32)} .tape.r{right:40px;transform:rotate(4deg);background:rgba(15,118,110,.32)} /* header */ .head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px} h1{margin:0;font:700 50px/0.95 'Caveat',cursive;letter-spacing:.01em} .squig{display:block;margin:3px 0 0} .sub{margin:5px 0 0;font:600 22px/1.1 'Caveat',cursive} .sub .n-ava{color:var(--ava)} .sub .n-leo{color:var(--leo)} .sub .amp,.sub .muted{color:var(--muted)} .week{display:flex;align-items:center;gap:9px;font:600 24px/1 'Caveat',cursive;border-bottom:2px dashed var(--line);padding:6px 8px 4px;margin-top:6px;white-space:nowrap} .week svg{width:17px;height:17px;stroke:var(--muted);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;flex:none} .week [contenteditable],.reward [contenteditable]{display:inline-block;outline:none} .week [contenteditable]{min-width:120px} [contenteditable]:empty::before{content:attr(data-ph);color:#c4b8a0} /* shared grid geometry: day rail + 4 chore columns */ .heads,.grid,.stars{display:grid;grid-template-columns:1.05in repeat(4,1fr);column-gap:10px} /* kid name banners */ .heads{margin-top:18px;row-gap:4px} .kid-head{grid-column:span 2;display:flex;flex-direction:column;align-items:center} .kid-head.ava{--kid:var(--ava)} .kid-head.leo{--kid:var(--leo)} .kh-name{font:700 33px/1 'Caveat',cursive;color:var(--kid)} .kh-squig{margin:1px 0 2px} .kh-meta{font:700 10.5px/1 'Baloo 2';letter-spacing:.14em;text-transform:uppercase;color:var(--muted)} /* day rows */ .grid{margin-top:12px;row-gap:9px} .day{display:flex;flex-direction:column;justify-content:center;padding-right:6px;border-right:2px dashed var(--line)} .day-name{font:800 16px/1 'Baloo 2';letter-spacing:.09em} .day-date{margin-top:3px;font:600 17px/1 'Caveat',cursive;color:var(--muted);outline:none;caret-color:var(--muted)} /* chore groups */ .group{grid-column:span 2;display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:8px 11px;border-radius:12px;background:var(--tint);border-left:4px solid var(--kid);min-height:66px} .group.ava{--kid:var(--ava);--tint:var(--ava-tint)} .group.leo{--kid:var(--leo);--tint:var(--leo-tint)} .group.ava:hover{background:rgba(228,87,46,.13)} .group.leo:hover{background:rgba(15,118,110,.13)} .chore{display:flex;align-items:center;gap:9px;min-height:48px} .chore + .chore{border-left:1.5px dashed rgba(44,38,32,.15);padding-left:10px} /* checkboxes */ .chk{ flex:none;width:23px;height:23px;padding:0; border:2.5px solid var(--kid);border-radius:7px;background:#fff;cursor:pointer; display:grid;place-items:center; box-shadow:0 1.5px 0 rgba(44,38,32,.22); transition:transform .15s,background .2s; } .chk svg{width:14px;height:14px;display:block} .chk svg path{fill:none;stroke:#fff;stroke-width:3.4;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:26;stroke-dashoffset:26;transition:stroke-dashoffset .28s ease .04s} .chore.done .chk{background:var(--kid);animation:pop .35s ease} .chore.done .chk svg path{stroke-dashoffset:0} @keyframes pop{0%{transform:scale(1)}45%{transform:scale(1.22) rotate(5deg)}100%{transform:scale(1)}} .chk:hover{transform:scale(1.1) rotate(-4deg)} .chk:focus-visible{outline:3px solid var(--kid);outline-offset:2px} /* chore text */ .txt{ flex:1;min-height:1.3em;margin:0; font:500 15.5px/1.22 'Baloo 2'; outline:none;border-bottom:2px solid transparent;padding-bottom:1px; transition:border-color .15s;overflow-wrap:break-word;caret-color:var(--kid); } .txt:focus{border-bottom-color:var(--kid)} .chore.done .txt{color:#a19681;text-decoration:line-through;text-decoration-color:var(--kid);text-decoration-thickness:2px} /* star tracker */ .stars{margin-top:18px;padding-top:10px;border-top:2px dashed var(--line);row-gap:10px;align-items:center} .stars-label{display:flex;align-items:center;align-self:stretch;padding-right:6px;border-right:2px dashed var(--line);font:800 12px/1.35 'Baloo 2';letter-spacing:.1em;text-transform:uppercase;color:var(--muted)} .star-set{grid-column:span 2;display:flex;gap:5px;flex-wrap:wrap;min-height:21px} .star-set.ava{--kid:var(--ava)} .star-set.leo{--kid:var(--leo)} .star{width:21px;height:21px;color:var(--kid)} .star path{fill:none;stroke:currentColor;stroke-width:1.7;stroke-linejoin:round} .star.filled path{fill:currentColor} .star.pop{animation:starpop .45s ease} @keyframes starpop{0%{transform:scale(.2) rotate(-25deg)}60%{transform:scale(1.3) rotate(10deg)}100%{transform:scale(1)}} .reward{grid-column:2/-1;display:flex;align-items:center;justify-content:center;gap:9px;font:700 23px/1 'Caveat',cursive;padding-top:2px} .reward svg{width:18px;height:18px;color:var(--muted);flex:none} .reward [contenteditable]{border-bottom:2px dashed var(--line);padding:0 10px 3px;min-width:2.5in;max-width:100%;text-align:center} /* footer */ .foot{display:flex;justify-content:space-between;align-items:baseline;gap:20px;margin-top:16px;font:600 12px/1.4 'Baloo 2';color:var(--muted)} .foot p{margin:0} .initials{white-space:nowrap} .blank{display:inline-block;width:88px;border-bottom:2px solid #b9ae97;height:12px;vertical-align:baseline} /* toast */ .toast{ position:fixed;left:50%;bottom:28px;transform:translate(-50%,16px); background:var(--ink);color:var(--paper);font:600 14px/1 'Baloo 2'; padding:12px 20px;border-radius:999px;opacity:0;pointer-events:none; transition:opacity .25s,transform .25s;z-index:60; box-shadow:0 10px 26px rgba(44,38,32,.35); } .toast.show{opacity:1;transform:translate(-50%,0)} /* ---------- print ---------- */ @page{size:letter landscape;margin:0.35in} @media print{ body{background:#fff;padding:0;overflow:visible} .toolbar,.hint,.toast,.tape{display:none!important} .sheet{width:100%;min-width:0;margin:0;border:none;border-radius:0;box-shadow:none;padding:0} .chk:hover{transform:none} } </style> </head> <body> <div class="toolbar"> <button class="tb primary" id="printBtn" type="button"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9V3h12v6"/><path d="M6 18H4.5A2.5 2.5 0 0 1 2 15.5v-5A2.5 2.5 0 0 1 4.5 8h15A2.5 2.5 0 0 1 22 10.5v5a2.5 2.5 0 0 1-2.5 2.5H18"/><rect x="6" y="14" width="12" height="7" rx="1.5"/></svg> Print </button> <button class="tb" id="shuffleBtn" type="button"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="m15 15 6 6"/><path d="m4 4 5 5"/></svg> Shuffle chores </button> <button class="tb" id="resetBtn" type="button"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4v6h6"/><path d="M3.5 15a9 9 0 1 0 2.1-9.4L3 10"/></svg> Clear ticks </button> </div> <p class="hint">Everything on the chart is editable — click any text to tweak it. Print in <b>Landscape</b>, pop it on the fridge, tick with a pen.</p> <main class="sheet" id="sheet"> <div class="tape l" aria-hidden="true"></div> <div class="tape r" aria-hidden="true"></div> <header class="head"> <div class="title-wrap"> <h1>Chore Chart</h1> <svg class="squig" width="252" height="10" viewBox="0 0 252 10" fill="none" aria-hidden="true"> <path d="M2 7 C 22 2 40 10 62 6 S 96 2 126 6" stroke="#e4572e" stroke-width="3.4" stroke-linecap="round"/> <path d="M126 6 C 148 10 168 2 190 6 S 228 10 250 5" stroke="#0f766e" stroke-width="3.4" stroke-linecap="round"/> </svg> <p class="sub"><span class="n-ava">Ava</span><span class="amp"> & </span><span class="n-leo">Leo</span><span class="muted"> · Monday to Friday</span></p> </div> <div class="week"> <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4.5" width="18" height="17" rx="2.5"/><line x1="8" y1="2.5" x2="8" y2="6.5"/><line x1="16" y1="2.5" x2="16" y2="6.5"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <span id="weekOf" contenteditable="true" spellcheck="false" data-ph="add the week"></span> </div> </header> <section class="heads"> <div></div> <div class="kid-head ava"> <span class="kh-name">Ava</span> <svg class="kh-squig" width="72" height="8" viewBox="0 0 72 8" fill="none" aria-hidden="true"><path d="M2 5 Q 10 1 18 5 T 34 5 T 50 5 T 66 5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg> <span class="kh-meta">age 7 · two chores a day</span> </div> <div class="kid-head leo"> <span class="kh-name">Leo</span> <svg class="kh-squig" width="66" height="8" viewBox="0 0 66 8" fill="none" aria-hidden="true"><path d="M2 5 Q 10 1 18 5 T 34 5 T 50 5 T 64 5" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"/></svg> <span class="kh-meta">age 10 · two chores a day</span> </div> </section> <section class="grid" id="grid"></section> <section class="stars"> <div class="stars-label">Stars earned</div> <div class="star-set ava" id="starsAva"></div> <div class="star-set leo" id="starsLeo"></div> <div class="reward"> <svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg> <span id="rewardTxt" contenteditable="true" spellcheck="false" data-ph="set a reward…"></span> </div> </section> <footer class="foot"> <p class="tip">Tick a box when a chore is done — every tick earns a star to color in.</p> <p class="initials">Parent’s initials <span class="blank"></span></p> </footer> </main> <div class="toast" id="toast" role="status"></div> <script> const KEY = 'ava-leo-chores-v1'; const DAYS = ['MON','TUE','WED','THU','FRI']; const DAY_FULL = ['Monday','Tuesday','Wednesday','Thursday','Friday']; const NAME = { ava: 'Ava', leo: 'Leo' }; /* age-appropriate defaults, one pair per day */ const DEFAULTS = { ava: [ ['Make your bed', 'Feed the cat'], ['Water the plants', 'Tidy the toy bin'], ['Set the table', 'Clear your plate'], ['Wipe the kitchen table', 'Match socks into pairs'], ['Toys back on the shelf', 'Dirty clothes in the hamper'] ], leo: [ ['Take out the recycling', 'Load the dishwasher'], ['Walk the dog', 'Fold the towels'], ['Empty the dishwasher', 'Practice 20 minutes'], ['Take out the trash', 'Vacuum the living room'], ['Help make dinner', 'Clean your room'] ] }; /* pools the shuffle button draws from (no repeats within a week) */ const POOLS = { ava: ['Make your bed','Feed the cat','Water the plants','Tidy the toy bin','Set the table','Clear your plate','Wipe the kitchen table','Match socks into pairs','Toys back on the shelf','Dirty clothes in the hamper','Bring in the mail','Dust the bookshelf','Straighten the shoes','Water the garden','Put away clean silverware'], leo: ['Take out the recycling','Load the dishwasher','Walk the dog','Fold the towels','Empty the dishwasher','Practice 20 minutes','Take out the trash','Vacuum the living room','Help make dinner','Clean your room','Feed the fish','Fold your laundry','Wipe the counters','Sweep the porch','Collect trash from the rooms'] }; const sheet = document.getElementById('sheet'); const grid = document.getElementById('grid'); /* ---------- state ---------- */ function nextMonday(){ const d = new Date(); const diff = (d.getDay() === 1) ? 0 : (8 - d.getDay()) % 7; d.setDate(d.getDate() + diff); return d; } const fmt = d => d.toLocaleDateString('en-US', { month:'short', day:'numeric' }); function buildDefault(){ const mon = nextMonday(); const days = DAY_FULL.map((_, i) => { const d = new Date(mon); d.setDate(mon.getDate() + i); return { date: fmt(d), ava: DEFAULTS.ava[i].slice(), leo: DEFAULTS.leo[i].slice(), done: { ava:[false,false], leo:[false,false] } }; }); return { week: 'Week of ' + fmt(mon), reward: '10 stars = Friday pizza night and you pick the movie', days }; } function load(){ try{ const s = JSON.parse(localStorage.getItem(KEY)); if (s && Array.isArray(s.days) && s.days.length === 5) return s; }catch(e){} return buildDefault(); } /* ---------- rendering ---------- */ const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); function render(s){ document.getElementById('weekOf').textContent = s.week || ''; document.getElementById('rewardTxt').textContent = s.reward || ''; let html = ''; s.days.forEach((day, i) => { html += ` <div class="day"> <span class="day-name">${DAYS[i]}</span> <span class="day-date" contenteditable="true" spellcheck="false" data-ph="add date">${esc(day.date)}</span> </div>`; ['ava','leo'].forEach(kid => { html += `<div class="group ${kid}">`; for (let c = 0; c < 2; c++){ const done = day.done && day.done[kid] && day.done[kid][c] ? ' done' : ''; html += ` <div class="chore${done}"> <button class="chk" type="button" aria-pressed="${done ? 'true' : 'false'}" aria-label="Mark ${NAME[kid]}'s ${DAY_FULL[i]} chore as done"> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17 19 6.5"/></svg> </button> <p class="txt" contenteditable="true" spellcheck="false" data-ph="write a chore…">${esc(day[kid][c] || '')}</p> </div>`; } html += `</div>`; }); }); grid.innerHTML = html; } /* ten star slots per kid in the tracker */ const STAR_PATH = 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z'; ['starsAva','starsLeo'].forEach(id => { let h = ''; for (let i = 0; i < 10; i++) h += `<svg class="star" viewBox="0 0 24 24" aria-hidden="true"><path d="${STAR_PATH}"/></svg>`; document.getElementById(id).innerHTML = h; }); function syncStars(animate = true){ ['ava','leo'].forEach(kid => { const n = grid.querySelectorAll(`.group.${kid} .chore.done`).length; document.querySelectorAll(`#stars${kid === 'ava' ? 'Ava' : 'Leo'} .star`).forEach((st, i) => { const on = i < n; if (on && !st.classList.contains('filled')){ st.classList.add('filled'); if (animate){ st.classList.remove('pop'); void st.offsetWidth; st.classList.add('pop'); } } else if (!on){ st.classList.remove('filled','pop'); } }); }); } /* ---------- persist edits locally ---------- */ let saveTimer; function save(){ clearTimeout(saveTimer); saveTimer = setTimeout(() => { const dayEls = [...grid.querySelectorAll('.day')]; const gs = { ava: [...grid.querySelectorAll('.group.ava')], leo: [...grid.querySelectorAll('.group.leo')] }; const state = { week: document.getElementById('weekOf').textContent.trim(), reward: document.getElementById('rewardTxt').textContent.trim(), days: dayEls.map((dEl, i) => ({ date: dEl.querySelector('.day-date').textContent.trim(), ava: [...gs.ava[i].querySelectorAll('.txt')].map(t => t.textContent.trim()), leo: [...gs.leo[i].querySelectorAll('.txt')].map(t => t.textContent.trim()), done: { ava: [...gs.ava[i].querySelectorAll('.chore')].map(c => c.classList.contains('done')), leo: [...gs.leo[i].querySelectorAll('.chore')].map(c => c.classList.contains('done')) } })) }; localStorage.setItem(KEY, JSON.stringify(state)); }, 350); } /* ---------- interactions ---------- */ sheet.addEventListener('click', e => { const chk = e.target.closest('.chk'); if (!chk) return; const done = chk.closest('.chore').classList.toggle('done'); chk.setAttribute('aria-pressed', done); syncStars(); save(); }); sheet.addEventListener('input', save); sheet.addEventListener('keydown', e => { if (e.key === 'Enter' && e.target.isContentEditable){ e.preventDefault(); e.target.blur(); /* keep each field a single line */ } }); /* paste as plain text, on one line */ sheet.addEventListener('paste', e => { if (!e.target.isContentEditable) return; e.preventDefault(); const t = (e.clipboardData || window.clipboardData).getData('text/plain').replace(/\s*\n\s*/g, ' '); document.execCommand('insertText', false, t); }); sheet.addEventListener('focusout', e => { if (e.target.isContentEditable && !e.target.textContent.trim()) e.target.textContent = ''; }); /* ---------- toolbar ---------- */ function sample(arr, n){ const a = arr.slice(); for (let i = a.length - 1; i > 0; i--){ const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a.slice(0, n); } document.getElementById('shuffleBtn').addEventListener('click', () => { ['ava','leo'].forEach(kid => { const picks = sample(POOLS[kid], 10); [...grid.querySelectorAll(`.group.${kid}`)].forEach((g, gi) => { [...g.querySelectorAll('.txt')].forEach((t, i) => { t.textContent = picks[gi * 2 + i]; }); [...g.querySelectorAll('.chore')].forEach(c => { c.classList.remove('done'); c.querySelector('.chk').setAttribute('aria-pressed', 'false'); }); }); }); syncStars(false); save(); toast('Fresh chores dealt for the week!'); }); document.getElementById('resetBtn').addEventListener('click', () => { grid.querySelectorAll('.chore.done').forEach(c => { c.classList.remove('done'); c.querySelector('.chk').setAttribute('aria-pressed', 'false'); }); syncStars(false); save(); toast('All ticks cleared — ready for a new week!'); }); document.getElementById('printBtn').addEventListener('click', () => window.print()); let toastTimer; function toast(msg){ const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2400); } /* ---------- boot ---------- */ render(load()); syncStars(false); </script> </body> </html>How it works
- The layout: a one-page landscape sheet (letter, ready to print) — Monday–Friday down the left rail with editable dates, Ava's two chores and Leo's two chores per day in color-coded columns, and a star tracker along the bottom where each ticked box earns a star. The reward line ("10 stars = Friday pizza night…") is editable too.
- Design choices: I gave it a warm paper-and-marker feel — Caveat for the handwritten headings, Baloo 2 for chore text — and used just two identity colors: coral for Ava, teal for Leo, which carry through the name banners, checkboxes, stars, and the two "washi tape" strips at the top. It prints light on ink; even if a printer strips background tints, the borders keep everything legible.
- Before printing: click any text (chores, dates, week, reward) to edit it. Dates auto-fill for next week. "Shuffle chores" deals a fresh, duplicate-free set from age-appropriate pools; "Clear ticks" resets for a new week. All your edits are remembered in the browser, so refreshing won't lose them.
- Printing: hit Print (or Ctrl/Cmd+P) and choose Landscape — the toolbar hides itself automatically and it fits on a single page. Then tick boxes and color in stars with a pen on the fridge.
- Seasons17 min 3 s$0.85
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
.htmlfile and open it in any browser — it runs entirely locally (only the two fonts come from Google Fonts).