nordic-skating / files / README.md
README.md
c90277fa 2026-10-09 Pavel Safronov | 1 | # Nordic Skating |
| 2 | ||
bcbc446b 2026-10-27 Pavel Safronov | 3 | **Live — <https://apps.fuzzyworld.net/nordic-skating/>** · [Source](https://code.fuzzyworld.net/nordic-skating/) |
c90277fa 2026-10-09 Pavel Safronov | 4 | |
| 5 | An endless skate across an endless lake of thin black ice, for a phone. The ice sings | |
| 6 | under the blades the way real black ice does, and its pitch tells you how thin it is. | |
| 7 | Leave it alone and it skates itself; steer, and you choose where the lake forks and | |
| 8 | how close to the thin ice you dare go. | |
| 9 | ||
| 10 | Inspired by National Geographic's | |
| 11 | [Hear the Otherworldly Sounds of Skating on Thin Ice](https://www.youtube.com/watch?v=v3O9vNi-dkA), | |
| 12 | in which Mårten Ajne skates a small lake outside Stockholm. | |
| 13 | ||
e5499331 2026-10-09 Pavel Safronov | 14 | <img src="docs/screenshots/landscape.png" alt="Black ice laced with white cracks, running between snowy banks of spruce to a foggy horizon" width="640" /> |
| 15 | ||
c90277fa 2026-10-09 Pavel Safronov | 16 | ## Status |
| 17 | ||
df2b8215 2026-10-10 Pavel Safronov | 18 | **Step 5 of 6, and a good deal since.** The lake runs on for ever between snowy, wooded |
| 19 | shores, forking every few kilometres, pinching to narrow passes, opening out until no | |
| 20 | shore can be seen, and dotted in places with islets of reeds to weave between. The skater skates it, and the ice sings under every stride at | |
2f7e86b7 2026-10-10 Pavel Safronov | 21 | a pitch set by how thick it is there. Thin ice is black, sings high and cracks under |
| 22 | you. Nobody falls through: how thick the ice is decides only how it sounds. | |
| 23 | ||
| 24 | Every visit is to a different lake, one of a million, by day, at dusk, at night under | |
0045befd 2026-10-10 Pavel Safronov | 25 | the Milky Way, or under the northern lights; in clear air, in falling snow, or in a |
| 26 | blizzard; with, some nights, a comet, and some days an aeroplane drawing its trail. | |
2f7e86b7 2026-10-10 Pavel Safronov | 27 | |
| 28 | <img src="docs/screenshots/aurora.png" alt="The lake at night under green curtains of aurora" width="640" /> | |
c90277fa 2026-10-09 Pavel Safronov | 29 | |
| 30 | 1. ✅ Scaffold: build, tests, deploy to GitLab Pages. | |
e5499331 2026-10-09 Pavel Safronov | 31 | 2. ✅ An endless lake, skated on autopilot, with steering. |
9ff4c3fc 2026-10-09 Pavel Safronov | 32 | 3. ✅ Measure the reference video, then synthesize the ice sound from the ice's thickness. |
646446cd 2026-10-09 Pavel Safronov | 33 | 4. ✅ Ice zones that fall out of the physics, and falling through. |
1cb1fefc 2026-10-09 Pavel Safronov | 34 | 5. ✅ Forks in the lake, narrow passes, and stretches of open sea. |
e3bfc714 2026-10-10 Pavel Safronov | 35 | 6. Game controller, then multiplayer. Postponed: [PLAN.md](PLAN.md) has what comes first. |
c90277fa 2026-10-09 Pavel Safronov | 36 | |
e5499331 2026-10-09 Pavel Safronov | 37 | ## Controls |
| 38 | ||
6e58e276 2026-10-10 Pavel Safronov | 39 | The button at the top right turns the sound on and off, as does the M key, and shows |
3f3d6bce 2026-10-11 Pavel Safronov | 40 | which it is: a speaker, or a speaker crossed out. Sound cannot start by itself: a |
6e58e276 2026-10-10 Pavel Safronov | 41 | browser lets a page make sound only after it has been touched, so until then the |
3f3d6bce 2026-10-11 Pavel Safronov | 42 | speaker is crossed out, and the first tap or key anywhere starts it. Turning it off is remembered for next time. On an iPhone the |
95f8bb94 2026-10-09 Pavel Safronov | 43 | ring/silent switch also silences it. |
9ff4c3fc 2026-10-09 Pavel Safronov | 44 | |
1cb1fefc 2026-10-09 Pavel Safronov | 45 | Do nothing and the skater follows the middle of the lake, round every bend and down |
| 46 | one side of every fork, for as long as you leave it. | |
e5499331 2026-10-09 Pavel Safronov | 47 | |
| 48 | | To | Touch or mouse | Keyboard | | |
| 49 | |---|---|---| | |
fe6b506c 2026-10-10 Pavel Safronov | 50 | | Steer left or right | Drag sideways from wherever you touch; or tilt the phone, with Tilt on | ← → or A D | |
| 51 | | Tilt to steer, on or off | The Tilt button at the bottom left (phones and tablets only) | | | |
df2b8215 2026-10-10 Pavel Safronov | 52 | | Turn right round, or circle an islet | Hold the steer over | the same | |
b2c4db9f 2026-10-10 Pavel Safronov | 53 | | Choose a way at a fork, or a side of an island | A touch towards it on the way up | the same | |
95f8bb94 2026-10-09 Pavel Safronov | 54 | | Sound on or off | The button at the top right | M | |
3f3d6bce 2026-10-11 Pavel Safronov | 55 | | Go faster or slower | The pace button at the bottom right: each press is the next pace up, and from the fastest back to the slowest | ↑ ↓ or W S | |
2f7e86b7 2026-10-10 Pavel Safronov | 56 | | Go to another lake | Randomize, at the top left | R | |
| 57 | ||
| 58 | At the top left, **About** opens a box that explains the app and its controls, and | |
| 59 | **Settings** one that changes it. The lake waits behind either, skater and sound both, | |
| 60 | until it is closed: by its button, by Escape, or by a touch outside it. | |
| 61 | ||
| 62 | **Randomize** goes to another lake, picked at random, at once and without reloading | |
| 63 | the page. The browser's Back button returns to the lake you left, and Forward goes on | |
| 64 | again. | |
| 65 | ||
8b498d51 2026-10-10 Pavel Safronov | 66 | ## Install |
| 67 | ||
| 68 | The app can be put on a phone's home screen, where it has its own icon, opens full | |
| 69 | screen without the browser's bars, and works with no network. | |
| 70 | ||
| 71 | - **Android (Chrome):** the menu, then "Add to Home screen" or "Install app". | |
| 72 | - **iPhone (Safari):** the Share button, then "Add to Home Screen". | |
| 73 | ||
| 74 | Nothing is fetched once it is installed: the whole app is kept on the phone and the | |
| 75 | lake is computed. A new version is taken up the next time it is opened with a network. | |
| 76 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 77 | ## Settings |
| 78 | ||
| 79 | Each takes effect as it is touched, behind the box, and is remembered for next time. | |
| 80 | ||
| 81 | | Setting | Choices | To begin with | | |
| 82 | |---|---|---| | |
| 83 | | Time of day | Surprise me, Day, Dusk, Night, Aurora | Surprise me: whichever the lake comes with | | |
0045befd 2026-10-10 Pavel Safronov | 84 | | Weather | Surprise me, Clear, Snow, Blizzard | Surprise me: whichever the lake comes with | |
3f3d6bce 2026-10-11 Pavel Safronov | 85 | | Pace | Meander, Cruise, Brisk, Racing, Olympic | Cruise | |
2f7e86b7 2026-10-10 Pavel Safronov | 86 | | Impossible ice | on or off | off | |
| 87 | | Lake | Another lake (the same as Randomize); a link back to this one | a different lake every visit | | |
| 88 | ||
| 89 | <img src="docs/screenshots/settings.png" alt="The settings box at the foot of the screen, under a night sky" width="320" /> | |
| 90 | ||
9f2e2509 2026-10-10 Pavel Safronov | 91 | ### Tilt to steer |
| 92 | ||
3f3d6bce 2026-10-11 Pavel Safronov | 93 | On a phone or tablet there is a **Tilt** button at the bottom left, showing a phone held |
| 94 | in a hand when tilt is on and a phone by itself when it is off; anywhere else it is not | |
| 95 | shown. Turned on, which is remembered, the phone is leaned | |
9f2e2509 2026-10-10 Pavel Safronov | 96 | left or right like a wheel, about the line the skater looks along: more lean, tighter |
| 97 | turn, full steering at 28°. Within 4° of level counts as level, since nobody holds a | |
| 98 | phone dead level, and held level the skater skates itself. It works held upright or on | |
| 99 | its side, and dragging still works alongside it. | |
| 100 | ||
fe6b506c 2026-10-10 Pavel Safronov | 101 | While tilt is on the screen is stopped from turning with the phone, since leaning a |
| 102 | phone over is exactly what makes its screen flip. A browser does that only for a page | |
| 103 | that fills the screen: the installed app already does, and in a browser tab the page | |
| 104 | goes full screen while tilt is on and comes out of it when tilt is turned off. An | |
| 105 | iPhone will do neither for a web page, so there the phone's own rotation lock, in | |
| 106 | Control Centre, is the way. | |
| 107 | ||
9f2e2509 2026-10-10 Pavel Safronov | 108 | An iPhone asks leave for this, each visit, at the first touch. If it has been refused |
df03cd8b 2026-10-10 Pavel Safronov | 109 | it is not asked again, and the app says so where its hint is: Settings, Safari, Motion & |
| 110 | Orientation Access turns it back on. | |
9f2e2509 2026-10-10 Pavel Safronov | 111 | Browsers give a page the phone's tilt only over HTTPS, which the live site is and |
| 112 | `npm run dev` on a phone is not, so it cannot be tried on a phone from the dev server. | |
| 113 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 114 | ### A different lake every visit |
| 115 | ||
| 116 | A lake is a number from 1 to 999,999, and everything about it follows from the number: | |
0045befd 2026-10-10 Pavel Safronov | 117 | its bends, widths and forks, its ice, its trees, the time of day and the weather it |
| 118 | comes with, which way its wind blows, and what is in its sky. | |
2f7e86b7 2026-10-10 Pavel Safronov | 119 | Opening the app picks a number, and so do Randomize and reloading the page: each is a |
| 120 | new lake under a new sky. Settings shows the number and a link to it, and `?seed=1234` | |
| 121 | on the address asks for a lake by number, which is how to come back to one or send it | |
| 122 | to someone. | |
95f8bb94 2026-10-09 Pavel Safronov | 123 | |
2f7e86b7 2026-10-10 Pavel Safronov | 124 | Which lake a page is showing is kept with its place in the browser's history, not in |
| 125 | its address. That is what lets Back return to the lake that was there while a reload, | |
| 126 | which is someone asking for something new, gets a new one. Randomize takes any | |
| 127 | `?seed=` off the address, since it no longer says which lake this is. | |
e5499331 2026-10-09 Pavel Safronov | 128 | |
2f7e86b7 2026-10-10 Pavel Safronov | 129 | ### Times of day |
| 130 | ||
| 131 | | Time | The sky | How often a lake comes with it | | |
| 132 | |---|---|---| | |
| 133 | | Day | Flat, bright winter noon | 35% | | |
| 134 | | Dusk | An orange horizon under deepening blue, and the first stars | 20% | | |
| 135 | | Night | Moonless and clear: the Milky Way and thousands of stars | 27% | | |
| 136 | | Aurora | The same night under slow green curtains, violet where they reach highest | 18% | | |
| 137 | ||
| 138 | The sky is painted by a shader (`src/render/sky.ts`), and each time has its own light: | |
| 139 | the snow is orange at dusk, blue at night and faintly green under the aurora, and the | |
| 140 | milkiness of thick ice shows as much as there is light to show it. `?time=night` on the | |
| 141 | address asks for a time whatever the settings say. | |
| 142 | ||
| 143 | <img src="docs/screenshots/night.png" alt="The lake at night, the Milky Way overhead" width="640" /> | |
| 144 | ||
0045befd 2026-10-10 Pavel Safronov | 145 | ### What else is in the sky |
| 146 | ||
| 147 | - **Shooting stars**, at night and under the aurora: one every quarter of a minute or | |
| 148 | so, somewhere in the sky, gone in under a second. | |
| 149 | - **A comet**, over one night lake in three: ahead and low, with a tail. | |
| 150 | - **An aeroplane**, over two day or dusk lakes in three: a bright speck crossing the | |
| 151 | sky ahead in four or five minutes, drawing a trail that widens and fades behind it, | |
| 152 | white by day and pink at dusk. When it has gone, a while later another comes. | |
| 153 | ||
| 154 | <img src="docs/screenshots/comet.png" alt="A comet with a long tail over the lake at night" width="640" /> | |
| 155 | ||
| 156 | ### Weather | |
| 157 | ||
| 158 | | Weather | Snow | How far you can see | The sky | Wind | How often a lake comes with it | | |
| 159 | |---|---|---|---|---|---| | |
| 160 | | Clear | none | 600 m | as the time of day has it | none | 64% | | |
| 161 | | Snow | falling quietly | 420 m | mostly cloud | 0.5 to 3 m/s | 26% | | |
| 162 | | Blizzard | thick, and driven | 200 m | all cloud | 7 to 16 m/s, in gusts | 10% | | |
| 163 | ||
893cda77 2026-10-10 Pavel Safronov | 164 | **Snow that falls lies, in drifts** (`src/render/drifts.ts`): long pale streaks on the |
| 165 | ice, all running the way that lake's wind blows, a few of them under falling snow and | |
| 166 | many in a blizzard. A drift is the lake's bad ice. In the middle of one a skater is | |
| 167 | dragged at four times as hard as on ice and slows to about half speed; the ice under | |
| 168 | it does not ring to a strike or sing to the glide, and the blade shushes instead. Bare | |
| 169 | ice between the drifts is as fast and as loud as ever, so in a blizzard there is a | |
| 170 | reason to pick a way. | |
| 171 | ||
| 172 | Snow in the air is a box of flakes that stays round the skater (`src/render/snow.ts`): they fall, | |
0045befd 2026-10-10 Pavel Safronov | 173 | the wind carries them, and they stream past as the skater moves, which is most of what |
| 174 | makes a blizzard feel fast. Each lake's wind blows from a direction of its own. The wind | |
| 175 | can be heard, louder and higher in its gusts. Cloud hides the stars, the aurora and | |
| 176 | whatever else is up there, and takes the colour out of a sunset. `?weather=snow` on the | |
| 177 | address asks for a weather whatever the settings say. | |
| 178 | ||
| 179 | <img src="docs/screenshots/snow.png" alt="Snow falling over the lake by day" width="640" /> | |
| 180 | ||
3f3d6bce 2026-10-11 Pavel Safronov | 181 | **A game controller** works as well: either stick or the direction buttons steer, up |
| 182 | and down on the direction buttons or the shoulder buttons change pace, Y or triangle | |
| 183 | goes to another lake, and X or square turns the sound on and off. It needs no | |
893cda77 2026-10-10 Pavel Safronov | 184 | setting up beyond being connected, though a browser shows a page a controller only |
| 185 | once one of its buttons has been pressed. | |
| 186 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 187 | Steering turns the skater: the harder the steer, the tighter the curve, up to a circle |
df2b8215 2026-10-10 Pavel Safronov | 188 | 16 m in radius, with a moment to lean into it. Held over, the skater turns right round |
| 189 | and can skate back up the lake, or round and round; that needs open water, about 24 m | |
| 190 | of it on the side being turned towards. A shore cannot be reached; near one the skater | |
c2835cab 2026-10-10 Pavel Safronov | 191 | is turned to run along it. |
2f7e86b7 2026-10-10 Pavel Safronov | 192 | |
| 193 | The autopilot does not skate a straight line. It carves long, slow S-curves down the | |
c2835cab 2026-10-10 Pavel Safronov | 194 | lake, 6 m either side of wherever its line is centred. |
| 195 | ||
| 196 | **Steering changes the autopilot's plan; it does not interrupt it.** Let go, and the | |
| 197 | skater carries on the way you sent them, from where they had got to: the autopilot's | |
| 198 | line is re-centred there and its next curve begins in your direction, wide at first if | |
| 199 | you sent it off at an angle, and settling back to its usual 6 m over about twenty | |
| 200 | seconds. It does not return to where it was, or to the middle of the lake. Steer left | |
| 201 | out of a right-hand curve and you have a left-hand curve. | |
2f7e86b7 2026-10-10 Pavel Safronov | 202 | |
3f3d6bce 2026-10-11 Pavel Safronov | 203 | The speed breathes with the stride: each push adds about a seventh of it, and the glide |
| 204 | after it gives that back. Strides are of different lengths, and no two the same, so | |
| 205 | neither are the speeds. | |
| 206 | ||
| 207 | ### Pace | |
| 208 | ||
| 209 | | Pace | Speed | Strides | Turning circle | | | |
| 210 | |---|---|---|---|---| | |
| 211 | | Meander | 3.5 m/s, 13 km/h | 2 to 4.5 s, long and lazy | 8 m | Standing tall. Tight enough to circle an islet closely | | |
| 212 | | Cruise | 7 m/s, 25 km/h | 1.4 to 3.2 s | 16 m | Where it starts | | |
| 213 | | Brisk | 10 m/s, 36 km/h | 1 to 2.4 s | 22 m | | | |
| 214 | | Racing | 12.5 m/s, 45 km/h | 0.8 to 1.9 s | 31 m | | | |
| 215 | | Olympic | 15 m/s, 54 km/h | 0.6 to 1.4 s, short and quick | 45 m | Down in a crouch. The pace of a long-track speed skater in a 1500 m, kept up for ever | | |
| 216 | ||
| 217 | A change of pace is worked up to, or eased down to, over a few strides. Everything | |
| 218 | else follows the speed. A faster skater is held to half a g sideways and so turns in a | |
| 219 | wider circle; looks further ahead, and steers round an islet from further off; keeps | |
| 220 | further from the shores, which in ordinary water leaves a racer little room to weave; | |
| 221 | swings wider on the autopilot's line; sees a wider view, which is most of what makes | |
| 222 | speed look like speed; and hears the air rush past. The pace is remembered, and | |
| 223 | `?pace=olympic` on the address asks for one for a visit. | |
2f7e86b7 2026-10-10 Pavel Safronov | 224 | |
| 225 | The screen is kept awake while the page is open, where the browser allows it. | |
e5499331 2026-10-09 Pavel Safronov | 226 | |
df2b8215 2026-10-10 Pavel Safronov | 227 | At a fork the lake parts round an island. A touch of steering one way, from 200 m |
c2835cab 2026-10-10 Pavel Safronov | 228 | before the fork, is taken as asking for that side, and the autopilot does the rest. With |
| 229 | no touch it takes the side it is already over towards, and down the middle, the side | |
| 230 | the fork prefers. Until the island begins, a little over | |
df2b8215 2026-10-10 Pavel Safronov | 231 | 200 m after the fork, you can still change, by steering the other way. After that there |
| 232 | is land between, though a skater who turns back can go round its tip. | |
1cb1fefc 2026-10-09 Pavel Safronov | 233 | |
646446cd 2026-10-09 Pavel Safronov | 234 | ## The ice |
| 235 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 236 | How thick the ice is decides how it looks and the note it sings. It does not decide |
| 237 | whether it holds: for now nobody falls through, and the whole lake is safe to skate. | |
| 238 | ||
| 239 | The zones are not chosen. They are the three thresholds of Gold's formula for what a | |
| 240 | sheet of ice will carry (`src/ice/strength.ts`), worked out for an 80 kg skater, and the | |
| 241 | note is the physics under [The sound](#the-sound). | |
646446cd 2026-10-09 Pavel Safronov | 242 | |
| 243 | | Ice | Thickness | Looks | Sings at | What happens | | |
| 244 | |---|---|---|---|---| | |
6e58e276 2026-10-10 Pavel Safronov | 245 | | Impossible, if turned on | 0.8 to 2.2 cm | bright pink, a colour nothing on a lake is, strongest where it is thinnest | 1390 to 3800 Hz | Nothing: it sings, very high, and never cracks. Real ice this thin would not hold anyone | |
2f7e86b7 2026-10-10 Pavel Safronov | 246 | | Cracks | 2.2 to 3.4 cm | nearly black | 900 to 1390 Hz | It cracks around you: more often and nearer the thinner it gets | |
646446cd 2026-10-09 Pavel Safronov | 247 | | Sings | 3.4 to 4.8 cm | dark grey | 630 to 900 Hz | Sound ice near its limit. The ice of the film is here, at 4.1 cm | |
| 248 | | Safe | over 4.8 cm | milky grey | below 630 Hz | Nothing. The thicker, the lower and plainer the note | | |
| 249 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 250 | Of the ice a skater can reach, about 30% cracks, 35% sings and 35% is safe. |
| 251 | ||
| 252 | Impossible ice is a setting, and off to begin with. Off, the thinnest ice is 2.2 cm, the | |
| 253 | thinnest that is real, in patches that are on the point of giving way all the way | |
5e8b9530 2026-10-10 Pavel Safronov | 254 | across and crack every second or so. On, those patches go on thinning to 8 mm at |
6e58e276 2026-10-10 Pavel Safronov | 255 | their middles and fall silent: about 4% of the ice a skater can reach. They are bright |
| 256 | pink, as bright at midnight as at noon: lit from within, in a colour chosen because | |
| 257 | nothing on a lake is that colour. | |
2f7e86b7 2026-10-10 Pavel Safronov | 258 | |
| 259 | Falling through is written and switched off (`RULES` in `src/skater.ts`). Switched on, | |
| 260 | ice under 2.2 cm gives way and the skater is in the water for four seconds. It needs one | |
| 261 | thing first: a line the autopilot can be sure of, since nothing now keeps thin ice off | |
| 262 | its path. | |
646446cd 2026-10-09 Pavel Safronov | 263 | |
2f7e86b7 2026-10-10 Pavel Safronov | 264 | <img src="docs/screenshots/thin-ice.png" alt="Milky grey ice down the lake, with a black patch of the thinnest ice to the left of the middle" width="640" /> |
646446cd 2026-10-09 Pavel Safronov | 265 | |
e5499331 2026-10-09 Pavel Safronov | 266 | ## How the lake works |
| 267 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 268 | - **The lake is a function, not a map** (`src/world/lake.ts`). Where a channel's middle |
| 269 | is, how wide it is and how the land rises from its shores are all computed from the | |
| 270 | distance skated and a seed. Any stretch can be made on demand, the same on every | |
| 271 | device, and nothing is stored. | |
| 272 | - **It forks.** A channel runs from the fork that made it to the fork that ends it: the | |
| 273 | first fork is 700 m from the start, and each one after comes 1.8 to 4.2 km after the | |
| 274 | last. A fork's two channels start as the one channel and draw 180 m apart over 600 m, | |
| 275 | which is always more than both are wide, so there is always an island. Each has a | |
| 276 | width and forks of its own from there on. Both are drawn for 1.2 km, and then only the | |
| 277 | one taken: the other is out of sight behind the island by then. | |
| 278 | - **It changes character over kilometres.** About 70% of the way it is ordinary water, | |
| 279 | 12 to 44 m from the middle to a shore. About 10% of the way it pinches to a pass 5 m | |
| 280 | to a side. And now and then it opens until both shores are beyond the haze, 700 m off | |
| 281 | or more: a stretch of sea. Near a fork it is always ordinary, so that a fork reads as | |
| 282 | one. | |
| 283 | ||
df2b8215 2026-10-10 Pavel Safronov | 284 | - **It has islets.** Tufts of reeds on humps of frozen ground, 5 to 15 m across, |
| 285 | standing in the ice as they do in the film, where the skater weaves between them. | |
| 286 | They come in beds: about a quarter of the lake's half-kilometres have some, one every | |
| 287 | 50 m or so, and the rest have none. Each has at least 9 m of ice between it and a | |
| 288 | shore, and there are none in a narrow pass or near a fork. The autopilot goes round | |
| 289 | them; a skater steering straight at one is turned aside at the last moment. | |
| 290 | ||
b2c4db9f 2026-10-10 Pavel Safronov | 291 | - **It has long islands.** Low banks of reeds, 180 to 480 m long and up to 32 m across, |
| 292 | standing in the middle of a channel with water both sides: the channel parts round | |
| 293 | one and is one again beyond it. The water either side is as wide as the channel | |
| 294 | would have been, so the lake swells where an island is. A touch of steering on the | |
| 295 | way up to one chooses the side; with none, the island has a side it prefers. About | |
| 296 | one every 5 km, and none near a fork or in a pass. | |
94b3b29f 2026-10-10 Pavel Safronov | 297 | - **Its ice is cracked the way ice cracks** (`src/ice/cracks.ts`). A few long cracks |
| 298 | run nearly straight for tens of metres; later ones start from the side of an earlier | |
| 299 | one, leave it at a wide angle, and stop where they meet another, since a crack cannot | |
| 300 | cross a crack. So the cracks are a network of long runs joined at T's, with finer ones | |
| 301 | between, and each is painted as it looks in clear ice: a bright edge at the surface | |
| 302 | and its face going down into the ice beside it. | |
b2c4db9f 2026-10-10 Pavel Safronov | 303 | - **It keeps the marks made on it** (`src/render/marks.ts`). The skates leave a line, |
| 304 | and each push a scratch to one side and then the other; on ice thin enough to crack, | |
94b3b29f 2026-10-10 Pavel Safronov | 305 | a crack opens where each near one is heard from, and a push on such ice starts more |
| 306 | from under the skate. A crack is a star of a few arms that fork, and it runs out from | |
| 307 | its middle over a moment. Every mark is a hairline, a pixel wide however near. They | |
| 308 | stay where they were made, so a skater who turns back or circles an islet comes on | |
| 309 | their own tracks, as in the film; the lake remembers the last few thousand stretches | |
| 310 | of line and forgets the ones before. | |
b2c4db9f 2026-10-10 Pavel Safronov | 311 | |
1cb1fefc 2026-10-09 Pavel Safronov | 312 | <img src="docs/screenshots/fork.png" alt="The lake parting round a wooded island ahead" width="640" /> |
| 313 | ||
b2c4db9f 2026-10-10 Pavel Safronov | 314 | <img src="docs/screenshots/island.png" alt="A long island of reeds ahead in the middle of the lake" width="640" /> |
| 315 | ||
df2b8215 2026-10-10 Pavel Safronov | 316 | <img src="docs/screenshots/islets.png" alt="An islet of reeds standing in the ice, with more beyond it" width="640" /> |
| 317 | ||
1cb1fefc 2026-10-09 Pavel Safronov | 318 | <img src="docs/screenshots/sea.png" alt="Ice to a hazy horizon with no shore in sight" width="640" /> |
| 319 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 320 | - **The skater has a place, a direction and a speed** (`src/skater.ts`), and steering is |
| 321 | turning. The autopilot steers by the same means and within the same limits, aiming at | |
| 322 | a point 18 m ahead on its wandering line, so every path on the lake is an arc. The | |
df2b8215 2026-10-10 Pavel Safronov | 323 | skater may point any way at all, back up the lake included; left alone, the autopilot |
| 324 | turns them down it again. | |
2f7e86b7 2026-10-10 Pavel Safronov | 325 | - **The skater is kept on the ice.** They have the width of the channel less 2 m at each |
df2b8215 2026-10-10 Pavel Safronov | 326 | shore. Within 24 m of the edge of that they may point less and less towards it, down |
| 327 | to not at all at the edge. Where the lake narrows faster than a skater could steer | |
| 328 | clear, their room starts closing 200 m early, whichever way they are going. Out at sea they may go no further than 150 m from the middle. | |
646446cd 2026-10-09 Pavel Safronov | 329 | - **Thickness is a function too**, and is painted on the ice from a small map of the 768 m |
| 330 | around the skater that is redrawn every 32 m (`src/render/ice.ts`). | |
1cb1fefc 2026-10-09 Pavel Safronov | 331 | - **The skater never leaves the origin of what is drawn.** The shores and islands are |
| 332 | built 100 m at a time ahead of the skater, thrown away behind, and placed relative to the skater | |
e5499331 2026-10-09 Pavel Safronov | 333 | every frame (`src/render/shores.ts`); the ice is one sheet whose texture slides the |
| 334 | other way (`src/render/ice.ts`). So nothing drawn is ever a large number, and the | |
| 335 | picture is as sharp 10,000 km down the lake as at the start. | |
| 336 | ||
c90277fa 2026-10-09 Pavel Safronov | 337 | ## The sound |
| 338 | ||
9ff4c3fc 2026-10-09 Pavel Safronov | 339 | The sound is synthesized in the browser. None of it is a recording. |
| 340 | ||
| 341 | ### The physics | |
| 342 | ||
| 343 | A sheet of ice floating on water carries bending waves, and they are dispersive: the | |
| 344 | higher the frequency, the faster it travels. The two sounds of thin ice follow from that | |
| 345 | (`src/ice/acoustics.ts`). | |
| 346 | ||
| 347 | - **The singing note.** A bending wave slower than sound in air cannot radiate into the | |
| 348 | air at all. The ice gives off sound most strongly at the one frequency whose bending | |
| 349 | wave travels exactly at the speed of sound in air, where the wave in the ice and the | |
| 350 | wave in the air keep step. That frequency is very nearly **30.3 Hz·m divided by the | |
| 351 | thickness**: 4 cm of ice sings at about 760 Hz, 3 cm at about 1010 Hz, 6 cm at about | |
| 352 | 505 Hz. The pitch is a thickness gauge: the higher it sings, the thinner the ice. | |
| 353 | - **The chirps.** A crack is every frequency at once. From some way off the high ones | |
| 354 | arrive first and the low ones after, ending on the note: a falling "pew". The further | |
| 355 | the crack, the longer the fall — about a third of a second from 400 m. | |
| 356 | ||
| 357 | The model is from Romeyn, Hanssen, Ruud and Johansen, | |
| 358 | [Sea ice thickness from air-coupled flexural waves](https://doi.org/10.5194/tc-15-2939-2021), | |
| 359 | *The Cryosphere* 15, 2939–2955 (2021), who took the sound of the very film this app is | |
| 360 | modelled on and read about 725 Hz and 4.3 cm of ice from it. With textbook values for | |
| 361 | freshwater ice and nothing tuned, the model here gives 4.2 cm for 725 Hz. | |
| 362 | ||
| 363 | ### What the film measures | |
| 364 | ||
| 365 | `npm run analyse-video` measures a local copy of the film (it is not in this repository): | |
| 366 | ||
| 367 | | | Measured | Used for | | |
| 368 | |---|---|---| | |
| 369 | | The note | median 740 Hz; 664–826 Hz over the middle 90% | Ice 4.1 cm thick, 3.7–4.6 cm; the middle of the lake's range of thickness | | |
| 370 | | Time between skate strikes | median 0.66 s | Not used: at that rate the view rocks from leg to leg without rest. Strides here are long glides of 1.4 to 3.2 s, each a different length | | |
| 371 | | How fast a ring dies | 36 dB a second | The ring's decay: to 1/e in 0.24 s | | |
| 372 | ||
8b498d51 2026-10-10 Pavel Safronov | 373 | **The chirps cannot be checked against the film.** Three ways of doing it were tried, |
| 374 | and the last is still in the tool: split each burst of sound into frequency bands, time | |
| 375 | when each band arrives, and see whether higher bands arrive earlier by as much as the | |
| 376 | model says. Of 122 bursts, 119 show no spread to measure: every band arrives within | |
| 377 | 30 ms of every other. The other three do not agree with each other or with the model. | |
| 378 | The likely reason is that the film's microphones were close to the skater, and a sound | |
| 379 | from close by has had no distance to spread out over. So the film gives the note and | |
| 380 | not the chirp. The chirps here are the model's, which is the paper's, and the paper | |
| 381 | measured the spreading directly, with instruments on the ice. | |
9ff4c3fc 2026-10-09 Pavel Safronov | 382 | |
| 383 | ### What is synthesized | |
| 384 | ||
| 385 | `src/audio/iceSound.ts`, from noise and sine waves only: | |
| 386 | ||
| 387 | - **The ring**: at each stride, every 1.4 to 3.2 s, a skate strikes the ice, which answers with a narrow band | |
| 388 | of noise at its note, starting a little high and falling onto it, then dying away. | |
| 389 | - **The hum and the scrape**: the gliding blade keeps the note faintly alive and hisses. | |
0045befd 2026-10-10 Pavel Safronov | 390 | - **The wind**, when there is one: low, loose noise, louder and higher in its gusts. |
893cda77 2026-10-10 Pavel Safronov | 391 | - **Snow under the blade**, in a drift: a soft, low shush in place of the ring, the hum |
| 392 | and the scrape. | |
9ff4c3fc 2026-10-09 Pavel Safronov | 393 | - **The cracks**: every few seconds the ice cracks somewhere between 40 and 600 m off, |
5e8b9530 2026-10-10 Pavel Safronov | 394 | and the chirp for that distance and that thickness arrives. The falling chirp is a |
| 395 | sine wave and stops the moment it lands on the note; what rings on after it is the | |
| 396 | ice, in the same band of noise as after a strike. (Left to ring by itself the sine was | |
| 397 | a clean, struck, high note, like a sonar ping.) On ice thin enough to crack under the | |
| 398 | skater the cracks come from a few metres away, and faster the thinner it is, up to | |
| 399 | one every second or so. | |
2f7e86b7 2026-10-10 Pavel Safronov | 400 | Impossible ice does not crack at all. |
c90277fa 2026-10-09 Pavel Safronov | 401 | |
9ff4c3fc 2026-10-09 Pavel Safronov | 402 | `npm run sound-check` renders twelve seconds of it without playing it and draws its |
| 403 | spectrogram: six seconds on the film's 4.1 cm, then onto ice thinning to 2.8 cm, with | |
| 404 | three cracks. | |
c90277fa 2026-10-09 Pavel Safronov | 405 | |
9ff4c3fc 2026-10-09 Pavel Safronov | 406 | <img src="docs/sound/synth.png" alt="Spectrogram: a bright narrow band near 740 Hz struck every two seconds or so and fading between, three thin lines falling steeply onto it, and the band climbing past 1 kHz in the last six seconds" width="800" /> |
c90277fa 2026-10-09 Pavel Safronov | 407 | |
| 408 | ## Develop | |
| 409 | ||
| 410 | Needs Node 22. | |
| 411 | ||
| 412 | ```sh | |
| 413 | npm install | |
| 414 | npm run dev # dev server on every interface, with a QR code for a phone on the same Wi-Fi | |
| 415 | npm test # vitest | |
| 416 | npm run build # typecheck, then build into dist/ | |
| 417 | npm run preview # serve dist/ | |
e5499331 2026-10-09 Pavel Safronov | 418 | npm run screenshots # build, then photograph the app at phone size into docs/screenshots/ |
9ff4c3fc 2026-10-09 Pavel Safronov | 419 | npm run sound-check # render the synthesized sound and draw its spectrogram into docs/sound/ |
| 420 | npm run analyse-video # measure a local copy of the reference film | |
8b498d51 2026-10-10 Pavel Safronov | 421 | npm run icons # remake the home-screen and tab icons from assets/skate-shoes.png |
c90277fa 2026-10-09 Pavel Safronov | 422 | ``` |
| 423 | ||
2f7e86b7 2026-10-10 Pavel Safronov | 424 | On the address: `?seed=1` asks for lake number 1, `?time=night` for a time of day (`day`, |
3f3d6bce 2026-10-11 Pavel Safronov | 425 | `dusk`, `night`, `aurora`), `?weather=snow` for a weather (`clear`, `snow`, `blizzard`), `?pace=olympic` for a pace |
| 426 | (`meander`, `cruise`, `brisk`, `racing`, `olympic`), | |
0045befd 2026-10-10 Pavel Safronov | 427 | and `?s=2500` starts that many metres down the lake. The |
2f7e86b7 2026-10-10 Pavel Safronov | 428 | screenshots use all three, so that they are of the same thing every run. |
e5499331 2026-10-09 Pavel Safronov | 429 | |
9ff4c3fc 2026-10-09 Pavel Safronov | 430 | The screenshots and the sound check need Playwright's Chromium once: |
| 431 | `npx playwright install chromium`. The sound check and the analysis need ffmpeg. | |
e5499331 2026-10-09 Pavel Safronov | 432 | |
c90277fa 2026-10-09 Pavel Safronov | 433 | ## Deploy |
| 434 | ||
| 435 | `.gitlab-ci.yml` tests and builds every push, and publishes the default branch to | |
| 436 | GitLab Pages. Asset paths are relative (`vite.config.ts`), so the build works at | |
| 437 | `/nordic-skating/` or at the root of a unique Pages domain without configuration. | |
| 438 | ||
| 439 | Static files go in `static/`, not `public/`: GitLab Pages needs its artifact to be | |
| 440 | named `public`, and a `public/` already in the repository would collide with it. | |
| 441 | ||
e3bfc714 2026-10-10 Pavel Safronov | 442 | ## Licence |
| 443 | ||
3f3d6bce 2026-10-11 Pavel Safronov | 444 | [MIT](LICENSE), for everything here but the icons. What it builds on is credited below. |
e3bfc714 2026-10-10 Pavel Safronov | 445 | |
c90277fa 2026-10-09 Pavel Safronov | 446 | ## Attribution |
| 447 | ||
3f3d6bce 2026-10-11 Pavel Safronov | 448 | - The app's icon and the pictures on its buttons are by |
| 449 | [Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon, used under | |
| 450 | the Flaticon free licence, which requires attribution. The credit is in the app's | |
| 451 | About box as well as here. None of them is covered by this project's MIT licence. | |
| 452 | - Of those, the app's icon is a skate. The artwork is `assets/skate-shoes.png`; the icons in `static/` are made from it by `npm run icons`, | |
8b498d51 2026-10-10 Pavel Safronov | 453 | which uses only its shape: pale on the colour of black ice for a home screen, and |
| 454 | as it was drawn, black on a see-through background, for a browser's tab (white where | |
3f3d6bce 2026-10-11 Pavel Safronov | 455 | the browser is dark). The same skate is on the pace button. |
| 456 | - And the pictures on the sound button, a speaker and a speaker crossed out, are | |
| 457 | `assets/sound-on.png` and `assets/sound-off.png`. On a button only a picture's shape | |
| 458 | is used, in the colour of the button's words. | |
| 459 | - The pictures on the Tilt button, a phone held in a hand for on and a phone by itself | |
| 460 | for off, are `assets/tilt-on.png` and `assets/tilt-off.png`. **Whether these two are | |
| 461 | by the same author has not been confirmed**; if they are not, the credit here and in | |
| 462 | the About box needs their author added. | |
c90277fa 2026-10-09 Pavel Safronov | 463 | - [three.js](https://threejs.org/) — MIT licence. |
e5499331 2026-10-09 Pavel Safronov | 464 | - [simplex-noise](https://github.com/jwagner/simplex-noise.js) — MIT licence. |
8b498d51 2026-10-10 Pavel Safronov | 465 | - [Workbox](https://developer.chrome.com/docs/workbox), by way of |
| 466 | [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) — MIT licence. It is the service | |
| 467 | worker that keeps the app on the phone. | |
9ff4c3fc 2026-10-09 Pavel Safronov | 468 | - The physics of the sound: Romeyn, R., Hanssen, A., Ruud, B. O., and Johansen, T. A., |
| 469 | [Sea ice thickness from air-coupled flexural waves](https://doi.org/10.5194/tc-15-2939-2021), | |
| 470 | *The Cryosphere* 15, 2939–2955, 2021 (CC BY 4.0). | |
646446cd 2026-10-09 Pavel Safronov | 471 | - What ice will carry: Gold, L. W., "Use of ice covers for transportation", *Canadian |
| 472 | Geotechnical Journal* 8, 170–181, 1971. | |
c90277fa 2026-10-09 Pavel Safronov | 473 | - The National Geographic video above is the reference this is modelled on. It is |
| 474 | copyrighted, and neither it nor any audio taken from it is part of this repository | |
| 475 | or the app: it is measured locally, and the app's sound is synthesized from those | |
| 476 | measurements. |