A tall jungle pane: terraced waterfalls wind down into a lily pool, and animals drop by at random - toucans, parrots, a swinging monkey, a jaguar, a crocodile…

<img src="images/hero-en.gif" alt="Claude Code on the left works through a task while the reef-tank pane on the right dims and a submarine comes down" width="720">
Hand Claude Code a task, and zone out at the fish while it works. (The chat window here is a mock-up; the tank is the real mod.)
Animated side panels for Claude Code that change when Claude is idle vs. working, plus two small prompt bands. I'm learning Claude Code mods by building things, and this is what came out so far.
Every scene is a hand-written SVG animation (no image files). The previews below are the real pictures, captured with the idle state on the left and the working state on the right (an 8-second loop).
한국어 설명은 아래에 있습니다.
This repository is a marketplace. Inside Claude Code:
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
Or from a shell:
claude plugin marketplace add Gnosis333/mod_gallery
claude plugin install night-train@mod-gallery --scope user
Swap night-train for any mod below. Panels open only when you run their slash command (they don't open by themselves, to save CPU). After installing, run /reload-plugins or start a new session.
To try one mod without installing: claude --plugin-dir mods/night-train.
Labels (the status line, descriptions, replies) are English by default. Every mod also has a Korean (한국어) mode through its language option:
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
Then restart Claude Code. You can also run /plugin configure night-train@mod-gallery inside Claude Code, and claude plugin configure night-train@mod-gallery shows whether it is set. Use en to switch back.
| Mod | Command | What it is |
|---|---|---|
night-train | /train | A night-train window |
beach-day | /beach | A top-down beach |
snow-cabin | /snow | A snowy cabin, day and night |
deep-sub | /deep | A deep-sea submersible |
campfire | /fire | A campfire to stare at |
jungle-falls | /falls | A jungle waterfall with 24 kinds of animals |
rain-window | /rain | A rainy window |
moon-view | /moon | The universe seen from the Moon |
tower-cut | /tower | A building cross-section |
desk-garden | /garden | A desk plant and a workshop |
reef-tank | /reef | An aquarium |
usage-band | automatic | Context, limits and cost on one line |
haiku-warning | automatic | A warning while a Haiku model is selected |
<img src="images/night-train.gif" alt="night-train" width="460">
A tall train window. At rest the train waits at STATION under lit lamp posts; when work starts the station slides away and hills, trees and poles stream past at different speeds. Now and then it runs through a long tunnel, and flocks of migrating birds cross the sky. The steam from the cup blows sideways, and when the turn ends the train rolls back into the station.
<img src="images/beach-day.gif" alt="beach-day" width="460">
A beach seen from above. Gentle waves wash the shore, and people in sunglasses lie on sunbeds under parasols (bikinis, one-pieces and trunks). Out at sea swimmers float on all kinds of rings, a pink unicorn among them. While Claude works a jet ski and a banana boat go by, and crabs scuttle across the sand.
<img src="images/snow-cabin.gif" alt="snow-cabin" width="460">
Snow falls softly over a white forest, snowy peaks and a log cabin. It's daytime at rest; while Claude works night falls, the windows glow, the chimney smokes, and a snowman is built every 15 seconds. A white rabbit, a white fox, a magpie and a husky come by.
<img src="images/deep-sub.gif" alt="deep-sub" width="460">
A yellow submersible drifts through the dark among glowing jellyfish, an anglerfish, lanternfish, a dumbo octopus and a gulper eel, while something vast and unknowable stirs faintly far below. While Claude works the searchlight comes on, sonar pings, and now and then the shadow of a giant squid sweeps past.
<img src="images/campfire.gif" alt="campfire" width="460">
A campfire in a dense forest clearing under the stars. Flames lick, sparks crackle up, and a ring of logs and six kinds of flowers surround the fire. A fox, an owl (it swivels its head), a deer, a raccoon and a hedgehog drop by. The fire roars up while Claude works and dies back afterwards.
<img src="images/jungle-falls.gif" alt="jungle-falls" width="460">
A terraced waterfall winds down into a lily pool. 24 kinds of animals visit at random (birds, monkeys, a jaguar, a crocodile, frogs, otters and more) and fish leap up the falls. It's day at rest and night while Claude works. This is the heaviest mod on CPU.
<img src="images/rain-window.gif" alt="rain-window" width="460">
A rainy window: drops run down the glass and merge, city lights blur outside and a cat dozes on the sill. While Claude works a storm comes in, and when lightning strikes the cat opens its eyes and the desk lamp flickers. When the turn ends a rainbow comes out and fades away.
<img src="images/moon-view.gif" alt="moon-view" width="460">
The Earth seen from the Moon. The lander's "eyes" flash and an astronaut bounces about (and tumbles around while Claude works). Stars, supernovae, a black hole, rockets, satellites, UFOs, planets and a few Star Wars-like ships pass at random.
<img src="images/tower-cut.gif" alt="tower-cut" width="460">
A building cross-section. One floor goes up with every finished turn, and at 20 floors the tower tops out with fireworks. A new session starts over. While Claude works the crane moves and raises the frame of the next floor.
<img src="images/desk-garden.gif" alt="desk-garden" width="460">
Three pictures stacked: a flowerpot by a window (one more flower blooms with every finished turn), a dozing cat by a fish tank, and a workshop where Claude's character works. While Claude works the character types at the keyboard.
<img src="images/reef-tank.gif" alt="reef-tank" width="460">
A tall aquarium with fish, a seahorse and jellyfish. While Claude works the lights dim and a yellow submarine dives; every finished turn adds a pearl to the treasure chest.
<img src="images/usage-band.png" alt="usage-band" width="720">
One line above the prompt: context window fill, the 5-hour and 7-day plan limits, and session cost. It turns amber at 70% and brick red at 85%, and each limit shows when it resets (↻). The image is an illustration of the look.
<img src="images/haiku-warning.png" alt="haiku-warning" width="720">
Shows a red warning above the prompt while a Haiku model is selected, as a reminder to switch before complex work. The image is an illustration of the look.
jungle-falls, snow-cabin and campfire are larger pictures and relatively heavier.mods/<name>/ with .claude-plugin/plugin.json, hooks/register.tsx (the behaviour), a test file and a state type. The part of register.tsx between // ---scene-start and // ---scene-end is what draws the picture.language option (see Language). The pictures themselves contain no text, except a few English words such as STATION and the speech bubble in desk-garden.Built with Claude Code.
MIT. Use, change and share freely.
<img src="images/hero-ko.gif" alt="왼쪽 Claude Code가 일하는 동안 오른쪽 어항 패널이 어두워지고 잠수함이 내려오는 모습" width="720">
Claude Code에 일을 맡기고, 일하는 동안엔 어항 보며 멍~ (왼쪽 대화창은 비슷하게 그린 화면이고, 어항은 실제 모드입니다.)
Claude Code 옆에 띄워 두는 그림 패널과 입력창 위 상태 띠를 모았습니다. 패널은 Claude가 쉬고 있을 때와 일하고 있을 때 모습이 달라집니다. Claude Code의 모드를 공부하면서 이것저것 만들어 본 결과물입니다. 위 영어 부분의 그림(왼쪽 쉬는 중, 오른쪽 작업 중)이 그대로 해당합니다.
/plugin marketplace add Gnosis333/mod_gallery
/plugin install night-train@mod-gallery
night-train 자리에 위 표의 모드 이름을 넣으면 됩니다. 패널은 슬래시 명령으로 열 때만 열립니다(CPU를 아끼려고 자동으로 열지 않습니다). 설치 후 /reload-plugins를 입력하거나 새 세션을 여세요.
한글 라벨로 보려면 모드마다 language 옵션을 ko로 정하고 Claude Code를 다시 시작하세요(기본은 영어).
echo '{"language":"ko"}' | claude plugin configure night-train@mod-gallery --values-stdin
/plugin configure night-train@mod-gallery로도 정할 수 있습니다. 영어로 되돌리려면 en을 쓰세요.
/train): 야간열차 창가. 쉴 때는 STATION에 정차해 가로등이 켜져 있고, 작업 중에는 산·나무·전신주가 서로 다른 속도로 흘러가며 가끔 긴 터널을 지납니다. 하늘에는 철새떼가 날아가고, 턴이 끝나면 다시 역으로 들어옵니다./beach): 위에서 내려다본 해변. 파라솔 아래 썬베드에 선글라스를 낀 사람들이 누워 있고, 바다에는 핑크 유니콘 튜브를 비롯한 여러 튜브가 떠 있습니다. 작업 중에는 제트스키, 바나나보트, 꽃게가 나옵니다./snow): 눈이 소복소복 내리는 흰 숲과 설산, 통나무 오두막. 쉴 때는 낮, 작업 중에는 밤이 되어 창에 불이 켜지고 눈사람이 15초마다 하나씩 생깁니다. 흰 토끼, 흰 여우, 까치, 허스키가 찾아옵니다./deep): 어둠 속 노란 잠수정과 야광 해파리, 아귀, 덤보 문어, 풍선장어, 그리고 저 아래 희미하게 움직이는 정체불명의 거대한 것. 작업 중에는 탐조등과 소나가 켜지고 가끔 거대 오징어 그림자가 스칩니다./fire): 별이 가득한 밤 숲속 공터의 모닥불. 불꽃과 불티, 통나무 원, 꽃 여섯 종류, 여우·올빼미(머리를 돌립니다)·사슴·너구리·고슴도치. 작업 중에는 불이 커지고 끝나면 잦아듭니다./falls): 계단식 폭포와 연꽃 연못, 동물 24종이 무작위로 방문. 쉴 때는 낮, 작업 중에는 밤. CPU를 가장 많이 씁니다./rain): 비 오는 창가. 작업 중에는 폭풍, 번개가 치면 고양이가 눈을 뜨고 스탠드가 깜빡이며, 끝나면 무지개가 뜹니다./moon): 달에서 본 지구. 통통 뛰는 우주인(작업 중에는 빙빙 돕니다), 무작위로 지나가는 우주 물체와 스타워즈풍 우주선./tower): 빌딩 단면. 턴이 끝날 때마다 한 층씩 올라가고 20층이면 준공 불꽃놀이./garden): 창가 화분(턴마다 꽃이 핍니다), 졸고 있는 고양이와 수조, Claude가 일하는 작업실./reef): 수족관. 작업 중에는 조명이 어두워지고 노란 잠수함이 내려오며, 턴이 끝날 때마다 진주가 쌓입니다.↻ 포함), 비용을 보여줍니다.jungle-falls, snow-cabin, campfire는 그림이 커서 상대적으로 무겁습니다.language 옵션을 ko로 하면 한글입니다. 그림 속 글자는 STATION과 desk-garden의 말풍선 정도입니다(말풍선은 옵션을 따릅니다).모두 Claude Code로 만들었습니다.
hooks/register.tsx 1024 lines1import { atom, read, update } from 'claude-code'
2import type { ElementTable, Register } from 'claude-code'
3
4import type { Falls } from '../types'
5
6const PANE = 'jungle-falls'
7const STRINGS = {
8 en: {
9 title: 'Jungle',
10 alt: 'A terraced waterfall winds down through the jungle into a lily pool, and animals drop by at random.',
11 working: (clock: string, tools: number) => `Working ${clock} · tools ${tools}`,
12 idle: 'Idle',
13 opened: 'Opened the jungle pane.',
14 note: 'The animation shows in the desktop app.',
15 },
16 ko: {
17 title: '정글',
18 alt: '정글 속 계단식 폭포가 굽이쳐 내려와 연꽃 연못으로 떨어지고, 동물들이 무작위로 찾아옵니다.',
19 working: (clock: string, tools: number) => `작업 중 ${clock} · 도구 ${tools}회`,
20 idle: '쉬는 중',
21 opened: '정글 폭포를 열었습니다.',
22 note: '그림 움직임은 데스크톱 앱에서 보입니다.',
23 },
24}
25
26type Words = (typeof STRINGS)['en']
27
28const KINDS = 24 // kinds of animal; 13 to 17 swim
29const SLOTS = 1 // each kind visits at its own times
30const HORIZON = 800 // seconds of visits laid out in each picture
31const isUnder = (kind: number) => kind >= 13 && kind <= 17 // these swim under the pool's lily pads
32const LIFE = 14 // seconds a visitor stays
33const LIMB_KINDS = [2, 3, 7, 9, 10, 19, 20, 21, 22, 23] // these use a limb; two never share one at once
34
35// ---scene-start
36type Pt = [number, number]
37type Slot = { id: number; kind: number; seed: number }
38
39const W = 400
40const H = 1660 // the pool is twice as deep as it was, so the picture is taller
41const TOP = 262 // where the cliff begins
42const POOL_Y = 1340
43
44const n = (v: number) => String(Math.round(v * 10) / 10)
45const EASE = '.45 0 .55 1'
46const pick = <T,>(list: T[], s: number): T => list[((s % list.length) + list.length) % list.length]
47const rnd = (s: number) => {
48 const v = Math.sin(s * 127.1 + 311.7) * 43758.5453
49
50 return v - Math.floor(v)
51}
52
53const sway = (deg: number, cx: number, cy: number, dur: number, begin: number) =>
54 `<animateTransform attributeName="transform" type="rotate" values="${-deg} ${cx} ${cy};${deg} ${cx} ${cy};${-deg} ${cx} ${cy}" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="${dur}s" begin="${n(-begin)}s" repeatCount="indefinite"/>`
55
56// ---- leaves and ferns --------------------------------------------------------------------------
57const GREENS = ['#1f6b45', '#2f8f55', '#43a863', '#175a3a', '#5cbf72', '#2a7d4c']
58
59const leaf = (x: number, y: number, rot: number, len: number, color: string) =>
60 `<g transform="translate(${n(x)} ${n(y)}) rotate(${n(rot)})"><path d="M0 0 C${n(len * 0.3)} ${n(-len * 0.34)} ${n(len * 0.75)} ${n(-len * 0.3)} ${n(len)} 0 C${n(len * 0.75)} ${n(len * 0.3)} ${n(len * 0.3)} ${n(len * 0.34)} 0 0Z" fill="${color}"/>` +
61 `<path d="M0 0 L${n(len * 0.92)} 0" stroke="#fff" stroke-opacity=".25" stroke-width="1.2"/></g>`
62
63const fern = (x: number, y: number, k: number, size = 1) =>
64 `<g>${sway(2.4, x, y, 4 + (k % 3), k)}` +
65 [-68, -34, 0, 34, 68]
66 .map((a, j) => leaf(x, y, a - 90 + (j % 2) * 4, (28 + (j % 3) * 8) * size, pick(GREENS, k + j)))
67 .join('') +
68 '</g>'
69
70// ---- the sky, the far mountains and the canopy line --------------------------------------------
71const SKYLINE =
72 `<rect x="0" y="0" width="${W}" height="${TOP + 60}" fill="url(#sky)"/>` +
73 '<circle cx="318" cy="96" r="70" fill="#fff4c0" opacity=".35"/><circle cx="318" cy="96" r="34" fill="#fff0a8" opacity=".85"/>' +
74 `<path d="M0 ${TOP - 40} L50 150 L96 ${TOP - 70} L160 120 L226 ${TOP - 60} L290 150 L350 ${TOP - 80} L${W} 160 V${TOP + 60} H0Z" fill="#8fbfb4" opacity=".75"/>` +
75 `<path d="M0 ${TOP - 20} L70 ${TOP - 100} L140 ${TOP - 30} L210 ${TOP - 110} L290 ${TOP - 34} L350 ${TOP - 96} L${W} ${TOP - 40} V${TOP + 60} H0Z" fill="#6fa59c" opacity=".85"/>` +
76 `<rect x="0" y="${TOP - 60}" width="${W}" height="70" fill="url(#haze)"/>`
77
78const CANOPY = [0, 1].map(layer =>
79 Array.from({ length: 22 }, (_, k) => {
80 const x = k * 19 - 8 + layer * 9
81 const y = TOP - 6 - layer * 22 + rnd(k + layer * 40) * 14
82
83 return `<circle cx="${n(x)}" cy="${n(y)}" r="${n(18 + rnd(k + layer * 9) * 14)}" fill="${layer ? '#2a7050' : '#1f5a40'}"/>`
84 }).join(''),
85).join('')
86
87// ---- the cliff -------------------------------------------------------------------------------------
88const ROCKS = Array.from({ length: 28 }, (_, k) => {
89 const x = rnd(k) * 440 - 20
90 const y = TOP + 10 + rnd(k + 50) * (POOL_Y - TOP - 20)
91 const rx = 20 + rnd(k + 100) * 36
92 const ry = 9 + rnd(k + 150) * 18
93
94 return (
95 `<ellipse cx="${n(x)}" cy="${n(y)}" rx="${n(rx)}" ry="${n(ry)}" fill="${pick(['#4b6b4f', '#3f5f48', '#587a58', '#33503f'], k)}"/>` +
96 `<ellipse cx="${n(x - rx * 0.1)}" cy="${n(y - ry * 0.55)}" rx="${n(rx * 0.7)}" ry="${n(ry * 0.4)}" fill="#7ec27a" opacity=".38"/>`
97 )
98}).join('')
99
100const WALL = `<rect x="0" y="${TOP - 4}" width="${W}" height="${POOL_Y - TOP + 8}" fill="url(#wall)"/>` + ROCKS
101
102// ---- the terraces and the water that winds through them --------------------------------------------
103const BASINS: [number, number, number][] = [
104 [262, 330, 62],
105 [176, 560, 70],
106 [246, 790, 68],
107 [150, 1030, 74],
108]
109// from the lip of one terrace to the next: x1, y1, x2, y2, width at the top, width at the foot
110const FALLS: [number, number, number, number, number, number][] = [
111 [248, 340, 192, 556, 26, 40],
112 [192, 570, 232, 786, 28, 42],
113 [232, 800, 166, 1026, 28, 44],
114 [164, 1040, 198, 1346, 36, 70],
115]
116
117const ctrl = (x1: number, y1: number, x2: number, y2: number) => {
118 const dy = y2 - y1
119
120 return { a: [x1 + (x2 - x1) * 0.35, y1 + dy * 0.15], b: [x2 - (x2 - x1) * 0.08, y1 + dy * 0.7] }
121}
122
123const ribbon = (x1: number, y1: number, x2: number, y2: number, w1: number, w2: number) => {
124 const { a, b } = ctrl(x1, y1, x2, y2)
125 const wm = (w1 + w2) / 2
126
127 return (
128 `M${n(x1 - w1 / 2)} ${y1} C${n(a[0] - wm / 2)} ${n(a[1])} ${n(b[0] - w2 / 2)} ${n(b[1])} ${n(x2 - w2 / 2)} ${y2} L${n(x2 + w2 / 2)} ${y2} ` +
129 `C${n(b[0] + w2 / 2)} ${n(b[1])} ${n(a[0] + wm / 2)} ${n(a[1])} ${n(x1 + w1 / 2)} ${y1}Z`
130 )
131}
132
133const centre = (x1: number, y1: number, x2: number, y2: number, dx: number) => {
134 const { a, b } = ctrl(x1, y1, x2, y2)
135
136 return `M${n(x1 + dx)} ${y1} C${n(a[0] + dx)} ${n(a[1])} ${n(b[0] + dx)} ${n(b[1])} ${n(x2 + dx)} ${y2}`
137}
138
139const falls = (fast: boolean) =>
140 FALLS.map(([x1, y1, x2, y2, w1, w2], k) => {
141 const spread = [-0.28, 0, 0.28]
142 .map((f, j) => {
143 const w = w1 + (w2 - w1) * 0.5
144
145 return (
146 `<path d="${centre(x1, y1, x2, y2, f * w)}" fill="none" stroke="#fff" stroke-width="${n(2.2 + (j % 2) * 1.4)}" stroke-linecap="round" stroke-dasharray="${12 + j * 5} ${24 + (j % 2) * 12}" opacity=".7">` +
147 `<animate attributeName="stroke-dashoffset" values="0;-${36 + j * 5 + (j % 2) * 12}" dur="${n((fast ? 0.7 : 1.25) + k * 0.12 + j * 0.1)}s" repeatCount="indefinite"/></path>`
148 )
149 })
150 .join('')
151
152 return (
153 `<path d="${ribbon(x1, y1, x2, y2, w1, w2)}" fill="url(#fallw)"/>` +
154 `<path d="${ribbon(x1, y1, x2, y2, w1 * 0.7, w2 * 0.7)}" fill="#fff" opacity=".16"/>` +
155 spread
156 )
157 }).join('') +
158 // the little stream that feeds the top terrace
159 `<path d="${ribbon(254, TOP + 4, 262, 326, 14, 36)}" fill="url(#fallw)"/>`
160
161const basin = (cx: number, cy: number, rx: number, k: number) =>
162 `<ellipse cx="${cx}" cy="${cy + 20}" rx="${rx + 14}" ry="24" fill="#2f4a38"/>` +
163 `<ellipse cx="${cx}" cy="${cy + 6}" rx="${rx + 10}" ry="17" fill="#5b7a58"/>` +
164 `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="12" fill="url(#basinw)"/>` +
165 [0, 1].map(j => `<ellipse cx="${cx + (j ? 18 : -22)}" cy="${cy + 1}" rx="14" ry="3.6" fill="none" stroke="#fff" stroke-width="1.2" opacity="0"><animate attributeName="opacity" values="0;.7;0" dur="2.6s" begin="${n(-k * 0.9 - j * 1.3)}s" repeatCount="indefinite"/><animateTransform attributeName="transform" type="scale" values="1 1;1.5 1.4" additive="sum" dur="2.6s" begin="${n(-k * 0.9 - j * 1.3)}s" repeatCount="indefinite"/></ellipse>`).join('') +
166 `<path d="M${cx - rx - 8} ${cy + 8} Q${cx} ${cy + 36} ${cx + rx + 8} ${cy + 8}" fill="none" stroke="#7d9a72" stroke-width="5" stroke-linecap="round"/>`
167
168const mist = (x: number, y: number, k: number, fast: boolean) =>
169 [0, 1, 2]
170 .slice(0, fast ? 3 : 2)
171 .map(
172 j =>
173 `<ellipse cx="${n(x + (j - 1) * 16)}" cy="${y - 4}" rx="${n(20 + j * 4)}" ry="${n(9 + j * 2)}" fill="#fff" opacity=".4"><animate attributeName="opacity" values=".12;.5;.12" dur="${n(2.2 + j * 0.5)}s" begin="${n(-k - j * 0.7)}s" repeatCount="indefinite"/>` +
174 `<animateTransform attributeName="transform" type="translate" values="0 0;${(j % 2 ? 1 : -1) * 5} -14;0 0" dur="${n(3 + j * 0.5)}s" begin="${n(-k - j * 0.4)}s" repeatCount="indefinite"/></ellipse>`,
175 )
176 .join('')
177
178// ---- salmon leaping up the falls, and out of the pool ---------------------------------------------------
179const salmon = () =>
180 '<g><animateTransform attributeName="transform" type="rotate" values="-16 0 0;16 0 0;-16 0 0" dur=".28s" repeatCount="indefinite"/>' +
181 '<path d="M-9 0 Q0 -5 8 0 Q0 5 -9 0Z" fill="#ee7f5a"/><path d="M-9 0 L-15 -4 L-14 0 L-15 4Z" fill="#d9603c"/><path d="M-2 -3 Q2 -7 4 -3" fill="#d9603c"/>' +
182 '<path d="M-6 1.2 Q0 3.4 6 1.2" stroke="#ffd0b8" fill="none" stroke-width="1.2"/><circle cx="5" cy="-.8" r="1" fill="#1c2430"/></g>'
183
184// fall, sideways offset (a share of the width), seconds per round, delay
185const LEAP: [number, number, number, number][] = [
186 [0, -0.18, 10.5, 0],
187 [1, 0.2, 12.5, 4],
188 [2, -0.1, 9.5, 2],
189 [3, -0.2, 13, 0],
190 [3, 0.22, 13, 6.5],
191]
192
193const leapers = () =>
194 LEAP.map(([k, f, dur, delay]) => {
195 const [x1, y1, x2, y2, w1, w2] = FALLS[k]
196 const { a, b: c } = ctrl(x1, y1, x2, y2)
197 const dx = f * ((w1 + w2) / 2)
198 const path = `M${n(x2 + dx)} ${y2} C${n(c[0] + dx)} ${n(c[1])} ${n(a[0] + dx)} ${n(a[1])} ${n(x1 + dx)} ${y1}`
199
200 return (
201 '<g data-fish="1" opacity="0">' +
202 `<animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;.02;.2;.23;1" dur="${dur}s" begin="${n(-delay)}s" repeatCount="indefinite"/>` +
203 `<animateMotion path="${path}" rotate="auto" dur="${dur}s" begin="${n(-delay)}s" repeatCount="indefinite" calcMode="linear" keyPoints="0;1;1" keyTimes="0;.22;1"/>${salmon()}</g>`
204 )
205 }).join('')
206
207const jumper = (x: number, y: number, delay: number, dur: number) =>
208 `<g data-fish="1" opacity="0"><animate attributeName="opacity" values="0;1;1;0;0" keyTimes="0;.01;.1;.11;1" dur="${dur}s" begin="${n(-delay)}s" repeatCount="indefinite"/>` +
209 `<animateMotion path="M${x} ${y} Q${x + 16} ${y - 52} ${x + 34} ${y}" rotate="auto" dur="${dur}s" begin="${n(-delay)}s" repeatCount="indefinite" calcMode="linear" keyPoints="0;1;1" keyTimes="0;.1;1"/>${salmon()}</g>` +
210 `<ellipse cx="${x + 34}" cy="${y}" rx="10" ry="3" fill="none" stroke="#fff" stroke-width="1.2" opacity="0"><animate attributeName="opacity" values="0;0;.8;0;0" keyTimes="0;.09;.12;.2;1" dur="${dur}s" begin="${n(-delay)}s" repeatCount="indefinite"/></ellipse>`
211
212// ---- the pool, its banks, pads and lotus ---------------------------------------------------------
213const PADS: Pt[] = [
214 [96, 1396],
215 [146, 1432],
216 [222, 1460],
217 [258, 1408],
218 [306, 1444],
219 [70, 1510],
220 [182, 1534],
221 [268, 1508],
222 [330, 1566],
223 [124, 1590],
224 [236, 1612],
225 [340, 1500],
226]
227
228const pad = ([x, y]: Pt, k: number) =>
229 `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 1.6;0 0" dur="${n(3.4 + k * 0.5)}s" begin="${n(-k)}s" repeatCount="indefinite"/>` +
230 `<path d="M${x} ${y} L${x + 15} ${y - 4} A16 6.6 0 1 1 ${x + 11} ${y + 4.6}Z" fill="#3f9f58" transform="translate(${-x + x} 0)"/>` +
231 `<ellipse cx="${x}" cy="${y}" rx="15" ry="5.6" fill="none" stroke="#2a7d46" stroke-width="1"/></g>`
232
233const lotus = (x: number, y: number) =>
234 `<g transform="translate(${x} ${y})">` +
235 [-40, -20, 0, 20, 40].map(a => `<path d="M0 0 Q-5 -10 0 -16 Q5 -10 0 0Z" fill="#f08ab8" transform="rotate(${a})"/>`).join('') +
236 '<circle cy="-2" r="3" fill="#ffd84d"/></g>'
237
238const POOL_BASE_SHAPE = `<path d="M24 ${POOL_Y - 4} H376 V1560 Q376 1654 200 1654 Q24 1654 24 1560Z" fill="url(#pool)"/>`
239
240
241// the bed of the pool: stones, waving weeds and light moving on the water
242const POOL_DEPTH =
243 Array.from({ length: 9 }, (_, k) => `<ellipse cx="${n(50 + rnd(k + 400) * 300)}" cy="${n(1620 + rnd(k + 410) * 26)}" rx="${n(14 + rnd(k + 420) * 22)}" ry="${n(6 + rnd(k + 430) * 7)}" fill="${pick(['#0a3a46', '#0d4452', '#15555f'], k)}"/>`).join('') +
244 Array.from({ length: 11 }, (_, k) => {
245 const x = 40 + k * 31 + rnd(k + 440) * 14
246 const ht = 44 + rnd(k + 450) * 60
247
248 return `<g>${sway(5, x, 1650, 3 + (k % 4) * 0.6, k * 0.8)}<path d="M${n(x)} 1650 q${n(rnd(k) * 10 - 5)} ${n(-ht * 0.5)} ${n(rnd(k + 9) * 12 - 6)} ${n(-ht)}" fill="none" stroke="${pick(['#1f8f5a', '#2aa56a', '#157a4a'], k)}" stroke-width="${n(3 + (k % 3))}" stroke-linecap="round"/></g>`
249 }).join('') +
250 Array.from({ length: 9 }, (_, k) => `<ellipse cx="${n(50 + rnd(k + 460) * 300)}" cy="${n(1380 + rnd(k + 470) * 240)}" rx="${n(18 + rnd(k) * 20)}" ry="${n(4 + rnd(k + 5) * 5)}" fill="#bff6ec" opacity="0"><animate attributeName="opacity" values="0;.22;0" dur="${n(4 + rnd(k + 8) * 4)}s" begin="${n(-rnd(k + 3) * 6)}s" repeatCount="indefinite"/></ellipse>`).join('')
251
252const POOL_BASE = POOL_BASE_SHAPE + POOL_DEPTH
253
254const POOL_TOP =
255 `<path d="M24 ${POOL_Y} q16 -6 33 0 t33 0 t33 0 t33 0 t33 0 t33 0 t33 0 t33 0 t33 0 t33 0 V${POOL_Y - 6} H24Z" fill="#a8efe6" opacity=".8"><animateTransform attributeName="transform" type="translate" values="0 0;-66 0" dur="4.2s" repeatCount="indefinite"/></path>` +
256 PADS.map((p, k) => pad(p, k)).join('') +
257 lotus(120, 1408) +
258 lotus(278, 1424) +
259 lotus(190, 1522) +
260 lotus(84, 1562) +
261 lotus(300, 1596) +
262 jumper(236, 1372, 0, 9) +
263 jumper(96, 1398, 4.5, 11) +
264 // something watching from the water
265 '<g transform="translate(330 1362)"><animateTransform attributeName="transform" type="translate" values="330 1362;330 1366;330 1362" dur="3.4s" repeatCount="indefinite"/>' +
266 '<circle cx="0" cy="0" r="3.8" fill="#4f7a3a"/><circle cx="-1" cy="-1" r="1.6" fill="#f2c94c"/><circle cx="-12" cy="1" r="3.2" fill="#4f7a3a"/><circle cx="-13" cy="0" r="1.4" fill="#f2c94c"/></g>'
267
268const BANKS =
269 `<path d="M0 ${POOL_Y - 10} Q30 ${POOL_Y - 22} 44 ${POOL_Y - 4} V${H} H0Z" fill="#4d3b2c"/><path d="M${W} ${POOL_Y - 10} Q370 ${POOL_Y - 22} 356 ${POOL_Y - 4} V${H} H${W}Z" fill="#4d3b2c"/>` +
270 `<path d="M0 ${POOL_Y - 10} Q30 ${POOL_Y - 22} 44 ${POOL_Y - 4}" fill="none" stroke="#5cbf72" stroke-width="9" stroke-linecap="round"/><path d="M${W} ${POOL_Y - 10} Q370 ${POOL_Y - 22} 356 ${POOL_Y - 4}" fill="none" stroke="#5cbf72" stroke-width="9" stroke-linecap="round"/>` +
271 Array.from({ length: 14 }, (_, k) => `<ellipse cx="${n(rnd(k + 70) * 40)}" cy="${n(1400 + k * 20)}" rx="${n(9 + rnd(k) * 8)}" ry="5" fill="#6b5640"/><ellipse cx="${n(W - rnd(k + 80) * 40)}" cy="${n(1410 + k * 18)}" rx="${n(9 + rnd(k + 3) * 8)}" ry="5" fill="#6b5640"/>`).join('')
272
273// reeds and big ferns on the banks and the terrace rims
274const GROWTH =
275 [22].map((x, k) => fern(x, POOL_Y - 8, k + 3, 1.3)).join('') +
276 [378].map((x, k) => fern(x, POOL_Y - 8, k + 7, 1.3)).join('') +
277 BASINS.map(([cx, cy, rx], k) => fern(cx - rx - 2, cy + 6, k + 11) + fern(cx + rx + 2, cy + 6, k + 21)).join('') +
278 Array.from({ length: 9 }, (_, k) => `<path d="M${n(46 + k * 36)} ${POOL_Y} q${n(rnd(k) * 8 - 4)} -26 ${n(rnd(k + 9) * 12 - 6)} -${n(28 + rnd(k + 4) * 14)}" stroke="#3f9a55" stroke-width="2.4" fill="none" stroke-linecap="round"/>`).join('')
279
280// ---- limbs for animals to walk and swing from, and vines ----------------------------------------------
281const LIMBS = [
282 { x0: 280, x1: 400, y: 900, side: 1 },
283 { x0: 0, x1: 118, y: 1110, side: -1 },
284 { x0: 0, x1: 120, y: 675, side: -1 },
285] as const
286
287const limb = (l: (typeof LIMBS)[number], k: number) => {
288 const tipx = l.side === -1 ? l.x1 : l.x0
289 const rootx = l.side === -1 ? l.x0 : l.x1
290
291 return (
292 `<path d="M${rootx} ${l.y + 8} Q${n((rootx + tipx) / 2)} ${l.y - 4} ${tipx} ${l.y}" fill="none" stroke="#3b2a1a" stroke-width="15" stroke-linecap="round"/>` +
293 `<path d="M${rootx} ${l.y + 6} Q${n((rootx + tipx) / 2)} ${l.y - 6} ${tipx} ${l.y - 2}" fill="none" stroke="#7a5532" stroke-width="10" stroke-linecap="round"/>` +
294 `<path d="M${rootx} ${l.y + 3} Q${n((rootx + tipx) / 2)} ${l.y - 9} ${tipx} ${l.y - 5}" fill="none" stroke="#9a7048" stroke-width="2.6" stroke-linecap="round" opacity=".6"/>` +
295 `<g>${sway(1.6, tipx, l.y, 5 + k, k)}${[-70, -35, 0, 35].map((a, j) => leaf(tipx, l.y - 2, (l.side === -1 ? a : 180 - a), 34 + j * 3, pick(GREENS, k + j))).join('')}</g>`
296 )
297}
298
299const vine = (x: number, len: number, dur: number, begin: number) =>
300 `<g>${sway(2.4, x, TOP + 6, dur, begin)}<path d="M${x} ${TOP + 6} q-7 ${n(len * 0.3)} 0 ${n(len * 0.5)} t0 ${n(len * 0.5)}" fill="none" stroke="#1f6b3d" stroke-width="3" stroke-linecap="round"/>` +
301 `<circle cx="${x}" cy="${TOP + 6 + len}" r="3.2" fill="#5cbf72"/><circle cx="${x - 3}" cy="${n(TOP + 6 + len * 0.55)}" r="2.4" fill="#43a863"/></g>`
302
303const VINES = [34, 84, 154, 318, 366].map((x, k) => vine(x, 150 + (k % 3) * 120, 4.5 + k * 0.4, k)).join('')
304
305// the near leaves that frame the picture
306const FRAME = [
307 [-14, 420, -14, 120],
308 [-14, 820, 8, 130],
309 [-14, 1230, -6, 118],
310 [414, 560, 192, 126],
311 [414, 1010, 172, 132],
312 [414, 1280, 190, 120],
313]
314 .map(
315 ([x, y, rot, len], k) =>
316 `<g>${sway(2, x, y, 5 + (k % 3), k * 1.3)}${leaf(x, y, rot, len, '#124d32')}${leaf(x, y, rot + (x < 0 ? 30 : -30), len * 0.8, '#1a6b43')}${leaf(x, y, rot - (x < 0 ? 28 : -28), len * 0.7, '#0f4029')}</g>`,
317 )
318 .join('')
319
320const RAYS = [
321 [300, 380, 150, 70, 8],
322 [236, 296, 40, -50, 11],
323 [350, 400, 260, 220, 13],
324]
325 .map(
326 ([a, b, c, d, dur], k) =>
327 `<polygon points="${a},0 ${b},0 ${c},1000 ${d},1000" fill="#fff6c8" opacity=".08"><animate attributeName="opacity" values=".04;.16;.04" dur="${dur}s" begin="${-k * 3}s" repeatCount="indefinite"/></polygon>`,
328 )
329 .join('')
330
331// Resting is day; while Claude works it is night: a dark veil, the moon, stars and fireflies fade in over 1.4 s.
332const fade = (a0: number, a1: number, inner: string, max = 1) => {
333 const v0 = Math.round(a0 * max * 1000) / 1000
334 const v1 = Math.round(a1 * max * 1000) / 1000
335 if (a0 === a1) {
336 return a1 === 0 ? '' : `<g opacity="${v1}">${inner}</g>`
337 }
338
339 return `<g opacity="${v0}"><animate data-once="1" attributeName="opacity" values="${v0};${v1}" dur="${n(1.4 * Math.abs(a1 - a0))}s" fill="freeze"/>${inner}</g>`
340}
341
342const NIGHT_SKY =
343 '<circle cx="88" cy="112" r="40" fill="#cfd8ff" opacity=".14"/><circle cx="88" cy="112" r="21" fill="#f4f1da"/><circle cx="81" cy="106" r="4" fill="#d6d2b6"/><circle cx="95" cy="119" r="3" fill="#d6d2b6"/>' +
344 Array.from({ length: 14 }, (_, k) => `<circle cx="${n(rnd(k * 2) * W)}" cy="${n(rnd(k * 2 + 1) * (TOP - 50))}" r="${n(0.7 + rnd(k + 90))}" fill="#fff" opacity=".7"><animate attributeName="opacity" values=".25;.9;.25" dur="${n(2 + rnd(k + 5) * 4)}s" begin="${n(-rnd(k + 9) * 6)}s" repeatCount="indefinite"/></circle>`).join('')
345
346const FIREFLIES = Array.from({ length: 10 }, (_, k) => {
347 const x = 20 + rnd(k + 200) * 360
348 const y = TOP + 40 + rnd(k + 210) * (H - 60 - TOP - 80)
349
350 return `<circle cx="${n(x)}" cy="${n(y)}" r="2.2" fill="#e8ff7a"><animate attributeName="opacity" values=".1;1;.2;1;.1" dur="${n(3 + rnd(k) * 4)}s" begin="${n(-rnd(k + 4) * 6)}s" repeatCount="indefinite"/>` +
351 `<animateMotion path="M0 0 C${n(10 + rnd(k) * 16)} -14 ${n(-12 - rnd(k + 3) * 14)} -10 0 0Z" dur="${n(6 + rnd(k + 8) * 5)}s" repeatCount="indefinite"/></circle>`
352}).join('')
353
354const nightLayer = (a0: number, a1: number) =>
355 fade(a0, a1, `<rect x="0" y="0" width="${W}" height="${H}" fill="#06133a"/>`, 0.55) + fade(a0, a1, NIGHT_SKY + FIREFLIES)
356
357// ---- visitors: each is drawn for LIFE seconds from its own start, then dropped ---------------------------
358const flap = (draw: string, dur: number) =>
359 `<g><animateTransform attributeName="transform" type="scale" values="1 1;1 -.4;1 1" dur="${dur}s" repeatCount="indefinite"/>${draw}</g>`
360
361// Crosses the picture left to right (or back), facing the way it goes.
362const flyer = (draw: string, y: number, dir: 1 | -1, dur: number, begin: string) => {
363 const path = dir === 1 ? `M-50 ${n(y)} C100 ${n(y - 70)} 260 ${n(y + 60)} 450 ${n(y - 30)}` : `M450 ${n(y)} C300 ${n(y - 70)} 140 ${n(y + 60)} -50 ${n(y - 30)}`
364
365 return `<g><animateMotion path="${path}" dur="${dur}s" begin="${begin}" fill="freeze"/><g transform="scale(${dir} 1)">${draw}</g></g>`
366}
367
368const toucan = () =>
369 flap('<path d="M-4 0 Q-10 -16 -18 -10 Q-10 -2 -4 0Z" fill="#2a3342"/>', 0.46) +
370 '<ellipse rx="14" ry="6" fill="#1c2430"/><ellipse cx="9" cy="1.4" rx="5" ry="3.8" fill="#fff"/><circle cx="15" cy="-2" r="5" fill="#1c2430"/>' +
371 '<path d="M18 -5 q16 -2 19 4 q-9 6 -19 2Z" fill="#f4a21f"/><path d="M20 -2 q10 -1 16 1" stroke="#d9531e" fill="none"/><circle cx="17" cy="-3.2" r="1.2" fill="#fff"/>' +
372 '<path d="M-12 -1 l-13 3 l13 2Z" fill="#1c2430"/><rect x="-26" y="1" width="3" height="3" fill="#e24a4a"/>'
373
374const parrot = (c: string, wing: string) =>
375 flap(`<path d="M-3 0 Q-8 -14 -16 -9 Q-9 -2 -3 0Z" fill="${wing}"/>`, 0.4) +
376 `<ellipse rx="10" ry="4.4" fill="${c}"/><circle cx="11" cy="-2" r="4.2" fill="${c}"/><ellipse cx="12" cy="-2" rx="2.2" ry="2.8" fill="#fff"/><circle cx="12.4" cy="-2.2" r="1" fill="#1c2430"/>` +
377 '<path d="M14 -4 q5 0 5 4 q-3 0 -5 -2Z" fill="#3a3a3a"/>' +
378 `<path d="M-9 0 L-32 6 L-28 1Z" fill="${wing}"/><path d="M-9 0 L-24 3" stroke="#f2c94c" stroke-width="2"/>`
379
380const monkeyHang = () =>
381 '<path d="M0 0 L-3 13 M0 0 L3 13" stroke="#8b5a2b" stroke-width="3.2" stroke-linecap="round"/>' +
382 '<path d="M2 32 q14 6 8 18 q-4 6 -10 2" fill="none" stroke="#7a4a22" stroke-width="3.4" stroke-linecap="round"/>' +
383 '<ellipse cx="0" cy="24" rx="6.5" ry="10" fill="#8b5a2b"/><ellipse cx="0" cy="25" rx="3.6" ry="6" fill="#e8c9a0"/>' +
384 '<path d="M-3 32 l-2 10 M3 32 l2 10" stroke="#8b5a2b" stroke-width="3" stroke-linecap="round"/>' +
385 '<circle cx="0" cy="12" r="6" fill="#8b5a2b"/><ellipse cx="0" cy="13.2" rx="3.8" ry="3.2" fill="#e8c9a0"/><circle cx="-1.8" cy="11.4" r=".9" fill="#1c2430"/><circle cx="1.8" cy="11.4" r=".9" fill="#1c2430"/><circle cx="-6" cy="11" r="2.2" fill="#8b5a2b"/><circle cx="6" cy="11" r="2.2" fill="#8b5a2b"/>'
386
387const legs = (x: number, k: number) =>
388 `<g><animateTransform attributeName="transform" type="rotate" values="${k ? -24 : 24} ${x} -8;${k ? 24 : -24} ${x} -8;${k ? -24 : 24} ${x} -8" dur=".8s" repeatCount="indefinite"/><rect x="${x - 2.4}" y="-9" width="5" height="12" rx="2.4" fill="#e8a63c"/></g>`
389
390const jaguar = () =>
391 `<g>${sway(8, -26, -16, 2.2, 0)}<path d="M-26 -16 q-14 -6 -12 -20 q2 -8 8 -4" fill="none" stroke="#e8a63c" stroke-width="5" stroke-linecap="round"/></g>` +
392 legs(-20, 0) +
393 legs(18, 0) +
394 '<rect x="-28" y="-24" width="50" height="17" rx="8.5" fill="#e8a63c"/><rect x="-28" y="-12" width="50" height="5" rx="2.5" fill="#f4d89a" opacity=".6"/>' +
395 legs(-10, 1) +
396 legs(26, 1) +
397 [[-20, -18], [-10, -20], [0, -17], [8, -20], [-16, -12], [-4, -11], [12, -14]].map(([x, y]) => `<circle cx="${x}" cy="${y}" r="2.2" fill="#3b2a1a"/>`).join('') +
398 '<circle cx="28" cy="-22" r="9" fill="#e8a63c"/><circle cx="23" cy="-30" r="3" fill="#e8a63c"/><circle cx="32" cy="-30" r="3" fill="#e8a63c"/><ellipse cx="34" cy="-19" rx="5" ry="3.6" fill="#f4d89a"/>' +
399 '<circle cx="30" cy="-24" r="1.6" fill="#1c2430"/><circle cx="37" cy="-20" r="1.2" fill="#222"/><circle cx="26" cy="-27" r="1.5" fill="#3b2a1a"/>'
400
401const croc = (jaw: number) =>
402 '<g><animateTransform attributeName="transform" type="rotate" values="-4 -30 0;4 -30 0;-4 -30 0" dur="2.6s" repeatCount="indefinite"/><path d="M-30 -3 L-72 3 L-30 6Z" fill="#46703a"/></g>' +
403 '<ellipse rx="34" ry="8.5" fill="#4f7a3a"/><ellipse cx="2" cy="4" rx="28" ry="4" fill="#cfd9a0" opacity=".7"/>' +
404 '<path d="M-22 -7 l3 -4 l3 4 l3 -4 l3 4 l3 -4 l3 4 l3 -4 l3 4 l3 -4 l3 4" fill="#35592b"/>' +
405 '<rect x="26" y="-6" width="32" height="8" rx="3.5" fill="#4f7a3a"/><circle cx="55" cy="-6.4" r="1.2" fill="#223"/>' +
406 `<g><animateTransform attributeName="transform" type="rotate" values="0 28 3;0 28 3;24 28 3;24 28 3;0 28 3;0 28 3" keyTimes="0;.35;.4;.46;.52;1" dur="${LIFE}s" begin="${n(jaw)}s" repeatCount="indefinite"/><rect x="28" y="2" width="29" height="5" rx="2.4" fill="#3f6630"/><path d="M32 2 l2 -2.6 l2 2.6 M40 2 l2 -2.6 l2 2.6 M48 2 l2 -2.6 l2 2.6" fill="#fff"/></g>` +
407 '<ellipse cx="27" cy="-8" rx="4.4" ry="3.2" fill="#4f7a3a"/><circle cx="28" cy="-8.6" r="2" fill="#f2c94c"/><rect x="27.4" y="-10" width="1.2" height="3" fill="#1c2430"/>'
408
409const frog = () =>
410 '<ellipse cx="0" cy="-5" rx="7" ry="5" fill="#5fbf4a"/><circle cx="-3.5" cy="-10" r="2.4" fill="#5fbf4a"/><circle cx="3.5" cy="-10" r="2.4" fill="#5fbf4a"/>' +
411 '<circle cx="-3.5" cy="-10.4" r="1" fill="#1c2430"/><circle cx="3.5" cy="-10.4" r="1" fill="#1c2430"/><path d="M-6 -2 q-5 2 -3 4 M6 -2 q5 2 3 4" stroke="#3f8f33" stroke-width="2" fill="none" stroke-linecap="round"/>'
412
413const butterfly = (c: string) =>
414 `<g><animateTransform attributeName="transform" type="scale" values="1 1;.25 1;1 1" dur=".32s" repeatCount="indefinite"/><ellipse cx="-3.4" cy="0" rx="3.6" ry="4.6" fill="${c}"/><ellipse cx="3.4" cy="0" rx="3.6" ry="4.6" fill="${c}"/><circle cx="-3.4" cy="-1" r="1" fill="#fff" opacity=".7"/><circle cx="3.4" cy="-1" r="1" fill="#fff" opacity=".7"/></g><rect x="-.5" y="-4" width="1" height="8" fill="#222"/>`
415
416const sloth = () =>
417 '<path d="M-7 6 L-7 -2 M7 6 L7 -2" stroke="#6b5a42" stroke-width="3.4" stroke-linecap="round"/><path d="M-9 -1 l2 2 M-7 -2 l0 3 M-5 -1 l-2 2" stroke="#e8e0cc" stroke-width="1" stroke-linecap="round"/>' +
418 '<ellipse cx="0" cy="14" rx="16" ry="7.4" fill="#8a7457"/><path d="M-12 12 q4 3 8 0 M0 12 q4 3 8 0" stroke="#6b5a42" stroke-width="1.2" fill="none"/>' +
419 '<circle cx="18" cy="14" r="6.4" fill="#e6d9b8"/><path d="M13.6 12.4 q4 -3 8 0 l-1 3 q-3 -1 -6 0Z" fill="#3b2a1a" opacity=".85"/><circle cx="17" cy="13.6" r="1" fill="#fff"/><circle cx="21" cy="13.6" r="1" fill="#fff"/><path d="M16.4 18 q2 1.6 4 0" stroke="#6b5a42" fill="none" stroke-linecap="round"/>' +
420 '<path d="M-7 20 L-8 28 M7 20 L8 28" stroke="#6b5a42" stroke-width="3" stroke-linecap="round"/>'
421
422const bat = () =>
423 flap('<path d="M0 0 L-13 -8 Q-9 -2 -13 4Z M0 0 L13 -8 Q9 -2 13 4Z" fill="#2a2433"/>', 0.22) +
424 '<ellipse rx="3" ry="4.4" fill="#2a2433"/><path d="M-2.4 -3.4 l-1 -3 l2.4 1.4 M2.4 -3.4 l1 -3 l-2.4 1.4" fill="#2a2433"/><circle cx="-1.1" cy="-1" r=".6" fill="#f2c94c"/><circle cx="1.1" cy="-1" r=".6" fill="#f2c94c"/>'
425
426const chameleon = () =>
427 '<path d="M-9 -7 q-11 6 -15 -2 q-3 -7 4 -6 q5 1 3 5" fill="none" stroke="#6fbf3a" stroke-width="3.2" stroke-linecap="round"><animate attributeName="stroke" values="#6fbf3a;#e8a63c;#6fbf3a" dur="6s" repeatCount="indefinite"/></path>' +
428 '<path d="M-5 -2 l-3 5 M5 -2 l3 5 M-1 -2 l-1 5 M9 -2 l2 5" stroke="#5e9a30" stroke-width="2" stroke-linecap="round"/>' +
429 '<ellipse cx="0" cy="-8" rx="11" ry="7.4" fill="#6fbf3a"><animate attributeName="fill" values="#6fbf3a;#e8a63c;#6fbf3a" dur="6s" repeatCount="indefinite"/></ellipse>' +
430 '<path d="M-6 -14 l3 -3 l3 3 l3 -3 l3 3" fill="none" stroke="#4f8a28" stroke-width="1.6" stroke-linecap="round"/>' +
431 '<ellipse cx="12" cy="-9" rx="6" ry="4.6" fill="#6fbf3a"><animate attributeName="fill" values="#6fbf3a;#e8a63c;#6fbf3a" dur="6s" repeatCount="indefinite"/></ellipse>' +
432 '<circle cx="12" cy="-12.4" r="3" fill="#8bd24f"/><circle cx="12.8" cy="-12.6" r="1.3" fill="#1c2430"/>' +
433 '<path d="M17 -8 L19 -8" stroke="#e24a4a" stroke-width="1.6" stroke-linecap="round"><animate attributeName="d" values="M17 -8 L19 -8;M17 -8 L19 -8;M17 -8 L34 -8;M17 -8 L19 -8;M17 -8 L19 -8" keyTimes="0;.5;.55;.6;1" dur="7s" repeatCount="indefinite"/></path>'
434
435const snake = () =>
436 '<path d="M0 0 q9 12 0 24 t0 24 t0 24 t0 24" fill="none" stroke="#4fae64" stroke-width="6.4" stroke-linecap="round"/>' +
437 '<path d="M0 0 q9 12 0 24 t0 24 t0 24 t0 24" fill="none" stroke="#a8e08a" stroke-width="1.6" stroke-dasharray="3 6" stroke-linecap="round"/>' +
438 '<ellipse cx="0" cy="98" rx="4.4" ry="5.6" fill="#3f9a55"/><circle cx="-2" cy="97" r="1" fill="#f2c94c"/><circle cx="2" cy="97" r="1" fill="#f2c94c"/>' +
439 '<path d="M0 103 v7 l-2 3 M0 110 l2 3" stroke="#e24a4a" stroke-width="1" fill="none"><animate attributeName="opacity" values="1;0;1;1" keyTimes="0;.2;.4;1" dur="1.6s" repeatCount="indefinite"/></path>'
440
441const hummingbird = () =>
442 flap('<ellipse cx="-2" cy="-2" rx="8" ry="2.4" fill="#cfe9f2" opacity=".65" transform="rotate(-28)"/>', 0.1) +
443 '<ellipse rx="5.4" ry="2.8" fill="#1f9d8a"/><circle cx="6" cy="-1" r="2.6" fill="#2fbfa0"/><path d="M8 -1 L20 -1.8" stroke="#333" stroke-width="1"/><circle cx="6.6" cy="-1.4" r=".7" fill="#111"/>' +
444 '<path d="M-5 0 L-11 3 L-11 -2Z" fill="#157a6a"/><circle cx="5.4" cy="1.4" r="1.4" fill="#e23b3b"/>'
445
446const heron = (begin: string) =>
447 '<path d="M-3 -44 V0 M3 -44 V0" stroke="#c9a56b" stroke-width="2" stroke-linecap="round"/>' +
448 '<ellipse cx="0" cy="0" rx="14" ry="3" fill="none" stroke="#fff" stroke-width="1.2" opacity=".6"><animate attributeName="rx" values="10;22;10" dur="3s" repeatCount="indefinite"/></ellipse>' +
449 '<ellipse cx="-2" cy="-52" rx="17" ry="8.6" fill="#8fa8c2" transform="rotate(-8 0 -52)"/><path d="M-16 -50 L-30 -46 L-16 -46Z" fill="#6f88a2"/><path d="M-8 -54 q10 -6 18 -2" stroke="#cfd9e6" stroke-width="2" fill="none"/>' +
450 `<g><animateTransform attributeName="transform" type="rotate" values="0 12 -56;0 12 -56;44 12 -56;44 12 -56;0 12 -56;0 12 -56" keyTimes="0;.3;.37;.45;.52;1" dur="${LIFE}s" begin="${begin}" fill="freeze"/>` +
451 '<path d="M12 -56 q9 -14 -2 -28 q-4 -6 4 -12" fill="none" stroke="#e8eef4" stroke-width="5" stroke-linecap="round"/>' +
452 '<circle cx="14" cy="-98" r="4.6" fill="#e8eef4"/><path d="M12 -100 l-9 -2" stroke="#1c2430" stroke-width="2" stroke-linecap="round"/><path d="M18 -98 L38 -96" stroke="#f2c94c" stroke-width="3" stroke-linecap="round"/><circle cx="15.6" cy="-99" r="1" fill="#1c2430"/></g>'
453
454const turtle = () =>
455 '<ellipse cx="9" cy="8" rx="8" ry="3.4" fill="#7da854"><animateTransform attributeName="transform" type="rotate" values="-20 9 8;20 9 8;-20 9 8" dur="1.8s" repeatCount="indefinite"/></ellipse>' +
456 '<ellipse cx="-10" cy="7" rx="6" ry="2.8" fill="#7da854"><animateTransform attributeName="transform" type="rotate" values="20 -10 7;-20 -10 7;20 -10 7" dur="1.8s" repeatCount="indefinite"/></ellipse>' +
457 '<path d="M-17 2 L-24 4 L-17 5Z" fill="#7da854"/><ellipse cx="20" cy="-1" rx="6.4" ry="4.2" fill="#9ac06a"/><circle cx="22" cy="-2.4" r="1.1" fill="#1c2430"/>' +
458 '<ellipse cx="0" cy="0" rx="18" ry="11" fill="#5b8a3c"/><ellipse cx="0" cy="0" rx="18" ry="11" fill="none" stroke="#3e6a28" stroke-width="1.6"/>' +
459 '<path d="M-8 -6 l6 -3 l6 3 l0 7 l-6 3 l-6 -3Z M6 -7 l6 3 M-14 2 l6 3" fill="none" stroke="#3e6a28" stroke-width="1.4"/>'
460
461const neon = () =>
462 '<g><animateTransform attributeName="transform" type="rotate" values="-8 -6 0;8 -6 0;-8 -6 0" dur=".34s" repeatCount="indefinite"/><path d="M-6 0 L-12 -4 L-12 4Z" fill="#cfd9e8"/></g>' +
463 '<ellipse rx="8" ry="3.3" fill="#d9e3f0"/><path d="M-7 -.6 L8 -.6" stroke="#2fc2ff" stroke-width="1.8"/><path d="M-6 1.2 Q0 3.4 8 1.2 L7 3 Q0 4 -6 2.6Z" fill="#e5483a"/><circle cx="5.4" cy="-1" r="1" fill="#1c2430"/>'
464
465const piranha = () =>
466 '<g><animateTransform attributeName="transform" type="rotate" values="-10 -8 0;10 -8 0;-10 -8 0" dur=".3s" repeatCount="indefinite"/><path d="M-8 0 L-16 -5 L-16 5Z" fill="#4f5e6a"/></g>' +
467 '<ellipse rx="11" ry="6.6" fill="#6b7a86"/><path d="M-8 2 Q0 8 10 3 Q0 5 -8 2Z" fill="#d9534f"/><path d="M-3 -7 Q2 -12 6 -6Z" fill="#4f5e6a"/>' +
468 '<path d="M7 1 l2 3 l2 -3 l2 3 l1 -3" fill="#fff" stroke="#fff" stroke-width=".6"/><circle cx="6" cy="-2" r="1.5" fill="#f2c94c"/><circle cx="6.3" cy="-2" r=".7" fill="#111"/>'
469
470const otter = () =>
471 '<path d="M-16 0 Q-28 2 -36 -3 Q-30 4 -16 3Z" fill="#6b4a2a"/><ellipse cx="0" cy="0" rx="17" ry="5.6" fill="#7a5532"/><ellipse cx="2" cy="2.6" rx="12" ry="2.6" fill="#c9a578"/>' +
472 '<circle cx="17" cy="-1" r="5.4" fill="#7a5532"/><ellipse cx="21" cy="1" rx="3.2" ry="2.4" fill="#e8d4b0"/><circle cx="21.6" cy="0" r=".9" fill="#111"/><circle cx="18" cy="-3" r="1" fill="#111"/><circle cx="14" cy="-5.4" r="1.6" fill="#7a5532"/>' +
473 '<path d="M22 2 l8 -1 M22 3 l8 2" stroke="#fff" stroke-width=".6"/>'
474
475const ray = () =>
476 '<path d="M-8 0 L-46 3" stroke="#5d7690" stroke-width="2" stroke-linecap="round"/>' +
477 '<g><animateTransform attributeName="transform" type="scale" values="1 1;1 .72;1 1" dur="1.8s" repeatCount="indefinite"/>' +
478 '<path d="M26 0 Q10 -17 -12 -20 Q-6 0 -12 20 Q10 17 26 0Z" fill="#6d8aa5"/><path d="M26 0 Q10 -9 -4 -10 Q-1 0 -4 10 Q10 9 26 0Z" fill="#8fa9c0" opacity=".6"/>' +
479 '<circle cx="-1" cy="-6" r="1.8" fill="#4f6a85"/><circle cx="-8" cy="8" r="1.4" fill="#4f6a85"/></g>' +
480 '<circle cx="17" cy="-3" r="1.2" fill="#111"/><circle cx="17" cy="3" r="1.2" fill="#111"/>'
481
482const dragonfly = () =>
483 flap(
484 '<ellipse cx="2" cy="-3" rx="11" ry="3" fill="#cfe9f2" opacity=".6" transform="rotate(-16 2 -3)"/><ellipse cx="2" cy="3" rx="11" ry="3" fill="#cfe9f2" opacity=".6" transform="rotate(16 2 3)"/>',
485 0.09,
486 ) +
487 '<rect x="-16" y="-1.3" width="28" height="2.6" rx="1.3" fill="#2f6fd0"/><path d="M-14 -1.3 v2.6 M-9 -1.3 v2.6 M-4 -1.3 v2.6" stroke="#fff" stroke-width=".8" opacity=".6"/>' +
488 '<circle cx="14" cy="0" r="3.2" fill="#6fd0a0"/><circle cx="15.4" cy="-.8" r="1" fill="#111"/>'
489
490const ant = () =>
491 '<path d="M-3 -1 l-2 3 M0 -1 l0 3 M3 -1 l2 3" stroke="#3b1f12" stroke-width=".8"/>' +
492 '<circle cx="-4" cy="-2" r="2" fill="#5a2a1a"/><circle cx="0" cy="-2.4" r="1.5" fill="#5a2a1a"/><circle cx="4" cy="-2.6" r="2.2" fill="#5a2a1a"/>' +
493 '<path d="M-1 -5 l4 -6 l4 6Z" fill="#5cbf72"><animateTransform attributeName="transform" type="rotate" values="-6 2 -5;6 2 -5;-6 2 -5" dur="1s" repeatCount="indefinite"/></path>'
494
495const kingfisher = () =>
496 '<path d="M-6 -1 L-13 3 L-6 3Z" fill="#2f7fd0"/><ellipse cx="0" cy="0" rx="6.4" ry="4.6" fill="#2f9fd0"/><ellipse cx="0" cy="2" rx="5" ry="2.8" fill="#f08a2f"/>' +
497 '<circle cx="5.6" cy="-3.6" r="3.6" fill="#2f9fd0"/><path d="M8.6 -4 L17 -3.4 L8.6 -2Z" fill="#222"/><circle cx="6.4" cy="-4.2" r=".9" fill="#fff"/><path d="M-4 -2 q4 -5 8 0" fill="#1f7fb0"/>'
498
499const owl = () =>
500 '<ellipse cx="0" cy="-12" rx="9" ry="12" fill="#8a6a4a"/><path d="M-6 -12 q3 4 6 0 q3 4 6 0 M-5 -6 q3 4 5 0 q3 4 5 0" fill="none" stroke="#e8d4b0" stroke-width="1.3"/>' +
501 '<path d="M-6 0 L-6 2 M-3 0 L-3 2 M3 0 L3 2 M6 0 L6 2" stroke="#e8a63c" stroke-width="1.6"/>' +
502 '<g><animateTransform attributeName="transform" type="scale" values="1 1;1 1;.15 1;.15 1;1 1;1 1" keyTimes="0;.3;.34;.5;.54;1" dur="7s" repeatCount="indefinite"/>' +
503 '<circle cx="0" cy="-26" r="8.4" fill="#9a7a56"/><path d="M-7 -32 l-2 -6 l5 3 M7 -32 l2 -6 l-5 3" fill="#9a7a56"/>' +
504 '<circle cx="-3.6" cy="-26" r="3.6" fill="#fff"/><circle cx="3.6" cy="-26" r="3.6" fill="#fff"/><circle cx="-3.6" cy="-26" r="1.8" fill="#f2c94c"/><circle cx="3.6" cy="-26" r="1.8" fill="#f2c94c"/>' +
505 '<circle cx="-3.6" cy="-26" r=".9" fill="#111"/><circle cx="3.6" cy="-26" r=".9" fill="#111"/><path d="M0 -24 l-1.6 3 l3.2 0Z" fill="#f08a2f"/></g>'
506
507const lg = (x: number, k: number, color: string) =>
508 `<g><animateTransform attributeName="transform" type="rotate" values="${k ? -24 : 24} ${x} -8;${k ? 24 : -24} ${x} -8;${k ? -24 : 24} ${x} -8" dur=".9s" repeatCount="indefinite"/><rect x="${x - 2.2}" y="-9" width="4.4" height="11" rx="2.2" fill="${color}"/></g>`
509
510const iguana = () =>
511 '<path d="M-14 -7 q-18 -2 -34 4 q-6 3 -12 2" fill="none" stroke="#5fae4a" stroke-width="5" stroke-linecap="round"/><path d="M-26 -5 l0 -3 M-36 -3 l0 -3 M-46 -1 l0 -3" stroke="#3f7a30" stroke-width="2"/>' +
512 lg(-8, 0, '#5fae4a') + lg(12, 0, '#5fae4a') +
513 '<ellipse cx="0" cy="-8" rx="17" ry="6.6" fill="#6fbf55"/><path d="M-12 -14 l2 -4 l2 4 l2 -4 l2 4 l2 -4 l2 4 l2 -4 l2 4 l2 -4 l2 4" fill="#3f7a30"/>' +
514 lg(-14, 1, '#5fae4a') + lg(8, 1, '#5fae4a') +
515 '<ellipse cx="20" cy="-9" rx="7.4" ry="5" fill="#7ccf62"/><circle cx="21" cy="-11.4" r="1.3" fill="#1c2430"/><path d="M18 -4 q3 7 6 0Z" fill="#e8a63c"/><circle cx="23" cy="-8.4" r=".8" fill="#3f7a30"/>'
516
517const coati = () =>
518 '<path d="M-17 -10 Q-34 -14 -30 -32 Q-27 -42 -20 -36" fill="none" stroke="#8a5a3a" stroke-width="6" stroke-linecap="round"/>' +
519 '<path d="M-24 -14 l5 -4 M-28 -22 l6 -2 M-29 -30 l6 0 M-25 -37 l5 3" stroke="#2a1a10" stroke-width="2.4" stroke-linecap="round"/>' +
520 lg(-12, 0, '#6b4528') + lg(10, 0, '#6b4528') +
521 '<ellipse cx="0" cy="-12" rx="18" ry="6.6" fill="#8a5a3a"/><ellipse cx="0" cy="-9" rx="14" ry="3" fill="#c9a578" opacity=".7"/>' +
522 lg(-8, 1, '#6b4528') + lg(14, 1, '#6b4528') +
523 '<ellipse cx="22" cy="-13" rx="7" ry="5" fill="#8a5a3a"/><path d="M26 -15 L36 -11 L26 -9Z" fill="#a8764c"/><circle cx="36" cy="-11" r="1.8" fill="#fff"/><circle cx="23" cy="-15" r="1.3" fill="#111"/><circle cx="20" cy="-18" r="2" fill="#6b4528"/>'
524
525const wrap = (kind: number, begin: string, body: string) =>
526 `<g data-visitor="${kind}" display="none"><animate attributeName="display" values="inline;inline" calcMode="discrete" dur="${LIFE}s" begin="${begin}"/>` +
527 `<g opacity="0"><animate attributeName="opacity" values="0;1;1;0" keyTimes="0;.05;.93;1" dur="${LIFE}s" begin="${begin}" fill="freeze"/>${body}</g></g>`
528
529const limbsInView = (y0: number, view: number) => LIMBS.filter(l => l.y > y0 + 50 && l.y < y0 + view - 20)
530
531const visitorSvg = (v: Slot, starts: number[], y0: number, view: number, limbAt = -1) => {
532 // every time it is due to appear, shifted by `lag` seconds
533 const B = (lag = 0) => starts.map(t => `${n(t + lag)}s`).join(';')
534 const r = (k: number) => rnd(v.seed + k)
535 const dir: 1 | -1 = r(1) < 0.5 ? 1 : -1
536 const yFly = y0 + 70 + r(2) * Math.max(80, view * 0.55)
537 // a limb inside the view, if there is one
538 const seen = limbsInView(y0, view)
539 const spot = seen.length ? seen[limbAt >= 0 ? limbAt % seen.length : Math.floor(r(3) * seen.length) % seen.length] : undefined
540 const dur = LIFE - 1
541
542 switch (v.kind) {
543 case 0:
544 return wrap(0, B(), flyer(toucan(), yFly, dir, dur, B()))
545 case 1:
546 return wrap(
547 1,
548 B(),
549 flyer(parrot('#e23b3b', '#2f7fd0'), yFly, dir, dur, B()) + flyer(parrot('#f2c94c', '#2fa58a'), yFly + 22, dir, dur, B(-0.7)),
550 )
551 case 2: {
552 const ax = spot ? (spot.side === -1 ? spot.x1 : spot.x0) : 40 + r(4) * 320
553 const ay = spot ? spot.y : y0 + 20
554 const L = 90 + r(5) * 40
555
556 return wrap(
557 2,
558 B(),
559 `<g transform="translate(${n(ax)} ${n(ay)})"><g><animateTransform attributeName="transform" type="rotate" values="-42 0 0;42 0 0;-42 0 0" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="4.4s" begin="${B()}" repeatCount="indefinite"/>` +
560 `<path d="M0 0 V${n(L)}" stroke="#2a7d3d" stroke-width="3"/><g transform="translate(0 ${n(L)})">${monkeyHang()}</g></g></g>`,
561 )
562 }
563 case 3: {
564 const l = spot ?? LIMBS[2]
565 const a = l.x0 + 6
566 const z = l.x1 - 6
567 const [s, e, f] = dir === 1 ? [a, z, 1] : [z, a, -1]
568 const mid = s + (e - s) * 0.55
569
570 return wrap(
571 3,
572 B(),
573 `<g><animateTransform attributeName="transform" type="translate" values="${n(s)} ${l.y - 6};${n(mid)} ${l.y - 6};${n(mid)} ${l.y - 6};${n(e)} ${l.y - 6}" keyTimes="0;.45;.66;1" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${f} 1)">${jaguar()}</g></g>`,
574 )
575 }
576 case 4: {
577 const x0 = 100 + r(6) * 70
578 const x1 = x0 + 90 + r(7) * 40
579 const [s, e, f] = dir === 1 ? [x0, x1, 1] : [x1, x0, -1]
580 const id = `cc${v.id}`
581
582 return wrap(
583 4,
584 B(),
585 `<clipPath id="${id}"><rect x="-90" y="-28" width="200" height="28"/></clipPath>` +
586 `<g><animateTransform attributeName="transform" type="translate" values="${n(s)} 1382;${n(e)} 1386" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
587 `<g clip-path="url(#${id})"><g transform="scale(${1.3 * f} 1.3)">${croc(3)}</g></g>` +
588 '<ellipse cx="0" cy="1" rx="52" ry="5" fill="none" stroke="#fff" stroke-width="1.2" opacity=".5"/></g>',
589 )
590 }
591 case 5: {
592 const order = dir === 1 ? [0, 1, 2, 3, 4] : [4, 3, 2, 1, 0]
593 const pts = order.slice(0, 4).map(i => PADS[i])
594 let path = `M${pts[0][0]} ${pts[0][1] - 4}`
595 for (let i = 1; i < pts.length; i++) {
596 const [px, py] = pts[i - 1]
597 const [qx, qy] = pts[i]
598 path += ` Q${n((px + qx) / 2)} ${n(Math.min(py, qy) - 38)} ${qx} ${qy - 4}`
599 }
600
601 return wrap(5, B(), `<g><animateMotion path="${path}" dur="${dur - 2}s" begin="${B()}" fill="freeze" calcMode="spline" keyPoints="0;1" keyTimes="0;1" keySplines=".4 0 .6 1"/>${frog()}</g>`)
602 }
603 case 6:
604 return wrap(
605 6,
606 B(),
607 [0, 1, 2, 3, 4]
608 .map(k => {
609 const y = yFly + k * 16 - 30
610 const path = `M-20 ${n(y)} C80 ${n(y - 60)} 160 ${n(y + 60)} 250 ${n(y - 20)} S380 ${n(y + 40)} 430 ${n(y - 30)}`
611
612 return `<g><animateMotion path="${path}" dur="${12 + k}s" begin="${B(- k * 0.5)}" fill="freeze"/>${butterfly(pick(['#3f9be0', '#f08a2f', '#c05fd0', '#f2c94c', '#e24a8d'], k + v.seed))}</g>`
613 })
614 .join(''),
615 )
616 case 8:
617 return wrap(
618 8,
619 B(),
620 [0, 1, 2, 3, 4]
621 .map(k => {
622 const y = yFly + k * 12 - 24
623 const path = `M-30 ${n(y)} C60 ${n(y - 90)} 120 ${n(y + 90)} 200 ${n(y - 30)} S330 ${n(y + 70)} 430 ${n(y - 20)}`
624
625 return `<g><animateMotion path="${path}" dur="${10 + (k % 3)}s" begin="${B(- k * 0.45)}" fill="freeze"/>${bat()}</g>`
626 })
627 .join(''),
628 )
629 case 9: {
630 const l = spot ?? LIMBS[0]
631 const s0 = l.x0 + 24 + r(6) * 30
632
633 return wrap(
634 9,
635 B(),
636 `<g><animateTransform attributeName="transform" type="translate" values="${n(s0)} ${l.y - 8};${n(s0 + 50 * dir)} ${l.y - 8}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)">${chameleon()}</g></g>`,
637 )
638 }
639 case 10: {
640 const ax = spot ? (spot.side === -1 ? spot.x1 : spot.x0) : 40 + r(4) * 320
641 const ay = spot ? spot.y : y0 + 20
642
643 return wrap(
644 10,
645 B(),
646 `<g transform="translate(${n(ax)} ${n(ay)})"><g><animateTransform attributeName="transform" type="rotate" values="-10 0 0;10 0 0;-10 0 0" keyTimes="0;.5;1" calcMode="spline" keySplines="${EASE};${EASE}" dur="3.6s" begin="${B()}" repeatCount="indefinite"/>${snake()}</g></g>`,
647 )
648 }
649 case 11: {
650 const pt = (k: number): Pt => [60 + r(k) * 280, y0 + 110 + r(k + 1) * Math.max(60, view * 0.55)]
651 const [p1, p2, p3] = [pt(20), pt(22), pt(24)]
652 const val = (p: Pt) => `${n(p[0])} ${n(p[1])}`
653
654 return wrap(
655 11,
656 B(),
657 `<g><animateTransform attributeName="transform" type="translate" values="-30 ${n(p1[1])};${val(p1)};${val(p1)};${val(p2)};${val(p2)};${val(p3)};${val(p3)};440 ${n(p3[1] - 30)}" keyTimes="0;.12;.28;.34;.5;.56;.72;1" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
658 `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -3;0 0;0 2;0 0" dur=".7s" repeatCount="indefinite"/>${hummingbird()}</g></g>`,
659 )
660 }
661 case 12: {
662 const hx = 100 + r(8) * 200
663
664 return wrap(
665 12,
666 B(),
667 `<g transform="translate(${n(hx)} 1394)"><g><animateTransform attributeName="transform" type="translate" values="0 0;${dir * 18} 0" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)">${heron(B())}</g></g></g>`,
668 )
669 }
670 case 13: {
671 const y = 1400 + r(9) * 200
672 const [s, e, f] = dir === 1 ? [50, 340, 1] : [340, 50, -1]
673
674 return wrap(13, B(), `<g><animateTransform attributeName="transform" type="translate" values="${s} ${n(y)};${e} ${n(y + 8)}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${f} 1)">${turtle()}</g></g>`)
675 }
676 case 14:
677 return wrap(
678 14,
679 B(),
680 Array.from({ length: 9 }, (_, k) => {
681 const y = 1400 + r(10) * 160 + (k % 3) * 12
682 const [s, e] = dir === 1 ? [40, 360] : [360, 40]
683 const path = `M${s} ${n(y)} C${n(s + (e - s) * 0.3)} ${n(y - 26 + k * 3)} ${n(s + (e - s) * 0.6)} ${n(y + 24 - k * 3)} ${e} ${n(y - 6)}`
684
685 return `<g><animateMotion path="${path}" dur="${dur - 1}s" begin="${B(- (k % 4) * 0.35 - Math.floor(k / 4) * 0.2)}" fill="freeze"/><g transform="scale(${dir} 1)">${neon()}</g></g>`
686 }).join(''),
687 )
688 case 15:
689 return wrap(
690 15,
691 B(),
692 Array.from({ length: 4 }, (_, k) => {
693 const y = 1410 + r(11) * 150 + k * 14
694 const [s, e] = dir === 1 ? [40, 360] : [360, 40]
695 const path = `M${s} ${n(y)} L${e} ${n(y + 10)}`
696
697 return `<g><animateMotion path="${path}" dur="${dur - 1}s" begin="${B(- k * 0.5)}" fill="freeze" calcMode="linear" keyPoints="0;.3;.3;.62;.62;1" keyTimes="0;.15;.3;.55;.7;1"/><g transform="scale(${dir} 1)">${piranha()}</g></g>`
698 }).join(''),
699 )
700 case 16: {
701 const [s, e] = dir === 1 ? [40, 360] : [360, 40]
702 const mid = (s + e) / 2
703 const path = `M${s} 1384 Q${n((s + mid) / 2)} 1352 ${n(mid)} 1384 T${e} 1384`
704
705 return wrap(16, B(), `<g><animateMotion path="${path}" rotate="auto" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(1 ${dir})">${otter()}</g></g>`)
706 }
707 case 17: {
708 const y = 1480 + r(12) * 150
709 const [s, e, f] = dir === 1 ? [50, 340, 1] : [340, 50, -1]
710
711 return wrap(17, B(), `<g><animateTransform attributeName="transform" type="translate" values="${s} ${y};${e} ${y - 10}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${f} 1)">${ray()}</g></g>`)
712 }
713 case 18: {
714 const pt = (k: number): Pt => [50 + r(k) * 300, Math.max(y0 + 60, 1230 + r(k + 1) * 150)]
715 const [p1, p2, p3, p4] = [pt(30), pt(32), pt(34), pt(36)]
716 const val = (p: Pt) => `${n(p[0])} ${n(p[1])}`
717
718 return wrap(
719 18,
720 B(),
721 `<g><animateTransform attributeName="transform" type="translate" values="-30 ${n(p1[1])};${val(p1)};${val(p1)};${val(p2)};${val(p2)};${val(p3)};${val(p3)};${val(p4)};440 ${n(p4[1])}" keyTimes="0;.1;.2;.26;.4;.46;.6;.66;1" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
722 `<g><animateTransform attributeName="transform" type="translate" values="0 0;0 -2;0 0;0 2;0 0" dur=".5s" repeatCount="indefinite"/>${dragonfly()}</g></g>`,
723 )
724 }
725 case 19: {
726 const l = spot ?? LIMBS[1]
727 const [s0, e0, f] = dir === 1 ? [l.x0 + 10, l.x1 - 10, 1] : [l.x1 - 10, l.x0 + 10, -1]
728
729 return wrap(
730 19,
731 B(),
732 Array.from({ length: 6 }, (_, k) => `<g><animateTransform attributeName="transform" type="translate" values="${s0} ${l.y - 7};${e0} ${l.y - 7}" dur="${dur}s" begin="${B(- k * 0.8)}" fill="freeze"/><g transform="scale(${f} 1)">${ant()}</g></g>`).join(''),
733 )
734 }
735 case 20: {
736 const l = spot ?? LIMBS[1]
737 const f = l.side === -1 ? 1 : -1
738 const tipx = l.side === -1 ? l.x1 : l.x0
739 const tx = tipx - f * 14
740 const py = l.y - 12
741 const dx = Math.min(350, Math.max(50, tx + f * 40))
742 const path = `M${n(tx)} ${n(py)} C${n(tx + f * 20)} ${n(py + 120)} ${n(dx - f * 10)} ${n(1352 - 260)} ${n(dx)} 1352 C${n(dx + f * 8)} ${n(1352 - 100)} ${n(tx + f * 30)} ${n(py + 80)} ${n(tx)} ${n(py)}`
743
744 return wrap(
745 20,
746 B(),
747 `<g><animateMotion path="${path}" dur="${dur}s" begin="${B()}" fill="freeze" calcMode="linear" keyPoints="0;0;.5;.5;1;1" keyTimes="0;.22;.45;.55;.8;1"/><g transform="scale(${f} 1)">${kingfisher()}</g></g>` +
748 `<ellipse cx="${n(dx)}" cy="1352" rx="4" ry="1.6" fill="none" stroke="#fff" stroke-width="1.4" opacity="0"><animate attributeName="opacity" values="0;0;.9;0;0" keyTimes="0;.45;.5;.66;1" dur="${dur}s" begin="${B()}" fill="freeze"/><animateTransform attributeName="transform" type="translate" values="0 0;0 0;0 0" dur="${dur}s" begin="${B()}"/><animate attributeName="rx" values="4;4;4;22;22" keyTimes="0;.45;.5;.66;1" dur="${dur}s" begin="${B()}" fill="freeze"/></ellipse>`,
749 )
750 }
751 case 21: {
752 const l = spot ?? LIMBS[0]
753 const f = l.side === -1 ? 1 : -1
754 const tipx = l.side === -1 ? l.x1 : l.x0
755
756 return wrap(21, B(), `<g transform="translate(${n(tipx - f * 24)} ${l.y - 8})"><g transform="scale(${f} 1)">${owl()}</g></g>`)
757 }
758 case 22: {
759 const l = spot ?? LIMBS[0]
760 const s0 = l.x0 + 40 + r(6) * 20
761
762 return wrap(
763 22,
764 B(),
765 `<g><animateTransform attributeName="transform" type="translate" values="${n(s0)} ${l.y - 6};${n(s0 + 60 * dir)} ${l.y - 6}" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${dir} 1)">${iguana()}</g></g>`,
766 )
767 }
768 case 23: {
769 const l = spot ?? LIMBS[0]
770 const a = l.x0 + 30
771 const z = l.x1 - 6
772 const [s0, e0, f] = dir === 1 ? [a, z, 1] : [z, a, -1]
773 const mid = s0 + (e0 - s0) * 0.5
774
775 return wrap(
776 23,
777 B(),
778 `<g><animateTransform attributeName="transform" type="translate" values="${n(s0)} ${l.y - 6};${n(mid)} ${l.y - 6};${n(mid)} ${l.y - 6};${n(e0)} ${l.y - 6}" keyTimes="0;.4;.6;1" dur="${dur}s" begin="${B()}" fill="freeze"/><g transform="scale(${f} 1)">${coati()}</g></g>`,
779 )
780 }
781 default: {
782 const l = spot ?? LIMBS[1]
783 const near = l.side === -1 ? l.x1 - 12 : l.x0 + 12
784 const far = l.side === -1 ? l.x1 - 78 : l.x0 + 78
785
786 return wrap(
787 7,
788 B(),
789 `<g><animateTransform attributeName="transform" type="translate" values="${n(near)} ${l.y + 4};${n(far)} ${l.y + 4}" dur="${dur}s" begin="${B()}" fill="freeze"/>` +
790 `<g><animateTransform attributeName="transform" type="rotate" values="-3 0 0;3 0 0;-3 0 0" dur="4.5s" repeatCount="indefinite"/>${sloth()}</g></g>`,
791 )
792 }
793 }
794}
795
796// ---- the whole picture ---------------------------------------------------------------------------------
797// Looping motion shifts back by the time already elapsed, so redrawing the picture never makes it jump.
798// Tags marked data-once (the sky darkening for work) are left alone and play from the start.
799const retime = (svg: string, elapsed: number) =>
800 svg.replace(/<(animate|animateTransform|animateMotion)\b[^>]*>/g, tag => {
801 if (tag.includes('data-once')) {
802 return tag
803 }
804 const found = / begin="(-?[\d.]+)s"/.exec(tag)
805 const shifted = ` begin="${n((found ? parseFloat(found[1]) : 0) - elapsed)}s"`
806
807 return found ? tag.replace(found[0], shifted) : tag.replace(/^<(\w+)/, `<$1${shifted}`)
808 })
809
810// How far the night has come: [from, to]. A rebuilt picture carries on from where the fade already is.
811const nightSpan = (w: Falls, now: number): [number, number] => {
812 const since = (now - w.startedAt) / 1000
813 const sinceEnd = (now - (w.endedAt ?? 0)) / 1000
814 if (w.working) {
815 return [Math.min(1, since / 1.4), 1]
816 }
817
818 return w.endedAt > 0 && sinceEnd < 1.4 ? [1 - sinceEnd / 1.4, 0] : [0, 0]
819}
820
821const buildScene = (w: Falls, elapsed: number, vh: number, now: number) => {
822 const view = Math.min(H, Math.max(vh, 240))
823 const y0 = H - view
824 const [a0, a1] = nightSpan(w, now)
825
826 const defs =
827 '<defs>' +
828 '<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8fd3e6"/><stop offset="1" stop-color="#f6efc4"/></linearGradient>' +
829 '<linearGradient id="haze" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e8f6ef" stop-opacity="0"/><stop offset="1" stop-color="#e8f6ef" stop-opacity=".75"/></linearGradient>' +
830 '<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#5f8a5c"/><stop offset=".55" stop-color="#456a4a"/><stop offset="1" stop-color="#2c4a3a"/></linearGradient>' +
831 '<linearGradient id="fallw" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#e6fbfb"/><stop offset=".5" stop-color="#8fe0e0"/><stop offset="1" stop-color="#3fb8c8"/></linearGradient>' +
832 '<linearGradient id="basinw" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7fe6dc"/><stop offset="1" stop-color="#2aa0a8"/></linearGradient>' +
833 '<linearGradient id="pool" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#46d0bd"/><stop offset=".5" stop-color="#1f8f94"/><stop offset="1" stop-color="#0b4b57"/></linearGradient>' +
834 '</defs>'
835
836 const backA =
837 SKYLINE +
838 CANOPY +
839 WALL +
840 VINES +
841 LIMBS.map((l, k) => limb(l, k)).join('') +
842 BASINS.map(([cx, cy, rx], k) => basin(cx, cy, rx, k)).join('') +
843 falls(w.working) +
844 // where each fall leaves its terrace: a lip of foam
845 FALLS.map(([x1, y1, , , w1]) => `<ellipse cx="${x1}" cy="${y1}" rx="${n(w1 / 2 + 4)}" ry="4" fill="#fff" opacity=".7"/>`).join('') +
846 leapers() +
847 FALLS.map(([, , x2, y2], k) => mist(x2, y2, k, w.working)).join('') +
848 BANKS +
849 POOL_BASE
850 const backB = POOL_TOP + mist(198, POOL_Y, 5, w.working) + mist(176, POOL_Y, 6, w.working) + GROWTH
851
852 // Every kind visits at random times for the next 25 minutes. The picture is not redrawn when one arrives,
853 // so nothing flickers; one animal is due at once, so the picture is never empty.
854 const slots: { v: Slot; starts: number[]; limbAt: number }[] = []
855 const nLimbs = Math.max(1, limbsInView(y0, view).length)
856 for (let kind = 0; kind < KINDS; kind++) {
857 for (let k = 0; k < SLOTS; k++) {
858 const starts: number[] = []
859 for (let t = Math.random() * 110; t < HORIZON; t += 80 + Math.random() * 50) starts.push(t)
860 const limbAt = LIMB_KINDS.includes(kind) ? Math.floor(Math.random() * nLimbs) : -1
861 slots.push({ v: { id: kind * SLOTS + k, kind, seed: Math.floor(Math.random() * 1_000_000) }, starts, limbAt })
862 }
863 }
864 slots[Math.floor(Math.random() * slots.length)].starts.unshift(0.4)
865
866 // A limb holds one visitor at a time: walk through every visit in order and drop any that would
867 // overlap an earlier one on the same limb.
868 const freeAt: number[] = Array(nLimbs).fill(-1)
869 slots
870 .flatMap((x, i) => (x.limbAt >= 0 ? x.starts.map(t => ({ i, t })) : []))
871 .sort((a, b) => a.t - b.t)
872 .forEach(({ i, t }) => {
873 const x = slots[i]
874 if (t < freeAt[x.limbAt]) {
875 x.starts = x.starts.filter(s => s !== t)
876 } else {
877 freeAt[x.limbAt] = t + LIFE + 1
878 }
879 })
880 const drawn = slots
881 .filter(x => x.starts.length > 0)
882 .map(x => ({ kind: x.v.kind, svg: visitorSvg(x.v, x.starts, y0, view, x.limbAt) }))
883 const under = drawn.filter(x => isUnder(x.kind)).map(x => x.svg).join('')
884 const air = drawn.filter(x => !isUnder(x.kind)).map(x => x.svg).join('')
885
886 const front = RAYS + FRAME + nightLayer(a0, a1)
887
888 return (
889 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 ${n(y0)} ${W} ${n(view)}" width="${W}" height="${n(view)}" preserveAspectRatio="xMidYMax slice">${defs}` +
890 `<rect x="0" y="${n(y0)}" width="${W}" height="${n(view)}" fill="#12241c"/>` +
891 retime(backA, elapsed) +
892 under +
893 retime(backB, elapsed) +
894 air +
895 retime(front, elapsed) +
896 '</svg>'
897 )
898}
899// ---scene-end
900
901const TERMINAL_ART: [string, string][] = [
902 [' ♣ ~~~ ┃┃ ~~~ ♣', '#43a863'],
903 [' ▟▙ ┃┃┃ ▟▙', '#8a929a'],
904 [' ♣ ▐ ┃┃┃ ▌ ♣', '#8fd8e4'],
905 [' ▟▙ ┃┃┃┃ ▟▙', '#8a929a'],
906 [' ≈≈≈≈≈≈ ◎ ◎ ≈≈≈≈≈≈', '#46d0bd'],
907]
908
909const clock = (ms: number) => {
910 const s = Math.max(0, Math.floor(ms / 1000))
911
912 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
913}
914
915const label = (w: Falls, now: number, T: Words) => (w.working ? T.working(clock(now - w.startedAt), w.tools) : T.idle)
916
917const jungle = atom(
918 { plugin: 'jungle-falls', key: 'calm' } as const,
919 { working: false, prev: false, tools: 0, startedAt: 0, endedAt: 0, tick: 0 } as Falls,
920)
921
922// The picture is built again only when the state it draws changes; the same text keeps it running.
923let t0 = 0
924let cache = { key: '', svg: '' }
925
926export const register: Register = (on, options) => {
927 // `language` is a field of the plugin's userConfig: 'en' (default) or 'ko'
928 const T: Words = STRINGS[options.language === 'ko' ? 'ko' : 'en']
929
930 on('session.start', async ($, e, next) => {
931 t0 = await $.clock.now()
932 await $.command.register({
933 name: 'falls',
934 description: 'Open the jungle waterfall pane',
935 })
936 // Wakes the pane each second while Claude works, for the stopwatch in its top line.
937 $.clock.every(1000, () => {
938 void read($, jungle)
939 .then(r => (r.working ? update($, jungle, v => ({ ...v, tick: v.tick + 1 })) : undefined))
940 .catch(() => {})
941 })
942
943 return next(e)
944 })
945
946 on('command.run', { command: 'falls' }, async $ => {
947 await $.ui.open({ id: PANE, title: T.title })
948
949 return { text: T.opened }
950 })
951
952 // The main loop's turns only: a subagent's run is not Claude at work.
953 on('turn.start', async ($, e, next) => {
954 if (e.agentId === undefined) {
955 const now = await $.clock.now()
956 await update($, jungle, v => ({ ...v, prev: false, working: true, tools: 0, startedAt: now }))
957 }
958
959 return next(e)
960 }).catch(($, e, next) => next(e))
961
962 // Each finished turn draws the picture afresh, with an animal due straight away.
963 on('turn.complete', async ($, e, next) => {
964 if (e.agentId === undefined) {
965 const now = await $.clock.now()
966 await update($, jungle, v => ({ ...v, prev: true, working: false, endedAt: now }))
967 }
968
969 return next(e)
970 }).catch(($, e, next) => next(e))
971
972 on('tool.call', async ($, e, next) => {
973 await update($, jungle, v => ({ ...v, tools: v.tools + 1 }))
974
975 return next(e)
976 }).catch(($, e, next) => next(e))
977
978 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
979 const els = $.ui.resolve(e)
980 const w = await read($, jungle)
981 const now = await $.clock.now()
982 if (t0 === 0) t0 = now
983 const cols = e.props?.bodyColumns ?? 40
984 const rows = e.props?.scroll?.bodyRows ?? 20
985 const line = label(w, now, T)
986
987 if (e.surface !== 'terminal') {
988 const { Box, Text, Svg } = els as ElementTable<'desktop'>
989 // The pane's px: its cells over the code font's advance (about 7.8 px) and line height
990 // (about 19.5 px). Short of the whole picture, the top of the jungle is cut, never the pool.
991 const width = Math.max(160, Math.round(cols * 7.8))
992 const full = Math.round((width * H) / W)
993 const tall = e.props?.scroll?.bodyRows ? Math.round((rows - 1) * 19.5) : full
994 const height = Math.min(full, tall)
995 const [n0, n1] = nightSpan(w, now)
996 const key = `${w.working}|${n0 > 0 && n0 < 1 ? 'f' : n0}|${n1}|${w.startedAt}|${w.endedAt}|${height}|${width}`
997 if (cache.key !== key) cache = { key, svg: buildScene(w, (now - t0) / 1000, (height * W) / width, now) }
998
999 return (
1000 <Box flexDirection="column" justifyContent="flex-end" minHeight={rows}>
1001 <Box justifyContent="center">
1002 <Text bold={w.working} dimColor={!w.working}>
1003 {line}
1004 </Text>
1005 </Box>
1006 <Svg source={cache.svg} alt={T.alt} width={width} height={height} isInteractive />
1007 </Box>
1008 )
1009 }
1010
1011 const { Box, Text } = els as ElementTable<'terminal'>
1012
1013 return (
1014 <Box flexDirection="column">
1015 {TERMINAL_ART.map(([row, color]) => (
1016 <Text color={color}>{row}</Text>
1017 ))}
1018 <Text>{line}</Text>
1019 <Text dimColor>{T.note}</Text>
1020 </Box>
1021 )
1022 })
1023}
1024types/index.d.ts 15 lines1export type Falls = {
2 working: boolean
3 prev: boolean
4 tools: number
5 startedAt: number
6 endedAt: number
7 tick: number
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'jungle-falls': { calm: Falls }
13 }
14}
15