From 7c228bf987f2ba7746dac70114526115f4348cef Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Tue, 20 Oct 2026 21:06:22 +0300 Subject: [PATCH 1/6] fix(theme): bevel the glass hairline with its panel `.gmt-glass::before` inherited `border-radius` but not `corner-shape`, so the gradient hairline drew round corners outside the bevelled border. Over a bright background the extra arc shows at the top-left and bottom-right. --- packages/theme/primitives.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/theme/primitives.css b/packages/theme/primitives.css index 2b29b55..3776c19 100644 --- a/packages/theme/primitives.css +++ b/packages/theme/primitives.css @@ -50,6 +50,8 @@ position: absolute; inset: 0; border-radius: inherit; + /* Follow the bevel too, or the hairline draws round corners outside it. */ + corner-shape: inherit; padding: 1px; background: linear-gradient( 135deg, From c63a2d6a9b1f81b180e9f348d59986ad0e0cf966 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Tue, 20 Oct 2026 21:06:22 +0300 Subject: [PATCH 2/6] chore: use @northguild/gmt for time and ban Date in lint - `@northguild/gmt` is a site dependency: the aurora takes wall-clock time from `getUnixNow()` (Temporal), never `Date`. - `@northguild/gmt-oxlint` is wired into `.oxlintrc.json`, so any `Date` use fails `pnpm lint`. - `geotiff` and `jpeg-js` are dev dependencies for the sky data scripts. --- .oxlintrc.json | 5 +- apps/site/package.json | 1 + package.json | 4 ++ pnpm-lock.yaml | 103 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 1d32906..1b1db6d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,3 +1,6 @@ { - "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.astro/**"] + "$schema": "./node_modules/oxlint/configuration_schema.json", + "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.astro/**"], + "jsPlugins": ["@northguild/gmt-oxlint"], + "extends": ["./node_modules/@northguild/gmt-oxlint/config/recommended.json"] } diff --git a/apps/site/package.json b/apps/site/package.json index 00ed23a..eb0b627 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -10,6 +10,7 @@ "preview": "wrangler dev" }, "dependencies": { + "@northguild/gmt": "^1.19.0", "@northguild/theme": "workspace:*", "astro": "7.2.7" }, diff --git a/package.json b/package.json index 5afe967..9f6b013 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "dev": "pnpm --filter @northguild/site dev", "build": "pnpm -r build", "check": "pnpm -r check", "lint": "oxlint", @@ -11,6 +12,9 @@ }, "devDependencies": { "@changesets/cli": "^3.0.2", + "@northguild/gmt-oxlint": "^1.2.1", + "geotiff": "3.0.5", + "jpeg-js": "^0.4.4", "oxfmt": "^0.63.0", "oxlint": "^1.78.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771ae48..443f4a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@changesets/cli': specifier: ^3.0.2 version: 3.0.3 + '@northguild/gmt-oxlint': + specifier: ^1.2.1 + version: 1.2.1(oxlint@1.89.0) + geotiff: + specifier: 3.0.5 + version: 3.0.5 + jpeg-js: + specifier: ^0.4.4 + version: 0.4.4 oxfmt: specifier: ^0.63.0 version: 0.63.0 @@ -20,6 +29,9 @@ importers: apps/site: dependencies: + '@northguild/gmt': + specifier: ^1.19.0 + version: 1.19.0 '@northguild/theme': specifier: workspace:* version: link:../../packages/theme @@ -1018,6 +1030,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-temporal/polyfill@0.5.1': + resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} + engines: {node: '>=12'} + '@manypkg/find-root@3.1.0': resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} engines: {node: '>=20.0.0'} @@ -1044,6 +1060,15 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + '@northguild/gmt-oxlint@1.2.1': + resolution: {integrity: sha512-ot1NndXxCaGikCYqe3r4D+nFM3twd1WMwylfj8xfWLCCWSRYgIFXmQ2yLpCcjf53KWxS660jmaIqWndXQh5lQA==} + peerDependencies: + oxlint: '>=1.0.0' + + '@northguild/gmt@1.19.0': + resolution: {integrity: sha512-RWOyb9G4z57t1wF2j7TrHivHW/MHFQHI8Ub63tWUIwhzBhf968gh84wvvBOrzc5GI36GkZbVJxOPj6Rj+ymSDA==} + engines: {node: '>=22.16.0'} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -1294,6 +1319,9 @@ packages: cpu: [x64] os: [win32] + '@petamoriken/float16@3.9.3': + resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} + '@pnpm/deps.graph-sequencer@1100.0.1': resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} engines: {node: '>=22.13'} @@ -1773,6 +1801,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + geotiff@3.0.5: + resolution: {integrity: sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==} + engines: {node: '>=10.19'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1832,10 +1864,16 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-yaml@4.3.2: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true + jsbi@4.3.2: + resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -1852,6 +1890,9 @@ packages: launch-editor@2.14.2: resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==} + lerc@3.0.0: + resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -2057,6 +2098,12 @@ packages: package-manager-detector@1.9.0: resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + + parse-headers@2.0.6: + resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2100,6 +2147,10 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + quick-lru@6.1.2: + resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==} + engines: {node: '>=12'} + radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} @@ -2517,6 +2568,9 @@ packages: vscode-uri@3.2.0: resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} + workerd@1.20260825.1: resolution: {integrity: sha512-ccS6TEaaRxgONKawiYGnFYUVGfr2pmN1b7mrNtw0ADVhFaYsEIVoCHnQ4UjhM9EJDzuaNgAWFY82nzVrjWpuOA==} engines: {node: '>=16'} @@ -2548,6 +2602,9 @@ packages: utf-8-validate: optional: true + xml-utils@1.10.2: + resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -2590,6 +2647,9 @@ packages: zod@4.6.7: resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3348,6 +3408,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@js-temporal/polyfill@0.5.1': + dependencies: + jsbi: 4.3.2 + '@manypkg/find-root@3.1.0': dependencies: '@manypkg/tools': 2.1.2 @@ -3383,6 +3447,14 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true + '@northguild/gmt-oxlint@1.2.1(oxlint@1.89.0)': + dependencies: + oxlint: 1.89.0 + + '@northguild/gmt@1.19.0': + dependencies: + '@js-temporal/polyfill': 0.5.1 + '@oslojs/encoding@1.1.0': {} '@oxc-project/types@0.154.0': {} @@ -3501,6 +3573,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.89.0': optional: true + '@petamoriken/float16@3.9.3': {} + '@pnpm/deps.graph-sequencer@1100.0.1': {} '@poppinss/colors@4.1.6': @@ -4046,6 +4120,17 @@ snapshots: fsevents@2.3.3: optional: true + geotiff@3.0.5: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.2.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-caller-file@2.0.5: {} get-east-asian-width@1.7.0: {} @@ -4106,10 +4191,14 @@ snapshots: jju@1.4.0: {} + jpeg-js@0.4.4: {} + js-yaml@4.3.2: dependencies: argparse: 2.0.1 + jsbi@4.3.2: {} + json-schema-traverse@1.0.0: {} jsonc-parser@2.3.1: {} @@ -4123,6 +4212,8 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.12.0 + lerc@3.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -4330,6 +4421,10 @@ snapshots: package-manager-detector@1.9.0: {} + pako@2.2.0: {} + + parse-headers@2.0.6: {} + path-browserify@1.0.1: {} path-to-regexp@6.3.0: {} @@ -4358,6 +4453,8 @@ snapshots: property-information@7.2.0: {} + quick-lru@6.1.2: {} + radix3@1.1.2: {} readdirp@4.1.2: {} @@ -4775,6 +4872,8 @@ snapshots: vscode-uri@3.2.0: {} + web-worker@1.5.0: {} + workerd@1.20260825.1: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260825.1 @@ -4807,6 +4906,8 @@ snapshots: ws@8.21.0: {} + xml-utils@1.10.2: {} + xxhash-wasm@1.1.0: {} y18n@5.0.8: {} @@ -4858,4 +4959,6 @@ snapshots: zod@4.6.7: {} + zstddec@0.2.0: {} + zwitch@2.0.4: {} From ddd3809d03487b6d8f90ced418dc17393a6c82f0 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Tue, 20 Oct 2026 21:06:22 +0300 Subject: [PATCH 3/6] build: sky data for the aurora sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each script is the provenance of the file it writes; re-run to refresh. - `aurora.sites.ts`: the viewpoints. Ísafjörður (Sólgata, by the church) and Helsinki (the Suomenlinna main pier, across the water to the city), with camera, aurora distances, city glow and credits per site. - `build-skyline.mjs`: per-site skyline, far shore over water, and ridge distance from Copernicus DEM GLO-30, plus OpenStreetMap building heights for Helsinki. Writes `skylines/.ts`. - `build-stars.mjs`: naked-eye stars (V <= 5.5, 2,887) from the Yale Bright Star Catalogue, 5th revised edition (CDS V/50), to `public/stars.bin`. - `build-moon.mjs`: a 128x64 lunar albedo map from NASA SVS's CGI Moon Kit. --- apps/site/public/stars.bin | Bin 0 -> 17322 bytes apps/site/src/scripts/aurora.moon.ts | 7 + apps/site/src/scripts/aurora.sites.ts | 146 +++++++++++ apps/site/src/scripts/skylines/helsinki.ts | 12 + apps/site/src/scripts/skylines/isafjordur.ts | 12 + scripts/build-moon.mjs | 56 +++++ scripts/build-skyline.mjs | 248 +++++++++++++++++++ scripts/build-stars.mjs | 58 +++++ 8 files changed, 539 insertions(+) create mode 100644 apps/site/public/stars.bin create mode 100644 apps/site/src/scripts/aurora.moon.ts create mode 100644 apps/site/src/scripts/aurora.sites.ts create mode 100644 apps/site/src/scripts/skylines/helsinki.ts create mode 100644 apps/site/src/scripts/skylines/isafjordur.ts create mode 100644 scripts/build-moon.mjs create mode 100644 scripts/build-skyline.mjs create mode 100644 scripts/build-stars.mjs diff --git a/apps/site/public/stars.bin b/apps/site/public/stars.bin new file mode 100644 index 0000000000000000000000000000000000000000..b0b03d5c3304da19e9f9c8ab247d3d9567c13f67 GIT binary patch literal 17322 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 literal 0 HcmV?d00001 diff --git a/apps/site/src/scripts/aurora.moon.ts b/apps/site/src/scripts/aurora.moon.ts new file mode 100644 index 0000000..ad0506f --- /dev/null +++ b/apps/site/src/scripts/aurora.moon.ts @@ -0,0 +1,7 @@ +// Generated by scripts/build-moon.mjs from NASA SVS's CGI Moon Kit. Do not edit. +// 128×64 greyscale albedo, equirectangular, longitude 0 at the centre. +export const MOON = { + width: 128, + height: 64, + data: "qKioqKioqKipqampqKioqKioqKipqKmpqaipqamqqqqrq6urrK2trKurq6urq6urq6uqq6urq6qpqKenp6enqKenp6enpqenp6anp6enqKeoqKioqKioqaipqKmpqKeop6eoqKmpqqqqqqqqqamoqqmoqKenp6eoqKinp6enp6emp6ioqKenpqempqekpqeqq6yrq6moqqelpaSkpKWmpqenqKenqKqrqqioqaqqq6qqqqmqqaiop6enqKipqqqpp6WmpaSlpaWmp6ipq6uqqqmpqqmoqKioqKyqqampqamoqKioqKamp6Wmp6enp6iop6eoqainp6iqqKenqKeopqanp6enqauqqqqtra2wr6+urKurqqioqKenqqqop6ioq6ysq66wsbCxsK+trKyrq6ysrq2traysrKqpqKmprKuqqqqpq6qsra2sra2trKqpqquqp6ipp6epq6qnp6ioqKmpqKipqamqq6uqqamnqKepqqmpqainqqupqKmop6ampaWmpqanqaqrraytra6trKupqKipqKiopqenqKemp6eqrrW3tLCwsK+wsbOysbCxsbKysLCurq+vrqyrqqqrqq2wsK+vsLGxsK+wsK2sq6yqqqmqqaanqKiop6ioqKmoqKurraurrKytrqurq6yrra6tq6urrKuqqampqainp6eqqKemp6iqrKurq6upqqqoqKWkpaSlp6alqqqnpqWmpaenqKmqqaipq62trrGztLKvrq2usbKxsbCwsbGusK2wsLOysa6urq2srKqrq6upp6anqKipqamqqamop6ioqKeoqKmqqqioqaqqq6qtrrCys7Gyr66traysqqinp6qpqqqpqaenqKioqKimpqWnp6empaSkpqeoqKelpKeppqWlpKSmpqinqKeorKurqa2wr7GysrKzsbGwsbGytre4t7e2trOyrq2rq6iop6ilo6anpqeoqaiqqKenqqqrqqiqqampqaqqqKioqKiqqautraywsrGopaepp6inq6urqqusp6enp6ipqaioqaeop6eoqqWkoqKjpaalpaSmpqeop6impaWnpKWnqaqtsLGtrKyqq6qoq66uq62vsLGxs7e3t7e0sa6rp6SlpaWioKChpKWmpqenqKmqq6qrrK6wrq2sr62trq+urauqqqquqqepqKmnqKSkpKWlpaiqqqunp6emqKqqqqyqq6mnqqmmp6WloqKipqOhpKSlpKemp6mrqaupp6Woqaursba5t7S0sK2pqamoqKqrra6vsbCusbO2tLCsq6ampKejo6Oko6KjpKepqaysrrO2tri5u7i1ubWztLOys7S2sa+tra2oqq2ur62sqqmoqqqppaSlpqWlpqeqqq2opaeoo56hpJ6bm5yam5uam5WdmZeenqKhqaiuqqqopqurq6eutLi2tbWwqqiopKCfo6efoaKhoKKoq7SypqKgmaGhnp2foKKYmp+hq6+wsbnCxcTDxcrLxsDCxcfExcC+u7u4srGvsK2oqauvq6ewrK2rq6enpKSlpaqrpqe1s6mpqKqknJqdnqKZk5SYl5mZk5mVkpqhmqGboqSpp6mqqaqqqa+0r6qnqqibmZ2RkZORl5mRiYOJkZaWm5mZl56YoJuRj5WTm6Ccoayrsr3BwLWxuLu/wMjN08/Nz8rGx8XGw7uzsrGsqaaropyjp6mmoqeio5yblqSjrLCkoZuWlpydnaOorrWvo5iZlpaYlJabkpGTopubmZ+uraSgp6CcnqSkp5+fpKCfmpyRk4qIhH97dHJ3enZ4fnF3gouQh4WMjYqPkJadmZynr7rCtLWzqaOlrq+urLW5vsDGyczRy9HKvbi0r6utpZycmp2hoZ+knqGfmJyanKaqoJSYp6Cel56ZmaOtsrmimZ2blZabmJSWkJGWlJOUlpqjmJWOjZGQlJeeop+XlZKOiYaBfISGgHx5cHFvcnVzbHN3eX+De3t+en55fn+Ji52rs7ursKSnpJWZqKB+e4yaqri+xcbDusG1r6utp6eem5OXmZqenJaZmJubn5ySlqGUlpicnp+jnpudop+gpJ+ioKOdoZ6TlpeRk5CTk5CRk5iXj42Nin1ucX+SlI6FgXl4dHNzdYWQgIJ9gYCKiIqHh352ent5dnZzdG9ubW+EjJymrKyblqGhnpqZkIKCkJyrsbe7wsi7t7qys6mjmpeTlaCelpOZmJOamZubmJWWkI2ho6qpoqCal5aWlJual5+Vnp2emJ2Ul52VjZKSjZKWmJqhmIqEgGRlb3d4dWdpZG6Jg4uZjYyGgn97j4tld4WajYF8gnp6gHt3cHFxeIiTmZqWoJmen5iloZGXpamuq6ivur2+zcW3ureqpaWdnJudqJmUj46TlZmZl5iZmJSYlK24s6agnZqcp6WalZWTk6ChqJiRlZSloZiOlZGPj46SppWJh4JqZ2lobGpsZVtcaXiTkZOIfIB7bGJnbnJ2gY6VkIR7fYqLgH19f3qIkpeXlZiSiZaMmp6TkJiVoqSmnaKxvcfDubi3t7O1sauqp6SelJmQjJGXnKqimJmYpKuep6u2tKygoqOqrJuamp6cn56cm5GXnaCZm56emJOVko6Wi3yAbV5cXF1jYGVobHaEjoyRcmJibFpXW11saGJpeYmRiH+Gg4eNgH16hYeSkZSWqo6CgYyfl5iblpGbnqWjp66xtrawt77HtayxuL+vqaCioJeTipCVn5mTm56oqp+tq7u+qKCdnqCioqOnqqShmaOkqqOUnKepsquckpWOjpOEfndeWFxTV1lZYVpWWHV8foRpV11YTU5XXF5hYGRrhIqNiY6XjIZ9eoOGgIKEiZOUioqChomAlpman56hnp2hrKust7Cts7OtrLW3sKamoqmooqWXlo+TnJeaoKKjo620t6uko5+fn56hrLK1uamfnKOam5KYl5qsrZ2Sk4+HgoGAeHtsUlBTVFFZVVlTbnh1dmhTS0xQT1JXX1pcZ3F1hoiOjIiGeW90fX98gY2MmJCEm5OPj4+WqqicoqKho56qtbC8ta60q6GopK6kl5aZnpylqrKtoqCanZ6fnKGdssq6sraqpaeio6W0wsCyp6OSnKCPjZKQlJ2Vj5CXlZKJhX1vWFVSTlFRVVtSVlpka2ttUlVTTEpNUlNeW2BreHZ2hJSBd4F+aG9ydmd2kYqNjY+WmJuPjo6UmJyepJ+hpqKtw9XEtreupamrpKugmaGcnZ6lp6OmpaWnnJecoKKwvsC5sqqfpqqvrLW1t6ihnZ2Ul52ai4yQkIeHlZiRkI2OgmxcTFBQUFZYUVBTWVhcX1tQV1dST01TVVhYV2dweW99gVxXXm1sXW5vaXeKmZWQkZeqmpCNiI6Rl5unoqCdpq3Q3bi1vb2yqK6lo52joqOenJufnaCdqLGvpaCgr6qyp6ivoqitpqaxsLCtqKSsqJybnp6UlJqXjYmNkI+Kh4dzXUVKUGRUTVJTUVdZWVZaV1RcVE5PTlBVVVZTX257bXRkUFdWWF1agYJ9jZGYnZOVm6CTh4mVnJScoZyfp6aZo6Gsr6y0srqzsKSjm6ClsbO0nZGZoqCdqrStqrKrrq2eo6mptrWsrbG1rqypo6iupqaloJyVkZ6TjIuMlpOGhW1WXWBeUlFMS01XVVxkW1hYXVtVVFNTVFNXVV5rcnxxZFtTU1FZWU5ogZCUmKGjnJWelZKNjpeeop2gpauvpZ6Vjo6ep6ewtrW1n5qYn6OirJhzZZGnppeorKWrv6+wra+Znq+8uLGttsKpra2wp6ibmJiXoIuKmZCLjo2ZmZaCalVjX15eTU5MTVFUbmpfYFZdVlFXV1NPVmphdHVsY3hmU1VTVllSS1B+n52Wkp2Yl4mKlo2XkZeaoKewsKOSj4+Njpmlp6Sys76om56dm6KjcF5fg6OmmJecm6e2tsC5v7m1vru+r7K8uK2op7GjopqYnpucoZSSjZCXmqGdm416Zl9tZ09NT0tLUVqCf2VgUVBRXVJbV1ZVV1ZldXd2f15KSU9ZU01PV3uSk5OFhoWGjnp0f4uKlZakqqadn5SOk46TkqGmpamkpaWhnaKqrKOEcHaPoaGYnKars7W0urSytbrFu7i1r66qo6arsKKdn5+qrqmrpZ6fnJyUpp6ej4p2ZWhNTWNMRkdPWGFgY1tXVVloX1tdW1paV1RagYpzV0dNTlxPT05Ve3txl4iLj3JnZF5eaXmLlJ+fmJ+YmpORkJCVn6GipKCfnKWhpqumoaGVnJeToJqkp6iyrq67rKu0vLS6qKOjoqCgqaKknJ+fobKxrqyspJ2WlpednqOklI1uV1JKUkRDRU1OUFRVY1lTVl9iZ1lhZGBkX3N8cGhYVlhQUUtLTVZvY2B4maOXal1XVExOcISWjJJ8hJidjZCNiZSWm6qpqKmkoqCloq62n6GclZemqKexu7ywuLetqbm2p760n5mZn6CjoqKio6muraqwtbe6qJ2XipOam5WWjXtiU01HSkVCQ0hLUlRgbFFdcXh5c3N0c2hmb21ZVVxxVHhmU05KRVFTTHKDpp1vV1NOS1tvlYh6gmNqf4yIj5OLipaarbKxq6Wmo6WfnKCemaulnrK3s7q+wNrJrquywriptK+knJyknJyrpKCgo7qpr6qqsKqlnpeRpJ2Yj5eNiGxqVU1MR0FBR0dUUl1mYldyhH2JhXl1aFtKVlROS2JfblJAQEZKQkJAWXeNoHlZUlJVj3yUjIF+XFdfY3aJlpKMlpuwtbKoqbSnpZ6am5OVnpidrbS1v7q9wLyztLi+vrSupaOmpaieoqqlq6OgoaKioqCupJ6clpaelJKTlZici4BvUkhHRkZFSEdPZX10W2x9g6GReXZmV1Nna1RHWnhxST5AS0JDQEVKYHSWkH1ueX+GgoKOk4N/b2t1eYSOlpaVk7LAtq6wrq6gm5yZmZ6XmqClvLC1v7q5uLO2sK+0p6ulp6ygn7Sqsa2uo6Wdm5+up6anoJykopeTlZiXrKydjYVoTE5TTEpLSlVthn5maXF7eXJkY1dIaHx+eW11hXlQSU1CRURERldkZ22HjIeId25mdIuUk4R2e4uEgo+Xl5uVnL++uK6mqayjoKGgnp+fnqCmsrTAtba2uayloaqhqraroJ6dqqqutbWunKKgpJmlrbOrtMSooqOZmJellJqUmYZeR0NFSkpLTGBpaWRmbnt2alddUktdcnyOiJuNeWFNR0dITlFOVWZsV1R6hYOFcXaLiJGYh29qdX6Dn5+fsJmht9HBs6ujo52jraWgopqkqqq3uq6urL60paOjq6O4xK6tpqOqsLK+wKyor5+Vk56pr7GxtqadnpaYlY6So5OXiFhLQ0NHSUlIVVxcXWJnbXd5XVtaYWFeco2WpJiHdlJPTFBoaFReXlZPTEdSanBofn15gohtX09ghJGZlKKtmp6/ysmysKyrnZ6mo6KhnqKkqqmwr6ansK+gn5+mq7Gusbispquqsbe9srCpopiSmaStqKSkpqiioJ2QioqDmJp/ZU9HQkZJSERKS1JYW2VgYW5sZmh6fm58j5eUkpWMhXNVWYuFgXFUVUtLTFVic3d6goGIfGFaXWqOjYiPmqOSmKvEy8Syp6ymrqmgoKeipq2nlpCipaWssaKboa2qq6Crsa6mqqulrreqpKablpOXnKOqqqeypqmknpWRi4mTa396XkZBTU9ISEtJT1hfXVdicHNnjY6NioiPlpGSlqWlh2Nsh4SAd1xVUU9XZGF1fnyBiIl9bGZ8hIqLlJCQlI+NkbrRyLKlo5+mn6Cdnpqit6mdl5ehoq+tpaipuLKxqqivqKWppaikoqGeo5yWlZSeoKuxr62tq6Wam5WZkY1ZjZFqVEZCTUlJSVFQWnZoUl9wd1lyhoqNi5GinpeWrZt9b3GGjpV7YVRSUV1ohoiDgISFhIl5a3qNjZONhpOamZKTrsq4qJ6anJyUlpuZmaSopquinqqnsKaipq27xruqorCqp6SjoJyblpuYnpiRlqa3vLW4tLOopqGPl5eMj46Pi39lT1ZOUElST05Xa1dQVl93Wl1/iH2Gh5WSlKKhqpmOfXuDjpV9ZVpaaXuclo6HiJKKkpKKkZeclY+Eip2cjoabqqqeoZWYlY+VmZWZlpewt6KcoqWqnqqzsrzCt6uloLappaKdmpeZj5OYlJGmpLm+vb/AurKYm4mQlI+Sk4+Qn39gcVxhYXBcT01ZVVlYYHBjXnKEiYaKjZCToJqjrp+NdmV1lYZ5dW9ncICCjIaIg4ySkZuYm5qRi4eMkYyHi4+XoZ+YmZmPjpeYkJiam6ippZyxppyTmKWyq6qynp2gnp6opaSgnJePkJGXmZ2jrLS6s6aUpaOQmpORlJSKhouOg35rbG52e2pjUk1qfFtgXlhgaoaKh4uRm5WTk6CvsIJqZ3OZi4aEgWdyfnWDgH+SjI6SoJyin4+Pi5CbjIWPlpGdmZialZuTlZeVkZeboKCZo6ChkJCTnp6Vn6yakJudlqCZm5qcmJKZmJicoKOnrqmOboSAlJiYk5iOkJCTmY+FgHZ5iHx0YFhcY3RnXGBaV1WAi5OMmo+RkI6YlaKujnVnX45+hHRueIGEdoF9f42WnJ6pm5ulnpiSi5GEiKOcnZ+WmJeTj5Kgi6aUj5aTk5qtk5qMlIiJhI+XmpiUkomPl5SOi4uWmJqjlqSopaiwq6B6Z3igqqGjlpijoKilnpB/goWEXktJTHNrcHBuXVNZW4KUi4mMiI6Tl5OQmp2hg4KKe4x9hHCDn5eAhoOHkJSaiZGVj5SdrJeFgoSRpaCgnHCBoJiakZOUqI+Mi4mKl5idjoiEhI+CjJCQioOLiIiWlpySj5CfnKecn5ybpK+wrJR5iqqqqqOWlpWiwMirn5mMi4FZU0tHX2ldbmRVWFFbcpGMm5CPmp2cjI+frpuQk5KJlZWWlZCUmZOLjYiOiZOPi5eNk5Gbi357h4+Wk5eWm6yan5qcmZaZmZGOiZCKioiCgIKBhH96h4uBhH97f5CTpaWjn5qXl5CRlJymrLKjk5yprrOnnpqWl5mqsqCYmaCJfGBWUlZqdmJoaFVWWGF8mZypm56snJCSm6ajn6OYkJKYoKOpoaKmopefioeMoZ2PjY2LjIh1cYuMioWGiJKQnJmalpSZkZyWmYmRlo+NiZCLbXp8bHGBiHuAfnh8jJ6cpK2knZCSj4+Tl6Grp56amqmnqaSglJORk5WZmpCap5uQd2VqbYNodXuGd3d2jaKnpq2ro5qRk52klpuYmZOZlpmcrrm3rKybo6KPho6QoZmOiYaHhnx8f32Ak5eZkZOQlZyfl6iZjpCbjoSOfYl6eoJ3d3Juc32KdXh8goWHm5qgm6aejouSlpCTlpyek5ChnaGloZqWk42WlpGWiJOdp5SBf3eGcWNueaOjlJmtt8W6q6+mnZydqpygnpORkaGblpmsy7rGs6SqnJSNm4+LioVod4mCg4OHlJWTlpWKlJSJiZCWjXmAhpmTdGtdhHyCeWx2d2lde3xugF9tgIOJlKaZpJqSj42QkIuRkpOVmKWenKSZnJGQmZCLjJiNkoyQhXCBhIOBfIeQk6ipq7q8xrWtraWno5yYlJeVkZOSnJmanaivs7ahm5WSkpSciIBocnZvcnV0fY2eoZ2VlI2RiYSFko94ZISJkJWCdHB+fHptaXd6dWN8f259bXJ8fn6InJyVkZmVlJCMjImNi5KXlpiTmZaUl5OWl46Jpo+GhIeFfoGQjo2Kkp2epa65v8C/ubSvp6SbmqOYl5mZnpiXmJeXoa6fppmVlpuQi4t8e3tua3d2b4F7hpaemZONiZaSi4GKkIyEiZiQioF6eXuAcm98hYx3aXp/bGNkcXd4foWRnZKMlJSPl5KQkI6PjY+Ul5WVmJWZl5KSo5iVh36KjpOPkZeal5qMmaavtrSzur67ta2spqOrn6GjnpSWnZ+joqOlmouOjoyKh4iPgXRtdG1kcXhxeHmFh5eYlY6LkoeJiI+WmZicl42GfXV3foyAd4KDgnVwdHpwZGx7dXl9g4aHk4yGjY2UjYuLi4mNiY+Zm5mZmJmglZGTnJN7doyJg36Rl5mUl5ahpbCxv8Oyt7Szs7OnnKKbnKKTlZmcoqWsp5+WjYuMjo+KiHhzfH56cGNoeXt3dnuHlJuMjYiVjpCHjbizmJSZiYB0d3t7fnyAiIV6dXJudGllZWxwdHd7foiMjIiPi4yWkJKPkJGTkJahmZiVlZiZmJGQl5CNg4V+goqNl5yZlZ6jsLe0tLe5tLaxq6CTkpWUlZehqaWlr7anmo+Li4+Pk5GSb3aFeYN7enV1eniEeIaJjIuMj5aVjYuYkpWNi4R/i3BvgoF1cH+Jj3h1dXR1bm9scnJxend4hpWKkJOPk5mQlJWUlpSZkZmSk5KVoaSYkoyMj4uKgX2Dh4yUmZWYnJ6wtLq9ucW8t7mupqmfn5mZlp+bnZ2ioJuUjJqVoaCMhnVodYJnaHx/gHiHkoiLhoSHk56VjYaJi5WLiYqDg4+JhoCCfmx2f35/en9reHJscmxudHZudnh8goSFkpmVnJOblZ+fk4qPlpaWk5aeoJOMi5OcmY+De4KNkZSVk5qnsKi1v7uvtbCuuaujnpuZnqGeoJ+io6Wfmo+Pm5mThHqRdGpte3ZwfISQg4GGiImSh4qTkoyDh42Qk4+Tg4OIiYOGiYN8c3uCe3h5hXZ8dGhqZ2t4c3t6dnp8foOLlI2SlpOWmpeTkZSanpuVmZuamJSRlpealol6hJaVlpiboaqjq7extKyyq6espKKhnKGkpKCZm6WnmpWcnpOSkZWPhouBbWZ4e4SDgYqIkYCDkpGRk6OakpGQiJGYlpKTiIeLjIt7fH96dn6BeYB0dHt+cm1wdHZ2eXqGg399iIqRjpCQmqCempOUlZuXnJifoKmhm5qZnaWmopecpZ6Xl5+qn6iyv7G1trCsq6uqrKCmpaadoJuhoqCYnJ+fmpeeoqOenKCDhpeTg3p9hX5zgYiPoJqaub6soo+PjJGYmp+PkYd6am9ycoKKiYWKh3yCi4l9enp6fnt+iIuGgoCEi5KWk5acnKChnaCgoJminaesraqopKOnq62wqqqnnp+jqaurq7G/vb+0rKSkp6qqsK6spaSgmpqgn6CdoZ6koaOfoJiar6ydjoKEf4iLh4l/e4uVnJ+utKylk4+TlpyXkJWNg4R8g4WIh4mChZmLho6TlISCgH+BgoaHiomJi4yVkpKVl5Wbm6ufoqegoKGeqK2wrrG0s7OwqqSvr6msp6aos6+yvsm/xrmtr62vq6qvpqWmqaeko6Ofpa6sra6tsamfnaWuppuYi3yKiI+XmKCYlpuYmZ+stbCjnZ+dm5WOkpGLhZGHhImMj4mXmJCPjJickIyPj5KTkpGLipOQlJeXm5mdnaKkoZuuo6mkpqqpqa+vr66ys7Cus7Wyrainqauvs7a/wsK+v7Kws7OwqbK1sKeorq+qq6qrr7a6tLm1sq+qqaipoZ2Yl5SZqq2mpKWlpp6pp6ayw7Khn5+inpqQl6agnpSOjZOWlaShmZ2enJmYmJ2cnp6hoJ6cnp6fn6GhoKaipaGlp6WpqKmorKupq66xtbe7t7WwrK2rq6iprKyrr7SytbOwsa+srqqsr7Wrrq+uq62vrqyprK2ws7GpqKWop6ShpKKeoJ+fnpudoaCjoqKhpaasraSkqa+opJ+hoqisqqaenJyanJ2enp+hnJudnp6fnqGhoqKlpqSioZ+hoqOkoqKjpaaopaepqampqKqssrOyrqmtqaiqq6mmpqmprauusK2traqqra+vsK2srbCtrq6tra6urKinp6enpaSkpKGlpaKenp+dnJ6ioZ+jo6ajpaanq6mopqWlpKenpautqaKipKCnp6emqaWgoaChoqWlpKGlp6alo6KioqKjpqempaWnqKeoqqyrrKimq6qoqqutq6usrK2rqqaqraywr66trKyusLGzsbOzsrOxtLOxsK2srq2sq6unpaenqayqqquop6eopqKhpKenpKakoqWqqqahoaGlpqWopqeoqKysqqimpaqrrKqrq6GgpaampqWjpKWko6GhoqGjoqSlp6qtq6mqqKeoqKuqqqinp6enqqmoq6qoqKyrrq2usbOwsbCxsLK0t7e3t7a3trS1trSzsrGwrKutq6urrKuqqKqtr7CwrauqqKanqaenpqirq6qrqKmnpqelpaWlp6emrK6tqaioqKmnp6urpaWnp6OlpaiopqSkpKOkpKOio6WnqquqqamoqaepqaipqKmoqKqqq6qoqKinqqqpqautq6qrq66vsbCws7Gztba3trazs7CwrqqpqamoqaqrrKyrq6ytsa2rqqmqqKWmo6OlpKeop6ipp6mopaWjpqinqKiqqaqnqamqqqqrra2rqqiop6epqaalqKmnqKmpqaioqampqaimqKqqqaqqrKmqqqmpqKinqKeopqmoqKurra6rqaqqqqqtr7CurbGzsLCvr66rq6urq62wsLCtq6qsrrCsrauqrK6vrauqqaioqqypp6ekpqqqqamopqanqaqsq62vr62qqquqqqipqamoqamop6inpaenqaioqamoqqqqp6epqKWlpaalpqanqKenp6aoqaenp6eoqKiqrK2tq6qqqqqrrK2srq6rra2sq6uqq6yrrK2tqqmnpqWmp6epqKiqqqqsq6urrK2sqaenp6ampqWmpqanp6anpaemp6mrqaqqqayssLCys7S0srGxsbCwsLCvr6+vr66trK2ur66wrq+vsLCwrq2urq+wr6+vr66traytra6trq2traysq6ytra6trq+vraurqqurraysra2urq6vr7CwsbGwsbGxsbGxsbCwsK+vrq6urrCwsLCvrq6vrq6ura6ura2tra2urq+wsLA=", +}; diff --git a/apps/site/src/scripts/aurora.sites.ts b/apps/site/src/scripts/aurora.sites.ts new file mode 100644 index 0000000..e2a9cac --- /dev/null +++ b/apps/site/src/scripts/aurora.sites.ts @@ -0,0 +1,146 @@ +// Places the sky can be seen from. The page picks one per visit (or the +// visitor switches in the info popover) and passes its index to the worker; +// the popover shows the same site's details. Plain data only: the page's own +// script and scripts/build-skyline.mjs both import this. + +/** One aurora curtain: its ground distance north and the x range it spans. */ +export type Curtain = { + /** Typical distance north of the viewer, km. */ + base: number; + /** Centre and half-width of the east–west range drawn, km. */ + centre: number; + half: number; +}; + +export type Site = { + id: string; + /** Short place name, then the country. */ + name: string; + country: string; + /** Where the viewer stands, in a few words. */ + spot: string; + lat: number; + lon: number; + /** Eye height above the ground (above the sea for a site over water), metres. */ + eye: number; + /** Camera heading from north toward east, degrees. */ + heading: number; + /** Camera pitch and vertical field of view, degrees. The frame's bottom + * edge sits at pitch − fov / 2, just below the horizon. */ + pitch: number; + fov: number; + /** Magnetic dip and declination, degrees (approximate, for ray tilt). */ + dip: number; + decl: number; + /** The near and far curtain. Farther south, the aurora sits farther north. */ + curtains: [Curtain, Curtain]; + /** City light on the sky near the horizon, 0 (none) to 1 (a capital). */ + glow: number; + /** Faintest star magnitude visible on a moonless night. */ + limit: number; + /** What stands between the viewer and the sky, for the popover text. */ + ground: string; + /** Water in front reflects the sky (the skyline script finds the shore). */ + water: boolean; + /** Add OpenStreetMap building heights to the skyline. */ + buildings: boolean; + /** Lit windows and streets on the skyline, 0 (none) to 1 (a capital). */ + lights: number; + /** Credits for the data behind this site's skyline. */ + credits: [string, string][]; +}; + +const COPERNICUS: [string, string] = [ + "Terrain", + "Copernicus DEM GLO-30 © DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA.", +]; +const OSM: [string, string] = [ + "Buildings", + "© OpenStreetMap contributors (ODbL).", +]; + +export const SITES: Site[] = [ + { + id: "isafjordur", + name: "Ísafjörður", + country: "Iceland", + spot: "Sólgata, by the church", + lat: 66.075075, + lon: -23.124175, + eye: 2, + heading: 20, + pitch: 32, + fov: 70, + dip: 77, + decl: -11, + curtains: [ + { base: 170, centre: 250, half: 650 }, + { base: 420, centre: 500, half: 1400 }, + ], + glow: 0.1, + limit: 5.5, + ground: "mountains", + water: false, + buildings: false, + lights: 0, + credits: [COPERNICUS], + }, + { + id: "helsinki", + name: "Helsinki", + country: "Finland", + spot: "the main pier on Suomenlinna, across the water to the city", + lat: 60.1493, + lon: 24.9848, + eye: 3, + heading: -25, + pitch: 25, + fov: 60, + dip: 74, + decl: 11, + curtains: [ + { base: 550, centre: -250, half: 1000 }, + { base: 900, centre: -400, half: 1600 }, + ], + glow: 1, + limit: 4.2, + ground: "skyline", + water: true, + buildings: true, + lights: 1, + credits: [COPERNICUS, OSM], + }, +]; + +/** Credits that hold for every site. */ +export const SKY_CREDITS: [string, string][] = [ + ["Stars", "Yale Bright Star Catalogue, 5th revised edition (CDS V/50)."], + ["Moon", "NASA's Scientific Visualization Studio."], +]; + +const POINTS = [ + "north", + "north-northeast", + "northeast", + "east-northeast", + "east", + "east-southeast", + "southeast", + "south-southeast", + "south", + "south-southwest", + "southwest", + "west-southwest", + "west", + "west-northwest", + "northwest", + "north-northwest", +]; + +/** Heading in degrees as a 16-point compass word, e.g. "north-northeast". */ +export const compass = (deg: number) => + POINTS[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16] ?? "north"; + +/** Latitude and longitude as "66.0751° N, 23.1242° W". */ +export const coords = (lat: number, lon: number) => + `${Math.abs(lat).toFixed(4)}° ${lat < 0 ? "S" : "N"}, ${Math.abs(lon).toFixed(4)}° ${lon < 0 ? "W" : "E"}`; diff --git a/apps/site/src/scripts/skylines/helsinki.ts b/apps/site/src/scripts/skylines/helsinki.ts new file mode 100644 index 0000000..663a526 --- /dev/null +++ b/apps/site/src/scripts/skylines/helsinki.ts @@ -0,0 +1,12 @@ +// Generated by scripts/build-skyline.mjs. Do not edit. +// Helsinki: 60.1493 N, 24.9848 E, eye 3 m above ground (3.0 m). +// elev: Int16 little-endian, hundredths of a degree above the horizontal. +// dist: Uint16 little-endian, tens of metres to the ridge. +// base: Int16, hundredths of a degree: the far shore over water, or -40°. +export const SKYLINE = { + from: -125, + step: 0.2, + elev: "GwQbBBsEmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBmwGbAZsBvwC/AL8AvwC/AL8AvwC/AL8AvwC/AL8AvwC/AL8AvwC/AL8AvwC/AL8AvwC/AL8AigCKAIoAigCKAIoAlgCWAA8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhAT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BIQEhASEBIQEhASEBIQEhASEBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQFpAGkAaQBpAGkAaQBlAGUAZQBkAGQAZABkAGQAbgB0AHQAdAB0AHQAbwBvAG8AYABgAGAAYABfAF8AXwBbAFsAXwBfAG0AbQBtAG0AbQBgAGAAbQBtAG4AbQBtAG0AbgBuAKMAowCjAF8AXwBfAFgAWABYAFgAWABbAFsAWwBbAFsAVQBUAFQARgBGAFcAVwBXAFcAVwBXAFUAVQBVAFUAVQBVAFUAUwBQAFAAUABQAE0ATQBNAE0ATQBFAEUARQBDAEsASwBLAEMAQwBDAEEAQQBBAD0AQQBBAEEAQQBAAEAAQAA4AIgAiACIAIgAiACIAIgAOwA7ADsAOwA7AC0AOAA4ADgAQgBCAEIAQgBCAEEAPwBEAEQARABKAEoASgBKAEoASgBKAEoASQBNAE0AVgBWAFYAUABMAFMAbQBRAIMAcwBYAIUAhQCFAFIAUgBSAFgAWABYAF8AXwBfAF8AUgBSAFIAUgBLAEsATQBNAE0ATQBNAEkAUABQAGkAaQBQAEsAPQBCAEMAXABcAFcAVwBhAGEAYQBNAE0AWwBbAFEAUQBFAEUARgBGAEEAQQA8AEAAQABGAEIARQBFAEAAQwBDADYANQA2ADYASwBLAEsASwBLAEsASwBLAEsASwBCAEIANwBCAEIAQgBCAEIAWABYADIAMgDjADMAMwAzADEAPgA+AD4APgA+ACsAKwBOAE4ATgAsACwAPQA9ADYAQQBEAEQAQwBCAEIAKQAqACkAJgAnAC0AKwAqACkAKAAoAC4ALAA2ACsALAA4ACsANAA8ADgAOAA4ADgANgA6ADUALwAuADkAOQAoADgAMAAqADgAOAAzAC0AKwAwACsALAAsACwAKwAnACcAHwAtACcAIgAdAB0AHQAfAB8AHQAmACgAGgAdABwAGgAbABsAJQAhACQAGQAkABsAKQAcAB0AHQAgAB4AJQAkAB4AJAAiABsAGwAZABoAGAAVABkAHAAaABkAGQAfAB8AHwAfAB8AHwAkACYAKwArAC4ALwAvAC8ALwAvACsALAAsACwALQAtACwALAAvAC8ALgAuAAEBMAAwAC4ALQAuAC8ALwAvAC8AMgAuADEALgAvAC8ALAApACcAJwAmACQAJQBhAGEAYQBhAGEAJwApAC0AMgA2ADgAPQA9AD4APwA+ADsAhQCFAIUAhQCFADAALgA4ADgAOwA7ACgAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAFwAXABcAFwAXABcAFwAbABsAGwAbABsACwALgAwADIAMwAyADEAMQAyADEALwAzADMAMwArADAAMAAwADAALgAuAC4ANgA2ADYAOAA4ADgAOAA4ADgAOABPAE8AOgA6AEsATgBOAEgASABIAE0ATQBNAEYAQgBCAEIAPAA4ADgAOAA5ADkAOAA8ADwAPAA4ADIANQA6ADoAOgA6ADoAQABAAD8APwA/AD8APwA/AD0AQQBBAEEAQQBBAEAAQABAAD4AQQBBADoAPQBBAEEASABIAEgASABKAEoASgBKAEYASQBJAEkASQBJAEkASQBFAEUARgBGAFEAUQBRAE0ATQBLAEsAOgA6ADoAOgBFAEUAPwBIAEgARwBHAEcANwAtAC0ALQAtAC0ALQAtACwALQAtAC0ALQAtAC0AKgAoACUAJgAmACUAJQAmACgAIwAsACcAJAAjADIAKAA1ADIANQA1ADAAIAAjADUAJABCAEIAQgAgACAAIQAiACMAJAAlACUAJwAoACkAKQApACsAKgAqACsAKwBKAEoASgBKAD4ALQA3AC8ALwBAAEoAMQA4AA==", + dist: "BgAGAAYACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAMgAyADIAMgAyADIAHwAfABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAEwATABMAEwATABMAEwATABMAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwAJAQkBCQEJAQkBCQH1APUA9QDkAOQA5ADkAOQAAgHgAOAA4ADgAOAA3QDdAN0A8wDzAPMA8wDeAN4A3gCrAKsA7gDuAPsA+wD7APsA+wC6ALoA0QDRAOIA0QDRANEA0wDTAEYBRgFGAbEAzwDPACEBIQEOAQ4BDgH2APYA9gD2APYA+AD5APkAFwH+APYA9gD2APYA9gD2APkA+QD5APkA+QD5APkALQEoASgBKAEoAQgBCAEIAQgBCAEjASMBIwEdATYBNgE2AR0BHQEdATABMAEwAToBOAE4ATgBOAE9AT0BPQFAASUBJQElASUBJQElASUBNAE0ATQBNAE0AR0BIgEiASIBIAEgASABIAEgASwBMAEVARUBFQEPAQ8BDwEPASABIAEgASAB9QDaANoA/QD9AP0A9wAkAUoB6QFLAeoBwALiAFkCWQJZAuUA5QDlAOQA5ADkAOgA6ADoAOgA5wDnAOcA5wDrAOsA6ADoAOgA6ADoAMYA4QDhAGwBbAHhAEMCqwGlAa0BZAFkAWwBbAFiAWIBYgG1AbUBYAFgAWkBaQHBAKMBpQGlAagBqAHDAZsBmwGdAaQBnQGdAZoBqQGpAb8BnQG4AbgBzADMAMwAzADMAMwAzADMAMwAzADHAMcAuQHJAMkAyQDJAMkATAFMAYEBgQGEAd4B3gHeAaUBXgFeAV4BXgFeAWABYAFJAUkBSQFdAV0BaQFpAbkBXgG+Ab4BZgFuAW4BZwESAWcBFAENARYBEwEUARcBEAEQASQBDwElASIBCgEoASIBEwElASIBIgEiASYBGAEQARsBGwG5ARIBEgEWAREBGgElAREBEQEPAQ0BFAEPARQBGQEZARQBFAEYARgBYQFhAW0DZgGgA6ADiAOcA8wDjwPMA8IDwwPaA9YDwwO3A7cD3APYA7IDlQFlAZMBYwHIA2sBZQGTAWcBlQGSAWcBmAGWAWoBjwEhAiICJAKLAR8CZgFkAWUBoAGYAZgBmAGYAZgBmAGiAagBnQGdAZ8BpwGnAacBpwGnAZ0BngGeAZ4BogGiAbkBuQGzAbMBvQG9ASUBrQGtAacBtAGwAbMBswGzAbMBrQG3AaoBpQGtAa0BrQGvAYQChAKCAoYCcwI9AD0APQA9AD0AaAI+AD4APgA+AD8APwA/AD4APgA+AD4APQA9AD0APQA9AD8APwBhAmECPwI/AkwEPgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD8APwA/AD8APwA/AD8APQA9AD0APQA9ADcBOAE4AToBOgEvAS8BLgEuAS4BLwEgASABIAEuAR0BHQEdAR0BGwEbARsBHgEeAR4BDgEOAQ4BDgEOAQ4BCgEPAQ8BAAEAAQ0BBgEGARgBGAEYAREBEQERAZUBFwEXAZIBDAEMAQwBDAGSAZIBjwFkAWQBZAFuAaYBhgGlAaUBpQFvAW8BbAFsAWABYAFgAWABYAFgAVsBagFqAWYBZgFcAWcBZwFnAWQBfgF+AXEBeAGAAYABeQF5AXkBeQF9AX0BfQF9AYMBfAF8AXwBfAGBAYEBgQGHAYcBegF6AXkBeQF5AXYBdgGAAYABgwGDAYMBgwGJAYkBdwGIAYgBgwGDAYMBegF7AXsBewF7AXsBgAGAAXsBgQGBAYEBgQGBAYEBlQGYAZ8BkQGRAZQBkwGPAYkBkAGpAKsAlgGjAakAngGmAKoAogCiAKsAnAGfAaAAnQCiAKQApgCiAKIAoQChAKEAoQDdAaEAnwCfAJ8AnwCfAO8AnwCfAJ8AnwCbAJsAmwCbAKcAnwCxAJ8AnwCoAKQAngCrAA==", + base: "xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/cX9xf3F/YP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/uL+4v7i/uL+4v7i/uL+4v7i/uL+4v7i/uL+4v7i/uL+rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/oP+g/6D/oP+g/6D/oP+g/6D/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/67/rv+u/67/rv+u/67/rv+4/7j/vP+8/7z/vP/z//P/8//z//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//b/9v/2//b/9v/3//f/9//3//f/9//0//T/4v/i/+L/4v/i/+L/4v/i//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/3//f/9//3//f/9//3//f/9//3/+7/7v/u/+7/7v/v/+//8v/y//L/8v/y//L/8v/y//L/8v/y//b/9v/2//b/9v/2//b/9v/1//X/9f/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/3//f/9//3//f/9//5//n/+f/5//n/+f/5//P/8//z//P/8//z//P/8//z//P/8//z//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/j/+P/4//j/+P/4//i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+P/4//j/+P/+P/4//j/+P/5//n/+f/5//n/+f/5//n/+f/5//j/+P/4//j/+P/4//j/+P/4//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//n/+f/5//n/+f/5//n/+f/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0/w==", +}; diff --git a/apps/site/src/scripts/skylines/isafjordur.ts b/apps/site/src/scripts/skylines/isafjordur.ts new file mode 100644 index 0000000..16bb1a0 --- /dev/null +++ b/apps/site/src/scripts/skylines/isafjordur.ts @@ -0,0 +1,12 @@ +// Generated by scripts/build-skyline.mjs. Do not edit. +// Ísafjörður: 66.075075 N, -23.124175 E, eye 2 m above ground (4.3 m). +// elev: Int16 little-endian, hundredths of a degree above the horizontal. +// dist: Uint16 little-endian, tens of metres to the ridge. +// base: Int16, hundredths of a degree: the far shore over water, or -40°. +export const SKYLINE = { + from: -80, + step: 0.2, + elev: "dwd4B3oHeweAB4wHmAekB7EHvgfKB9YH4wfwB/0HCggXCCQIMgg9CEkIVQhhCG0IdQh8CIMIiQiOCI8IjgiMCIsIiwiLCIwIigiJCIgIhwiGCIcIhgiGCIYIhwiJCIsIjQiNCIwIiwiLCIwIjQiMCIwIiwiLCIoIiQiHCIYIhQiFCIUIhgiICIoIjgiRCJMIkgiSCJQIlgiXCJcIlwiXCJcIlQiUCJMIlAiVCJgImwidCJ0ImwidCKAIpQinCKcIpgimCKcIqQisCK4IrgivCLAIsgizCLIIsgiwCLAIsQi0CLgIuQi3CLYItgi4CLoIvgi9CLoIuAi3CLYItwi3CLcIsgiuCK0IrAipCKkIqgirCK8IrwisCKkIqAioCKoIqwirCKcIpAihCJ8IngidCJkIlQiTCJEIjgiMCIoIigiJCIkIhwiFCIQIgwiECIUIhwiKCI4IkQiXCJwIngikCKkIqgisCK4IsAiyCLYItgixCKsIpgiiCJ4ImQiWCJEIjgiLCIUIggh9CHoIeAh0CHAIaghlCF8IXAhZCFYIVQhSCFEITAhJCEUIPwg5CDQIMAgtCCsIJAgjCCEIIQghCCAIHggfCB0IGwgbCBoIFggSCA4IDAgLCAsIBggDCAEIAAj/BwAIAggFCAcIAgj9B/kH+Af4B/gH8wfvB+0H6wfrB+kH5wflB+QH4gffB94H3gffB9wH2AfUB9MH0wfSB9AHzQfKB8UHwwfBB8AHwAe8B7QHrwerB6gHpgeeB5gHlAePB4wHiAeEB38HeAdzB2wHYgdVB0gHOwcuByEHFQcLBwcHBQcBB/0G+Qb3BvcG9wb5BvoG/Ab9Bv0G+gb4BvYG9AbxBu4G6wbqBucG4gbdBtgGzQbCBrYGqQaaBooGewZpBlgGUAZHBj8GOAYxBikGIQYcBhgGGAYbBh4GHgYfBh8GHQYZBhUGEQYLBgUG/wX0BeQF1QXDBagFjQV6BW0FZAVXBUsFQAU0BSkFIAUZBREFBQX6BO0E0ATCBKMEiwRwBFsEVgRPBEkEQQQ3BC0EHQQLBPcD2QO8A6oDnAONA3gDZwNXA0cDOAMpAx0DEAMBA/MC5QLYAssCuAKnApYChQJzAmECVAJDAjACHwIMAvwB6wHaAcgBuAGmAZcBiAF5AWsBXQFQAUEBNgEsASMBGQEQAQYB/ADzAOkA4ADXAM8AxgC9ALUArQClAJ0AlQCOAIYAfgB3AG4AZQBcAFIASQBAADwAPAA/AEUASQBKAEoASgBLAEwATQBOAE4ATgBOAE4ATQBLAEoARABGAEgASABGAD8AQABCAEIAQwBEAEYARwBIAEgASABIAEkASgBLAEkASABHAEUAQQBCAFEAZQBqAG4AdwB6AH8AggCEAIQAhACEAIMAhACEAIQAhQCFAIcAigCMAI0AkACRAJIAkQCSAJIAkwCUAJUAlwCXAJcAlgCWAJYAlQCVAJQAlwCbAJ0AnwCgAKMApACmAKgAqQCrAKwArwCwALEAsgCzALQAtQC2ALcAuAC5ALoAvAC9AL8AwADCAMIAwwDEAMUAxwDJAMoAywDLAMsAzADMAMwAzgDPANAA0QDTANQA1QDWANcA2ADZANkA2gDaANkA2QDYANcA1wDWANYA1QDVANUA1QDWANYA1wDYANgA2wDdAN8A4ADfAOAA4ADhAOEA4QDiAOIA4gDiAOAA3wDfAN4A3QDdANwA2wDYANkA2QDcANsA2wDeAN4A3QDcANoA3ADeAN8A4ADgAN8A3wDdANkA1wDVANQA0gDSANQA1gDWANIAzwDPAM4AzADMAM4AzwDQANMA1QDXANoA3QDdANwA2wDaANgA1wDWANQA0wDSAM4AyADEAMAAvgC8ALoAuQC0ALUAtAC0ALQAtACzALMAswCyALMAtAC0ALMArwCtAKoApwCnAKUApACpALMAvQDHANIA3ADmAPAA+gADAQ0BFwEfAScBLwE2AT8BRwFQAVoBZAFtAXcBgAGKAZQBnwGqAbYBwgHOAdkB5AHvAfgBAgILAhQCHgIpAjgCSAJbAm4CfwKNAp0CqgK3AsUCywLOAtECzgLNAswCzALUAuAC7AL2AgADCwMVAyADKgM0AzwDQgNIA1ADVwNgA2sDdgOCA4UDigOPA48DjQOLA4sDlgOjA68DuwPOA+ID9gMEBA4EGQQjBCsENAQ9BEAEQwRGBEgESQRKBEwETgRQBFQEVwRbBF8EYgRoBHEEewSFBJQEogStBLkExATMBNME2wTgBOQE6ATrBOoE6gTpBOcE6QTtBPIE9gT7BAIFBQUIBQwFEAUQBRMFFwUXBRkFHgUjBSgFLAUzBTgFOQU7BT0FPQU9BT0FPQU7BTgFNgU0BTEFLwUvBS8FMgU3BTwFPQU/BUIFRQVGBUYFRwVKBUsFSgVLBUwFTQVMBU4FTwVOBUwFSgVIBUcFRgVFBUIFQAVABUIFRgVGBUYFSgVQBVMFUQVQBVEFVAVXBVkFXAVhBWYFaQVrBW4FcgV2BXoFfgWDBYcFjAWRBZcFngWiBaYFqwWvBa8FrgWwBbYFuAW3BbkFvAXBBcMFxQXIBc0F0gXTBdUF1QXWBdcF2gXcBd8F4gXkBeUF5wXpBegF5wXpBesF6gXuBfQF+gUBBgUGDgYXBh0GIgYnBi4GMwY2Bg==", + dist: "zADMAMwAzACuAK4ArgCuAK4ArgCuAK4ArgCwALAAsACwALAArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4AsACwALAAsACwALAAsACuAK4ArgCwALAAsACwALAAsACwALAAsACwAK4AsACwALAAsACwALAAsACwALAAsACwALAAsACwALAAsACwALAAsACwAK4ArgCwALAAsACwALAAsACwALAArgCuALAAsACwALAAsACuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK4ArgCuAK4ArQCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCwALAAsACwALAAsACwALAAsACwALAAsACuAK4ArgCuAK4ArQCtAK0ArQCrAKsAqwCrAKoAqgCrAKsAqwCrAKsAqwCrAKsArQCtAK0ArgCuAK4AsACwALAAsACwALAAsACwALAAsQCxALEAsQCzALMAswC0ALQAtAC0ALQAtAC2ALYAtwC3ALcAtwC3ALcAtwC2ALYAtwC3ALcAtwC3ALcAtwC5ALkAuQC5ALkAuQC6ALoAuQC5ALkAuQC5ALkAugC6ALoAugC6ALoAugC8ALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8AL0AvQC9AL0AvQC9AL0AvwC/AL8AvwC/AMAAwADAAMAAwADCAMIAwgDCAMMAwwDFAMUAxQDFAMYAxgDGAMYAxgDGAMYAxgDFAMUAxQDFAMUAxQDGAMYAyADJAMkAyQDJAMkAywDLAMsAywDLAMwAzADOAM4AzgDMAMwAzADMAMwAzADMAMwAywDLANQA1QDXANcA2ADYANsA4ADjAOQA5gDmAOcA6QDpAOkA6gDsAOwA7ADsAOwA7ADtAO0A7QDwAPAA8wD2APwA/AD8AP4A/wD8APwA+QD5APkA+QD5APsA8ADvAO8ACAEIARABEQERAREBEQERAREBEQERAREBEQERARoBGgEaARoBFwEXARcBHQEXARcBFAEUARQBFAEUARQBFAERAREBEQERAREBGgEaARoBFgEUAQsBCwEKAQsBEQENAfAA8ADwAPAA8ADwAO8A1QDVANUA1QDVANUA1QDVANUA1QDDAMMAwgDCAMAAwADAAMAAwADCAMIAwgDCAMIAwgDCAMMAxQDFAKQLpAvHC9YL4AvgC+UL5QtHDDgMOAwtDC0MKAwjDCMMIwweDB4MGQwVDQ8NDw0EDeMNEw4ZDhMOEw4ZDhkOGQ4ZDhkOKw4lDjcORA5KDkQOPQ43DjEOKw6GDTsHLAcsBwoH7wbtBtgGzAbBBrsGtQa4BskGyQbPBtIGzAbqBoUGgwZ9BngGdQZyBm0GbQZqBmQGYgZfBlkGVwZXBlcGVAZRBlQGVAZXBhkHHAcfBxwHJgcyBywHLAcpBywHLwcvBy8HLwcpBykHKQcpBywHLAcvBy8HLwcvBzIHMgcvBywHHwccBxwHLwcsBzIHSgdEB0EHRAdEB0EHQQdEB04HgAeAB3oHdgdgB10HWgdXB1oHWgdXB1oHVAdUB1QHUQdRB1QHVwdXB1cHSgdHB0EHQQdBB0QHRAc+BzUHEwcQBw0HCgcWBxYHGQcZB3YHegd9B4AHgAeAB20HWgdUB0oHRAdBB0EHQQdEB0QHSgdOB1EHmgedB6cHqgetBz4HOAc1BzUHNQc1BzgHOwc7B0EHRwdOB04HXQdRB04HQQdBB0EHRAcpBy8HMgcvBy8HLAc+BzsHOwc+B0EHRAdHB0oHTgdUB1cHWgddB2MHZwdqB2oHZwdjB10HWgdOB0oHRwc+B/oH+gf6B/oH9wfzB/AH6QfiB8sHwQe6B7cHsQenB60HtwfVB9UH4gdJAUcBRwFGAUYBRgFEAUQBRAFDAUMBQwFBAUEBQQE+AT4BPgE+AT4BPgE7ATsBOAE4ATcBNwE3ATUBNQE1ATUBMgEyATIBMQExAS4BLgEsASsBKQEpASkBKQEmASYBJgElASUBJQElASUBJQEjASMBIwEdAR0BHAEcARwBHAEcARwBHAEcARwBHAEaARoBGgEaARoBGgEaARoBGQEXARcBFwEXARYBEQERAREBEAEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgENAQ0BDQENAQ0BDQEOAQ4BDQENAQ0BDQEIAQgBCAEHAQUBBQEFAQUBBQEFAQUBBQEFAQUBBQEFAQUBBQEFAQQBBAECAQIBAgECAQEBAQEBAQEB/wD/AP4A/gD+APwA/AD7APsA+wD5APkA+QD5APkA+AD4APgA+AD4APgA+QD5APkA+AD4APgA9gD2APYA9QD1APUA9QDzAPMA8gDyAPIA8ADwAPAA8ADvAO8A7wDvAO8A7wDvAPAA8ADwAPAA8ADvAO8A7wDvAO8A7QDtAO0A7QDtAOwA7ADsAOwA7ADqAOoA6gDqAOoA6QDpAOkA6QDpAOkA6QDpAOkA5wDnAOcA5wDnAOcA5wDmAOYA5gDmAOYA5gDmAOYA5gDmAOQA5ADkAOQA5ADkAOMA4wDjAOMA4wDjAOMA4wDjAOMA4wDkAOQA5ADjAOMA4wDjAOMA4ADgAOAA4ADgAOAA3gDeAA==", + base: "YPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8A==", +}; diff --git a/scripts/build-moon.mjs b/scripts/build-moon.mjs new file mode 100644 index 0000000..d57774e --- /dev/null +++ b/scripts/build-moon.mjs @@ -0,0 +1,56 @@ +// Bakes a small lunar albedo map for the aurora's moon from NASA SVS's CGI +// Moon Kit (LRO colour map, longitude 0 at the centre, east to the right). +// Writes apps/site/src/scripts/aurora.moon.ts: 128×64 greyscale, base64. +// +// Run: node scripts/build-moon.mjs +// Moon: NASA's Scientific Visualization Studio. + +import { writeFileSync } from "node:fs"; +import jpeg from "jpeg-js"; + +const SOURCE = + "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/lroc_color_poles_1k.jpg"; +const W = 128; +const H = 64; +const OUT = new URL("../apps/site/src/scripts/aurora.moon.ts", import.meta.url); + +const res = await fetch(SOURCE); +if (!res.ok) throw new Error(`moon map: HTTP ${res.status}`); +const img = jpeg.decode(Buffer.from(await res.arrayBuffer()), { + useTArray: true, +}); + +// Box-filter down to W×H luminance. +const out = new Uint8Array(W * H); +const sx = img.width / W; +const sy = img.height / H; +for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let sum = 0; + let n = 0; + for (let j = Math.floor(y * sy); j < Math.floor((y + 1) * sy); j++) { + for (let i = Math.floor(x * sx); i < Math.floor((x + 1) * sx); i++) { + const o = (j * img.width + i) * 4; + sum += + 0.2126 * img.data[o] + + 0.7152 * img.data[o + 1] + + 0.0722 * img.data[o + 2]; + n++; + } + } + out[y * W + x] = Math.round(sum / n); + } +} + +writeFileSync( + OUT, + `// Generated by scripts/build-moon.mjs from NASA SVS's CGI Moon Kit. Do not edit. +// ${W}×${H} greyscale albedo, equirectangular, longitude 0 at the centre. +export const MOON = { + width: ${W}, + height: ${H}, + data: "${Buffer.from(out).toString("base64")}", +}; +`, +); +console.log(`${W}×${H}, ${out.length} bytes`); diff --git a/scripts/build-skyline.mjs b/scripts/build-skyline.mjs new file mode 100644 index 0000000..b0ca488 --- /dev/null +++ b/scripts/build-skyline.mjs @@ -0,0 +1,248 @@ +// Builds the skyline seen from each site in aurora.sites.ts. For each azimuth +// it marches outward over the Copernicus DEM GLO-30 surface model (terrain, +// trees and buildings), optionally adds OpenStreetMap building heights, and +// keeps the highest elevation angle, allowing for Earth curvature and standard +// refraction. Where the site looks over water it also records the far shore, +// so the page can draw the water below it. +// Writes apps/site/src/scripts/skylines/.ts. +// +// Run: node scripts/build-skyline.mjs [site id ...] (default: all sites) +// Terrain: Copernicus DEM GLO-30 © DLR e.V. 2010–2014 and © Airbus Defence and +// Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA. +// Buildings: © OpenStreetMap contributors (ODbL). + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fromFile } from "geotiff"; +import { SITES } from "../apps/site/src/scripts/aurora.sites.ts"; + +const SPAN = 100; // degrees either side of the camera heading +const AZ_STEP = 0.2; +const FAR = 80_000; +const BUILDINGS_FAR = 12_000; // OSM buildings beyond this are below a pixel +const LAND = 1.5; // metres above sea level that count as land, not water +const NO_SHORE = -40; // degrees: the strip runs below the frame +const R_EARTH = 6_371_000; +const R_EFF = (R_EARTH * 7) / 6; // standard refraction +const OUT = new URL("../apps/site/src/scripts/skylines/", import.meta.url); +const DEG = 180 / Math.PI; + +const cache = join(tmpdir(), "northguild-dem"); +mkdirSync(cache, { recursive: true }); +mkdirSync(OUT, { recursive: true }); + +const tileName = (lat, lon) => + `${lat < 0 ? "S" : "N"}${String(Math.abs(lat)).padStart(2, "0")}_00_` + + `${lon < 0 ? "W" : "E"}${String(Math.abs(lon)).padStart(3, "0")}_00`; + +async function load(tile) { + const name = `Copernicus_DSM_COG_10_${tile}_DEM`; + const file = join(cache, `${name}.tif`); + if (!existsSync(file)) { + const res = await fetch( + `https://copernicus-dem-30m.s3.amazonaws.com/${name}/${name}.tif`, + ); + // Open sea has no tile: treat it as sea level. + if (res.status === 404) return null; + if (!res.ok) throw new Error(`${tile}: HTTP ${res.status}`); + writeFileSync(file, Buffer.from(await res.arrayBuffer())); + } + const image = await (await fromFile(file)).getImage(); + const [west, south, east, north] = image.getBoundingBox(); + const [data] = await image.readRasters(); + return { + west, + south, + east, + north, + width: image.getWidth(), + height: image.getHeight(), + data, + }; +} + +/** OSM building footprints with a height, cached per bounding box. */ +async function osmBuildings([south, west, north, east]) { + const file = join(cache, `osm-${south}-${west}-${north}-${east}.json`); + if (!existsSync(file)) { + const query = `[out:json][timeout:120];(way["building"](${south},${west},${north},${east}););out tags geom;`; + const res = await fetch("https://overpass-api.de/api/interpreter", { + method: "POST", + headers: { "User-Agent": "northguild-skyline/1.0" }, + body: new URLSearchParams({ data: query }), + }); + if (!res.ok) throw new Error(`overpass: HTTP ${res.status}`); + writeFileSync(file, await res.text()); + } + const { elements } = JSON.parse(readFileSync(file, "utf8")); + return elements + .filter((e) => e.geometry?.length) + .map((e) => { + const t = e.tags ?? {}; + const levels = Number.parseFloat(t["building:levels"]); + const height = + Number.parseFloat(t.height) || + (Number.isFinite(levels) ? levels * 3.2 + 1 : 10); + return { height, points: e.geometry }; + }); +} + +async function build(site) { + const latRad = site.lat / DEG; + const dLat = (FAR / R_EARTH) * DEG; + const dLon = dLat / Math.cos(latRad); + const names = []; + for ( + let la = Math.floor(site.lat - dLat); + la <= Math.floor(site.lat + dLat); + la++ + ) { + for ( + let lo = Math.floor(site.lon - dLon); + lo <= Math.floor(site.lon + dLon); + lo++ + ) { + names.push(tileName(la, lo)); + } + } + const tiles = (await Promise.all(names.map(load))).filter(Boolean); + + const height = (lat, lon) => { + for (const t of tiles) { + if (lon < t.west || lon >= t.east || lat <= t.south || lat > t.north) { + continue; + } + const x = ((lon - t.west) / (t.east - t.west)) * t.width - 0.5; + const y = ((t.north - lat) / (t.north - t.south)) * t.height - 0.5; + const x0 = Math.max(0, Math.min(t.width - 2, Math.floor(x))); + const y0 = Math.max(0, Math.min(t.height - 2, Math.floor(y))); + const fx = Math.min(1, Math.max(0, x - x0)); + const fy = Math.min(1, Math.max(0, y - y0)); + const at = (i, j) => t.data[(y0 + j) * t.width + x0 + i]; + const top = at(0, 0) * (1 - fx) + at(1, 0) * fx; + const bottom = at(0, 1) * (1 - fx) + at(1, 1) * fx; + return Math.max(0, top * (1 - fy) + bottom * fy); + } + return 0; + }; + + // Local metres east (x) and north (y) of the viewer. + const toLocal = (lat, lon) => [ + ((lon - site.lon) / DEG) * R_EARTH * Math.cos(latRad), + ((lat - site.lat) / DEG) * R_EARTH, + ]; + + const from = site.heading - SPAN; + const count = Math.round((2 * SPAN) / AZ_STEP) + 1; + // On a pier the eye height is above the sea; the model there may show a building. + const h0 = site.water ? site.eye : height(site.lat, site.lon) + site.eye; + const angleTo = (top, r) => Math.atan2(top - h0 - (r * r) / (2 * R_EFF), r); + // Over water the viewer's own pier may be in the model; start just past it. + const near = site.water ? 15 : 300; + + const best = new Float64Array(count).fill(-Infinity); + const bestR = new Float64Array(count).fill(FAR); + const shore = new Float64Array(count).fill(NO_SHORE / DEG); + + for (let i = 0; i < count; i++) { + const az = (from + i * AZ_STEP) / DEG; + let landAt = 0; + // Step grows with distance: 15 m near, about 130 m far. + for (let r = near; r <= FAR; r += Math.max(15, r / 600)) { + const lat = site.lat + (r * Math.cos(az) * DEG) / R_EARTH; + const lon = + site.lon + (r * Math.sin(az) * DEG) / (R_EARTH * Math.cos(latRad)); + const z = height(lat, lon); + if (!landAt && z > LAND) landAt = r; + const angle = angleTo(z, r); + if (angle > best[i]) { + best[i] = angle; + bestR[i] = r; + } + } + // Over water the strip stops at the first land, however near: the + // island's own shore curves down toward the frame. + if (site.water && landAt) shore[i] = angleTo(0, landAt); + } + + if (site.buildings) { + const box = [ + site.lat - 0.11, + site.lon - 0.22, + site.lat + 0.11, + site.lon + 0.22, + ].map((v) => Math.round(v * 1000) / 1000); + const buildings = await osmBuildings(box); + let used = 0; + for (const { height: tall, points } of buildings) { + const local = points.map((p) => toLocal(p.lat, p.lon)); + const dists = local.map(([x, y]) => Math.hypot(x, y)); + const r = Math.min(...dists); + if (r < 50 || r > BUILDINGS_FAR) continue; + // Ground under the building: the lowest surface-model value at its corners. + const ground = Math.min(...points.map((p) => height(p.lat, p.lon))); + const angle = angleTo(ground + tall, r); + const azs = local.map(([x, y]) => Math.atan2(x, y) * DEG); + const lo = Math.min(...azs); + const hi = Math.max(...azs); + if (hi - lo > 90) continue; // straddles the bearing behind the viewer + for ( + let i = Math.max(0, Math.ceil((lo - from) / AZ_STEP)); + i <= Math.min(count - 1, Math.floor((hi - from) / AZ_STEP)); + i++ + ) { + if (angle > best[i]) { + best[i] = angle; + bestR[i] = r; + } + } + used++; + } + console.log( + `${site.id}: ${used} OSM buildings within ${BUILDINGS_FAR / 1000} km`, + ); + } + + const elev = Int16Array.from(best, (a) => Math.round(a * DEG * 100)); + const dist = Uint16Array.from(bestR, (r) => + Math.min(65535, Math.round(r / 10)), + ); + const base = Int16Array.from(shore, (a) => Math.round(a * DEG * 100)); + + const b64 = (a) => Buffer.from(a.buffer).toString("base64"); + writeFileSync( + new URL(`${site.id}.ts`, OUT), + `// Generated by scripts/build-skyline.mjs. Do not edit. +// ${site.name}: ${site.lat} N, ${site.lon} E, eye ${site.eye} m above ground (${h0.toFixed(1)} m). +// elev: Int16 little-endian, hundredths of a degree above the horizontal. +// dist: Uint16 little-endian, tens of metres to the ridge. +// base: Int16, hundredths of a degree: the far shore over water, or ${NO_SHORE}°. +export const SKYLINE = { + from: ${from}, + step: ${AZ_STEP}, + elev: "${b64(elev)}", + dist: "${b64(dist)}", + base: "${b64(base)}", +}; +`, + ); + + const deg = Array.from(elev, (v) => v / 100); + console.log( + `${site.id}: ground ${h0.toFixed(1)} m, ${tiles.length} tiles, ${count} azimuths, ` + + `elevation ${Math.min(...deg)}° to ${Math.max(...deg)}°`, + ); + for (let az = from; az <= from + 2 * SPAN; az += 10) { + const i = Math.round((az - from) / AZ_STEP); + console.log( + ` ${String(az).padStart(4)}° ${deg[i].toFixed(2).padStart(6)}° ` + + `${(dist[i] / 100).toFixed(1).padStart(5)} km shore ${(base[i] / 100).toFixed(2)}°`, + ); + } +} + +const wanted = process.argv.slice(2); +for (const site of SITES) { + if (!wanted.length || wanted.includes(site.id)) await build(site); +} diff --git a/scripts/build-stars.mjs b/scripts/build-stars.mjs new file mode 100644 index 0000000..5dc3794 --- /dev/null +++ b/scripts/build-stars.mjs @@ -0,0 +1,58 @@ +// Builds apps/site/public/stars.bin from the Yale Bright Star Catalogue, +// 5th revised edition (Hoffleit & Warren 1991, CDS V/50): naked-eye stars +// (V <= 5.5), J2000 positions. Precession to the current date happens at +// runtime in the aurora worker. +// +// Run: node scripts/build-stars.mjs +// Record, 6 bytes little-endian: ra uint16 (0..65536 = 0..360°), +// dec int16 (±32767 = ±90°), vmag uint8 ((V + 2) × 20), b-v int8 (× 50). + +import { writeFileSync } from "node:fs"; +import { gunzipSync } from "node:zlib"; + +const SOURCE = "http://tdc-www.harvard.edu/catalogs/bsc5.dat.gz"; +const LIMIT = 5.5; +const OUT = new URL("../apps/site/public/stars.bin", import.meta.url); + +const res = await fetch(SOURCE); +if (!res.ok) throw new Error(`catalogue: HTTP ${res.status}`); +const text = gunzipSync(Buffer.from(await res.arrayBuffer())).toString( + "latin1", +); + +// Byte ranges from the catalogue ReadMe (1-based, inclusive). +const field = (line, from, to) => line.slice(from - 1, to).trim(); + +const stars = []; +for (const line of text.split("\n")) { + const raH = field(line, 76, 77); + const vmag = field(line, 103, 107); + // Blank coordinates are entries removed from the catalogue (novae, clusters). + if (!raH || !vmag) continue; + const v = Number(vmag); + if (v > LIMIT) continue; + const ra = + (Number(raH) + + Number(field(line, 78, 79)) / 60 + + Number(field(line, 80, 83)) / 3600) * + 15; + const sign = field(line, 84, 84) === "-" ? -1 : 1; + const dec = + sign * + (Number(field(line, 85, 86)) + + Number(field(line, 87, 88)) / 60 + + Number(field(line, 89, 90)) / 3600); + const bv = Number(field(line, 110, 114) || "0.6"); + stars.push({ ra, dec, v, bv }); +} + +const buf = Buffer.alloc(stars.length * 6); +stars.forEach(({ ra, dec, v, bv }, i) => { + const o = i * 6; + buf.writeUInt16LE(Math.round((ra / 360) * 65536) % 65536, o); + buf.writeInt16LE(Math.round((dec / 90) * 32767), o + 2); + buf.writeUInt8(Math.max(0, Math.min(255, Math.round((v + 2) * 20))), o + 4); + buf.writeInt8(Math.max(-127, Math.min(127, Math.round(bv * 50))), o + 5); +}); +writeFileSync(OUT, buf); +console.log(`${stars.length} stars, ${buf.length} bytes`); From b0312897d950736195a41cb904d8a81287d3a8db Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Tue, 20 Oct 2026 21:06:22 +0300 Subject: [PATCH 4/6] =?UTF-8?q?feat(site):=20the=20real=20night=20sky=20ov?= =?UTF-8?q?er=20=C3=8Dsafj=C3=B6r=C3=B0ur=20and=20Helsinki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WebGL background on the landing page, rendered in a Web Worker on an OffscreenCanvas so the main thread does no WebGL work. - Aurora as a thin emissive curtain about 100 km up, seen in perspective (Lawlor & Genetti: a ground footprint times a height profile), marched over a curved Earth. Rays converge toward the magnetic zenith; folds, bright knots and dark gaps vary along the arc. Simulated, not live data. - Real stars for this moment, precessed to date; dimmer near the horizon and when the moon is up. - The moon at true size, with its real position (topocentric) and phase lit by the real sun; it sets behind the mountains. - Mountains (Ísafjörður) or the city skyline with lights and the sea in front (Helsinki), with the sky and city lights reflected in the water. - A faint info button opens a popover with the site, its credits and a location switcher. Each visit starts at a random site. - Reduced motion draws one still frame. Slow GPUs step down in quality and finally fall back to the CSS gradient. --- apps/site/src/components/Aurora.astro | 324 ++++++++++++++ apps/site/src/pages/index.astro | 2 + apps/site/src/scripts/aurora.shaders.ts | 364 ++++++++++++++++ apps/site/src/scripts/aurora.sky.ts | 244 +++++++++++ apps/site/src/scripts/aurora.worker.ts | 543 ++++++++++++++++++++++++ 5 files changed, 1477 insertions(+) create mode 100644 apps/site/src/components/Aurora.astro create mode 100644 apps/site/src/scripts/aurora.shaders.ts create mode 100644 apps/site/src/scripts/aurora.sky.ts create mode 100644 apps/site/src/scripts/aurora.worker.ts diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro new file mode 100644 index 0000000..43d4b9d --- /dev/null +++ b/apps/site/src/components/Aurora.astro @@ -0,0 +1,324 @@ +--- +import { + SITES, + SKY_CREDITS, + compass, + coords, +} from "../scripts/aurora.sites"; +--- + + + + + +
+
+ Seen from + { + SITES.map((site, i) => ( + + )) + } +
+ { + SITES.map((site, i) => ( + + )) + } +
+ { + SKY_CREDITS.map(([what, source]) => ( + <> +
{what}
+
{source}
+ + )) + } +
+
+ + + + diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 786b77f..6928e46 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -1,5 +1,6 @@ --- import Base from "../layouts/Base.astro"; +import Aurora from "../components/Aurora.astro"; import RepoCard from "../components/RepoCard.astro"; // Descriptions are each package's own `package.json` `description`, verbatim. @@ -22,6 +23,7 @@ const repos = [ --- +

NorthGuild

Open-source tools for developers, built in the frigid north.

diff --git a/apps/site/src/scripts/aurora.shaders.ts b/apps/site/src/scripts/aurora.shaders.ts new file mode 100644 index 0000000..70cfccd --- /dev/null +++ b/apps/site/src/scripts/aurora.shaders.ts @@ -0,0 +1,364 @@ +// GLSL for the aurora worker. Units: kilometres and seconds. World frame: +// x east, y up, z north, viewer at the origin. +// +// The aurora follows Lawlor & Genetti: a thin emissive sheet whose ground +// track (the "footprint") is a 2D map, times a height profile. Each curtain's +// track is a curve z(x), so the footprint pass writes one texture row per +// curtain: its path, brightness along the arc and ray height. The sky pass +// marches altitudes from the lower border up and measures the distance to +// that path at each step. + +export const MAP_W = 2048; +export const MAP_H = 4; +export const CURTAINS = 2; + +export const COMMON = ` +precision highp float; +#define MAP_W ${MAP_W}.0 +#define MAP_H ${MAP_H}.0 +#define CURTAINS ${CURTAINS} +uniform float uT; +uniform vec3 uSpring, uCyan, uPurple; +// Ground shift per km of altitude along a field line (toward magnetic south). +uniform vec2 uTilt; +const float H0 = 100.0; // lower border, km +const float RE = 6371.0; // Earth radius, km +const float SIG = 8.0; // sheet half-thickness (gaussian sigma), km +float tt; +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +float noise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + 1.0), f.x), f.y); +} +// Per site, per curtain: base distance north, then the x range drawn +// (centre, half-width), all km. +uniform vec3 uC0, uC1; +vec3 curtain(float k) { return k < 0.5 ? uC0 : uC1; } +vec2 span(float k) { return curtain(k).yz; } +// Ground distance north of the viewer: a base plus folds at three scales. +float path(float x, float k) { + float s = 1.0 + 0.6 * k; + float z = curtain(k).x; + z += 90.0 * s * (noise(vec2(x / 420.0 + k * 7.0, tt * 0.0012 + k)) - 0.5); + z += 35.0 * s * (noise(vec2(x / 140.0 + k * 3.0, tt * 0.003 + 2.0 * k)) - 0.5); + z += 10.0 * (noise(vec2(x / 45.0 + k * 11.0, tt * 0.008)) - 0.5); + return z; +} +// Long bright stretches and dark gaps; a faint arc is always left. +float lit(float x, float k) { + vec2 q = vec2(x / 260.0 - tt * 0.002, k * 9.0 + tt * 0.003); + float n = 0.62 * noise(q) + 0.31 * noise(q * 2.03 + 17.0); + return 0.08 + 0.92 * smoothstep(0.38, 0.62, n); +} +float knots(float x, float k) { + return 0.45 + 0.55 * noise(vec2(x / 55.0 + tt * 0.004, k * 5.0 + tt * 0.01)); +} +// How rayed (rather than smooth) this stretch of the arc is. +float rayed(float x, float k) { + return smoothstep(0.3, 0.7, noise(vec2(x / 180.0 + k * 4.0, tt * 0.003 + k))); +} +// Fine striations along the arc, drifting about 0.5 km/s. +float rays(float x, float k, float c) { + float r = 0.6 * noise(vec2(x / 3.5 + tt * 0.15, k * 13.0 + tt * 0.02)) + + 0.4 * noise(vec2(x / 1.6 - tt * 0.1, k * 17.0)); + return mix(1.0, r * 1.4, c); +} +float rayHeight(float x, float k) { + return rayed(x, k) * (0.3 + 0.7 * noise(vec2(x / 90.0 + k, tt * 0.002))); +} +// Colour by altitude: bright spring-cyan at the border, cyan to blue above, +// faint purple at the top. +vec3 ramp(float h) { + vec3 blue = mix(uCyan, uPurple, 0.5); + vec3 c = mix(mix(uSpring, uCyan, 0.55), uCyan, smoothstep(100.0, 125.0, h)); + c = mix(c, blue, smoothstep(125.0, 180.0, h)); + return mix(c, uPurple, smoothstep(180.0, 260.0, h)); +} +`; + +export const QUAD_VS = ` +attribute vec2 aPos; +varying vec2 vUv; +void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); } +`; + +// Rows 2k: path z as ±200 km around the base (two bytes), sqrt of fine +// brightness, ray height. +// Rows 2k+1: sqrt of coarse brightness (no rays), for far-away pixels. +export const FOOT_FS = `${COMMON} +varying vec2 vUv; +void main() { + tt = uT; + float row = floor(vUv.y * MAP_H); + float k = floor(row / 2.0); + vec2 sp = span(k); + float x = sp.x + (vUv.x * 2.0 - 1.0) * sp.y; + float fade = smoothstep(0.0, 0.08 * sp.y, sp.y - abs(x - sp.x)); + float zn = clamp((path(x, k) - curtain(k).x + 200.0) / 400.0, 0.0, 1.0); + float coarse = lit(x, k) * knots(x, k) * fade; + if (row - 2.0 * k < 0.5) { + float fine = coarse * rays(x, k, rayed(x, k)); + gl_FragColor = vec4(floor(zn * 255.0) / 255.0, fract(zn * 255.0), + sqrt(min(fine / 1.5, 1.0)), rayHeight(x, k)); + } else { + gl_FragColor = vec4(sqrt(coarse), 0.0, 0.0, 1.0); + } +} +`; + +export const skyFs = (steps: number, top: number) => `${COMMON} +#define STEPS ${steps} +uniform sampler2D uMap, uMoonTex; +uniform vec3 uVoid, uTeal, uIce; +uniform mat3 uCam; +uniform vec2 uTan; +uniform float uPxAng; +uniform vec4 uMoon; // direction, angular radius +uniform vec3 uMoonN, uSun; +uniform float uMoonlight; +uniform float uGlow; // city light near the horizon, 0..1 +uniform float uLights; // lit windows and streets, 0..1 +varying vec2 vUv; +const float H1 = ${top}.0; +const float PI = 3.14159265; + +// Path of curtain k at x: (z, dz/dx, fine brightness, ray height). Two +// fetches and a manual lerp, since the two-byte z cannot be filtered. +vec4 fetchPath(float x, float k) { + vec2 sp = span(k); + float u = clamp((x - sp.x + sp.y) / (2.0 * sp.y), 0.0, 1.0) * MAP_W - 0.5; + float i = floor(u), f = u - i; + float v = (2.0 * k + 0.5) / MAP_H; + vec4 a = texture2D(uMap, vec2((i + 0.5) / MAP_W, v)); + vec4 b = texture2D(uMap, vec2((i + 1.5) / MAP_W, v)); + float z0 = curtain(k).x - 200.0; + float za = z0 + 400.0 * (a.r + a.g / 255.0); + float zb = z0 + 400.0 * (b.r + b.g / 255.0); + float bf = mix(a.b, b.b, f); + return vec4(mix(za, zb, f), (zb - za) * MAP_W / (2.0 * sp.y), 1.5 * bf * bf, + mix(a.a, b.a, f)); +} +float fetchCoarse(float x, float k) { + vec2 sp = span(k); + float c = texture2D(uMap, vec2((x - sp.x + sp.y) / (2.0 * sp.y), + (2.0 * k + 1.5) / MAP_H)).r; + return c * c; +} +// Sharp lower border, then an exponential tail whose height grows with g. +float profile(float h, float g) { + return smoothstep(94.0, 106.0, h) * exp(-max(h - 106.0, 0.0) / (25.0 + 45.0 * g)); +} +vec3 moon(vec3 rd) { + float cm = dot(rd, uMoon.xyz); + if (cm < cos(uMoon.w * 3.0) || uMoon.y < -0.02) return vec3(0.0); + vec3 o = (rd - uMoon.xyz * cm) / sin(uMoon.w); + float r = length(o); + float px = uPxAng / uMoon.w; + float cover = 1.0 - smoothstep(1.0 - px, 1.0 + px, r); + vec3 n = o - uMoon.xyz * sqrt(max(0.0, 1.0 - r * r)); + vec3 north = normalize(uMoonN - uMoon.xyz * dot(uMoonN, uMoon.xyz)); + vec3 west = cross(north, uMoon.xyz); + float lat = asin(clamp(dot(n, north), -1.0, 1.0)); + float lon = atan(dot(n, west), dot(n, -uMoon.xyz)); + float alb = texture2D(uMoonTex, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI)).r; + float day = max(dot(n, uSun), 0.0); + float halo = exp(-(1.0 - cm) / (uMoon.w * uMoon.w * 6.0)) * 0.06; + float full = 0.5 - 0.5 * dot(uSun, uMoon.xyz); + return uIce * (cover * alb * (2.2 * day + 0.03) + halo * full); +} +// Sky seen along rd (above the horizon): background, city glow, moonlight, +// the aurora and the moon. Before tone mapping. j is the step jitter. +vec3 skyColour(vec3 rd, float j) { + float c2 = dot(rd.xz, rd.xz); + vec3 acc = vec3(0.0); + for (int i = 0; i < STEPS; i++) { + float s = (float(i) + j) / float(STEPS); + float h = H0 - 6.0 + (H1 - H0 + 6.0) * s * s; + float dh = (H1 - H0 + 6.0) * (2.0 * s + 1.0 / float(STEPS)) / float(STEPS); + // Distance along the ray to altitude h over a curved Earth. + float t = min(2.0 * h / (rd.y + sqrt(rd.y * rd.y + 2.0 * c2 * h / RE)), 2500.0); + float dt = dh / max(rd.y + c2 * t / RE, 0.03); + vec2 g = rd.xz * t - uTilt * (h - H0); + vec2 dg = rd.xz * dt - uTilt * dh; + float pxKm = t * uPxAng; + float dens = 0.0, gh = 0.0; + for (int k = 0; k < CURTAINS; k++) { + float fk = float(k); + vec4 m = fetchPath(g.x, fk); + vec2 n = normalize(vec2(-m.y, 1.0)); + float d = (g.y - m.x) * n.y; + // Widen the sheet by this step's travel across it, keeping its integral. + float dn = abs(dot(dg, n)) + pxKm; + float s2 = SIG * SIG + dn * dn / 12.0; + float bc = fetchCoarse(g.x, fk); + float b = mix(m.z, bc, smoothstep(1.5, 4.0, pxKm / 3.0)); + dens += b * exp(-0.5 * d * d / s2) * SIG * inversesqrt(s2) + + bc * 0.1 * exp(-0.5 * d * d / (16.0 * SIG * SIG)); + gh = max(gh, m.w); + } + acc += ramp(h) * dens * profile(h, gh) * dt; + } + float e = max(rd.y, 0.0); + float air = exp(-0.12 * (1.0 / (e + 0.04) - 1.0)); + vec3 col = uVoid + uTeal * 0.05 * exp(-e * 8.0) + + uGlow * (uTeal * 0.09 * exp(-e * 9.0) + uIce * 0.035 * exp(-e * 22.0)) + + uMoonlight * (uIce * 0.02 + uTeal * 0.05) * (0.5 + 0.5 * exp(-e * 3.0)); + return col + (acc * 0.035 + moon(rd)) * air; +} +// City lights along the shore, by azimuth bin: on or off, and how bright. +float shoreLight(float az) { + float cell = floor(az * 950.0); + float h = hash(vec2(cell, 3.7)); + float f = fract(az * 950.0) - 0.5; + return step(1.0 - 0.16 * uLights, h) * fract(h * 41.3) * max(0.0, 1.0 - abs(f) * 2.4); +} +void main() { + vec2 ndc = vUv * 2.0 - 1.0; + vec3 rd = normalize(uCam * vec3(ndc * uTan, 1.0)); + // Static per-pixel jitter: breaks banding without shimmer. + float j = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))); + vec3 col; + if (rd.y >= 0.0) { + col = skyColour(rd, j); + } else { + // Water: the sky mirrored and broken by slow ripples that crowd toward + // the horizon; strongest at grazing angles (Fresnel). + float d = -rd.y; + float az = atan(rd.x, rd.z); + vec2 q = vec2(az * 700.0, 0.06 / d); + float w = noise(q + vec2(0.0, uT * 0.35)) + 0.5 * noise(q * 2.3 - vec2(0.0, uT * 0.25)) - 0.75; + vec3 rr = normalize(vec3(rd.x, d * max(0.15, 1.0 + 0.9 * w), rd.z)); + float fresnel = 0.02 + 0.98 * pow(1.0 - d, 5.0); + col = uVoid * 0.5 + skyColour(rr, j) * fresnel * 0.85; + // City lights stretched into broken vertical streaks. + float streak = shoreLight(az + w * 0.0004) * exp(-d * 45.0) + * smoothstep(0.35, 0.75, noise(vec2(az * 900.0, d * 260.0 - uT * 0.6))); + col += uIce * streak * 0.35; + } + col = 1.0 - exp(-col * 1.7); + col += (hash(gl_FragCoord.xy) - 0.5) / 255.0; + gl_FragColor = vec4(col, 1.0); +} +`; + +const PROJECT = ` +uniform mat3 uCam; +uniform vec2 uTan; +// Camera space is (right, up, forward); clip w is the forward distance, so +// anything behind the viewer is clipped. +vec4 project(vec3 p) { + vec3 c = p * uCam; + return vec4(c.xy / uTan, 0.0, c.z); +} +`; + +// Electrons spiralling down field lines and flaring where they reach the +// lower border. Each lands at the curtain's shape at its landing time, so it +// stays put while the curtain drifts on. +export const SPARK_VS = `${COMMON}${PROJECT} +attribute vec4 aSeed; +uniform float uPx; +varying vec3 vCol; +void main() { + float k = aSeed.z < 0.6 ? 0.0 : 1.0; + float life = 10.0 + 10.0 * fract(aSeed.z * 7.13); + float ph = fract(uT / life + aSeed.y); + vec2 sp = span(k); + float x0 = sp.x + (aSeed.x * 2.0 - 1.0) * sp.y * 0.9; + float lift = 1.0 + 12.0 * pow(fract(aSeed.w * 13.7), 2.0); + tt = uT + (0.4 - ph) * life; + float z0 = path(x0, k) + (fract(aSeed.w * 5.3) - 0.5) * 2.0 * SIG; + float bright = lit(x0, k) * knots(x0, k); + tt = uT; + float hit = H0 + lift; + float h = mix(420.0, hit, smoothstep(0.0, 0.4, ph)); + float spin = uT * (2.0 + 3.0 * aSeed.w) + aSeed.y * 40.0; + vec2 g = vec2(x0, z0) + uTilt * (h - H0) + + 0.03 * (h - hit) * vec2(cos(spin), sin(spin)); + vec4 c = project(vec3(g.x, h - dot(g, g) / (2.0 * RE), g.y)); + float glow = smoothstep(0.45, 0.65, ph) * (1.0 - smoothstep(0.75, 1.0, ph)); + vCol = mix(uCyan, ramp(hit), glow) * 0.3 * glow * bright; + gl_PointSize = 1.3 * clamp(250.0 / max(c.w, 1.0), 0.6, 2.0) * uPx; + gl_Position = c; +} +`; + +// Catalogue stars: J2000 directions turned into the sky of date. Fainter +// near the horizon (extinction) and when the moon brightens the sky. They +// twinkle; they never move except with the sky itself. +export const STAR_VS = ` +precision highp float; +${PROJECT} +attribute vec3 aDir; +attribute vec2 aMag; +uniform mat3 uStars; +uniform vec3 uIce, uCyan; +uniform float uPx, uT, uLimit, uFade; +varying vec3 vCol; +void main() { + vec3 w = uStars * aDir; + float alt = w.y; + float m = aMag.x + 0.12 * (1.0 / (max(alt, 0.0) + 0.04) - 1.0); + float b = pow(2.512, -m); + float a = min(2.6 * sqrt(b), 1.6) * smoothstep(uLimit + 0.3, uLimit - 0.6, m) * uFade; + float seed = fract(sin(dot(aDir.xy, vec2(91.7, 37.3)) + aDir.z * 11.1) * 4375.85); + float amp = 0.1 + 0.3 * (1.0 - smoothstep(0.05, 0.5, alt)); + float tw = 1.0 + amp * sin(uT * (1.1 + 2.0 * seed) + seed * 60.0) + * sin(uT * 0.37 + seed * 17.0); + vCol = mix(uIce, uCyan, 0.35 * smoothstep(0.4, -0.1, aMag.y)) * a * tw; + gl_PointSize = uPx * (1.6 + 1.4 * min(sqrt(b), 1.2)); + gl_Position = alt > 0.0 ? project(w) : vec4(0.0, 0.0, 2.0, 1.0); +} +`; + +export const POINT_FS = ` +precision mediump float; +varying vec3 vCol; +void main() { + vec2 q = gl_PointCoord * 2.0 - 1.0; + float m = max(0.0, 1.0 - dot(q, q)); + gl_FragColor = vec4(vCol * m * m, 1.0); +} +`; + +// The skyline as a strip of directions from the ridge down below the frame, +// drawn last so it hides sky, stars and sparks. +export const RIDGE_VS = ` +precision highp float; +${PROJECT} +attribute vec4 aRidge; // direction, haze 0..1 (negative on the bottom edge) +varying float vHaze, vDown; +varying vec3 vDir; +void main() { + vDown = step(aRidge.w, -0.5); + vHaze = mix(aRidge.w, -1.0 - aRidge.w, vDown); + vDir = aRidge.xyz; + gl_Position = project(aRidge.xyz); +} +`; + +export const RIDGE_FS = ` +precision highp float; +uniform vec3 uVoid, uTeal, uIce; +uniform float uMoonlight, uLights; +varying float vHaze, vDown; +varying vec3 vDir; +float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } +void main() { + // Distant ridges are paler at the top, fading within a degree or so. + float rim = vHaze * exp(-vDown * 60.0); + vec3 col = uVoid * 0.45 + (uTeal * 0.035 + uIce * 0.006) * rim + + uIce * 0.02 * uMoonlight; + // A scatter of lit windows and street lights on a city skyline. + vec3 d = normalize(vDir); + vec2 g = vec2(atan(d.x, d.z) * 950.0, asin(d.y) * 1100.0); + float h = hash(floor(g)); + vec2 f = fract(g) - 0.5; + // Only past the first kilometre: the viewer's own surroundings stay dark. + float on = step(1.0 - 0.13 * uLights, h) * step(0.02, vDown) * step(0.003, vHaze); + col += uIce * on * exp(-dot(f, f) * 14.0) * (0.25 + 0.5 * fract(h * 37.0)); + gl_FragColor = vec4(1.0 - exp(-col * 1.7), 1.0); +} +`; diff --git a/apps/site/src/scripts/aurora.sky.ts b/apps/site/src/scripts/aurora.sky.ts new file mode 100644 index 0000000..17015e8 --- /dev/null +++ b/apps/site/src/scripts/aurora.sky.ts @@ -0,0 +1,244 @@ +// What the real sky looks like from a site right now: sidereal time, +// precession, and the sun and moon. Vectors use the scene's world frame: +// x east, y up, z north. Angles in radians unless named *Deg. +// Moon and sun follow Meeus, "Astronomical Algorithms" (2nd ed.), ch. 25 and +// 47, with the larger periodic terms only (moon good to about 0.05°). + +import type { Site } from "./aurora.sites"; + +export type Mat3 = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, +]; +export type Vec3 = [number, number, number]; + +const RAD = Math.PI / 180; +const ARCSEC = RAD / 3600; + +const julian = (ms: number) => ms / 86400000 + 2440587.5; + +/** Row-major 3×3 product a·b. */ +function mul(a: Mat3, b: Mat3): Mat3 { + return Array.from({ length: 9 }, (_, k) => { + const i = Math.floor(k / 3) * 3; + const j = k % 3; + return a[i]! * b[j]! + a[i + 1]! * b[3 + j]! + a[i + 2]! * b[6 + j]!; + }) as Mat3; +} + +function apply(m: Mat3, v: Vec3): Vec3 { + return [ + m[0] * v[0] + m[1] * v[1] + m[2] * v[2], + m[3] * v[0] + m[4] * v[1] + m[5] * v[2], + m[6] * v[0] + m[7] * v[1] + m[8] * v[2], + ]; +} + +/** Column-major copy for gl.uniformMatrix3fv. */ +export const columns = (m: Mat3) => + new Float32Array([m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]]); + +/** J2000 equatorial to mean equator of date (IAU 1976 precession). */ +function precession(t: number): Mat3 { + const zeta = (2306.2181 * t + 0.30188 * t * t + 0.017998 * t ** 3) * ARCSEC; + const z = (2306.2181 * t + 1.09468 * t * t + 0.018203 * t ** 3) * ARCSEC; + const th = (2004.3109 * t - 0.42665 * t * t - 0.041833 * t ** 3) * ARCSEC; + const [cz, sz] = [Math.cos(zeta), Math.sin(zeta)]; + const [cZ, sZ] = [Math.cos(z), Math.sin(z)]; + const [ct, st] = [Math.cos(th), Math.sin(th)]; + return [ + cz * cZ * ct - sz * sZ, + -sz * cZ * ct - cz * sZ, + -cZ * st, + cz * sZ * ct + sz * cZ, + -sz * sZ * ct + cz * cZ, + -sZ * st, + cz * st, + -sz * st, + ct, + ]; +} + +/** Equatorial of date to the world frame, for local sidereal time lst. */ +function horizon(lat: number, lst: number): Mat3 { + const [cl, sl] = [Math.cos(lst), Math.sin(lst)]; + const [cp, sp] = [Math.cos(lat), Math.sin(lat)]; + return [-sl, cl, 0, cp * cl, cp * sl, sp, -sp * cl, -sp * sl, cp]; +} + +const ecliptic = (lon: number, lat: number, eps: number): Vec3 => { + const [cb, sb] = [Math.cos(lat), Math.sin(lat)]; + const [cl, sl] = [Math.cos(lon), Math.sin(lon)]; + const [ce, se] = [Math.cos(eps), Math.sin(eps)]; + return [cb * cl, cb * sl * ce - sb * se, cb * sl * se + sb * ce]; +}; + +// Meeus table 47.A: D, M, M', F, longitude (1e-6°), distance (1e-3 km). +const MOON_LR: number[][] = [ + [0, 0, 1, 0, 6288774, -20905355], + [2, 0, -1, 0, 1274027, -3699111], + [2, 0, 0, 0, 658314, -2955968], + [0, 0, 2, 0, 213618, -569925], + [0, 1, 0, 0, -185116, 48888], + [0, 0, 0, 2, -114332, -3149], + [2, 0, -2, 0, 58793, 246158], + [2, -1, -1, 0, 57066, -152138], + [2, 0, 1, 0, 53322, -170733], + [2, -1, 0, 0, 45758, -204586], + [0, 1, -1, 0, -40923, -129620], + [1, 0, 0, 0, -34720, 108743], + [0, 1, 1, 0, -30383, 104755], + [2, 0, 0, -2, 15327, 10321], + [0, 0, 1, 2, -12528, 0], + [0, 0, 1, -2, 10980, 79661], + [4, 0, -1, 0, 10675, -34782], + [0, 0, 3, 0, 10034, -23210], + [4, 0, -2, 0, 8548, -21636], + [2, 1, -1, 0, -7888, 24208], + [2, 1, 0, 0, -6766, 30824], + [1, 0, -1, 0, -5163, -8379], + [1, 1, 0, 0, 4987, -16675], + [2, -1, 1, 0, 4036, -12831], + [2, 0, 2, 0, 3994, -10445], + [4, 0, 0, 0, 3861, -11650], + [2, 0, -3, 0, 3665, 14403], + [0, 1, -2, 0, -2689, -7003], + [2, 0, -1, 2, -2602, 0], + [2, -1, -2, 0, 2390, 10056], + [1, 0, 1, 0, -2348, 6322], + [2, -2, 0, 0, 2236, -9884], +]; +// Meeus table 47.B: D, M, M', F, latitude (1e-6°). +const MOON_B: number[][] = [ + [0, 0, 0, 1, 5128122], + [0, 0, 1, 1, 280602], + [0, 0, 1, -1, 277693], + [2, 0, 0, -1, 173237], + [2, 0, -1, 1, 55413], + [2, 0, -1, -1, 46271], + [2, 0, 0, 1, 32573], + [0, 0, 2, 1, 17198], + [2, 0, 1, -1, 9266], + [0, 0, 2, -1, 8822], + [2, -1, 0, -1, 8216], + [2, 0, -2, -1, 4324], + [2, 0, 1, 1, 4200], + [2, 1, 0, -1, -3359], + [2, -1, -1, 1, 2463], + [2, -1, 0, 1, 2211], + [2, -1, -1, -1, 2065], +]; + +/** Geocentric ecliptic longitude, latitude (radians) and distance (km). */ +export function moonEcliptic(t: number): [number, number, number] { + const Lp = 218.3164477 + 481267.88123421 * t; + const D = (297.8501921 + 445267.1114034 * t) * RAD; + const M = (357.5291092 + 35999.0502909 * t) * RAD; + const Mp = (134.9633964 + 477198.8675055 * t) * RAD; + const F = (93.272095 + 483202.0175233 * t) * RAD; + const A1 = (119.75 + 131.849 * t) * RAD; + const A2 = (53.09 + 479264.29 * t) * RAD; + const A3 = (313.45 + 481266.484 * t) * RAD; + const E = 1 - 0.002516 * t; + let sl = 0; + let sr = 0; + let sb = 0; + for (const [d, m, mp, f, l, r] of MOON_LR) { + const arg = d! * D + m! * M + mp! * Mp + f! * F; + const e = E ** Math.abs(m!); + sl += l! * e * Math.sin(arg); + sr += r! * e * Math.cos(arg); + } + for (const [d, m, mp, f, b] of MOON_B) { + sb += + b! * E ** Math.abs(m!) * Math.sin(d! * D + m! * M + mp! * Mp + f! * F); + } + const L = Lp * RAD; + sl += 3958 * Math.sin(A1) + 1962 * Math.sin(L - F) + 318 * Math.sin(A2); + sb += + -2235 * Math.sin(L) + + 382 * Math.sin(A3) + + 175 * Math.sin(A1 - F) + + 175 * Math.sin(A1 + F) + + 127 * Math.sin(L - Mp) - + 115 * Math.sin(L + Mp); + return [(Lp + sl / 1e6) * RAD, (sb / 1e6) * RAD, 385000.56 + sr / 1000]; +} + +/** Geocentric ecliptic longitude of the sun (radians). */ +function sunLongitude(t: number): number { + const L0 = 280.46646 + 36000.76983 * t; + const M = (357.52911 + 35999.05029 * t) * RAD; + const C = + (1.914602 - 0.004817 * t) * Math.sin(M) + + (0.019993 - 0.000101 * t) * Math.sin(2 * M) + + 0.000289 * Math.sin(3 * M); + return (L0 + C) * RAD; +} + +const normalize = (v: Vec3): Vec3 => { + const l = Math.hypot(v[0], v[1], v[2]); + return [v[0] / l, v[1] / l, v[2] / l]; +}; + +export type Sky = { + /** J2000 equatorial to world, for the star catalogue. */ + stars: Mat3; + /** Unit vector to the moon (topocentric) and its angular radius. */ + moon: Vec3; + moonRadius: number; + /** Lunar north (ecliptic pole is within 1.5° of it). */ + moonNorth: Vec3; + sun: Vec3; + /** 0..1: how much the moon lights the sky (phase and altitude). */ + moonlight: number; +}; + +export function sky(site: Site, ms: number): Sky { + const jd = julian(ms); + const t = (jd - 2451545) / 36525; + const gmst = + (280.46061837 + 360.98564736629 * (jd - 2451545) + 0.000387933 * t * t) * + RAD; + const lst = gmst + site.lon * RAD; + const lat = site.lat * RAD; + const hor = horizon(lat, lst); + const eps = (23.4392911 - 0.0130042 * t) * RAD; + + // Topocentric moon: subtract the observer's position from the geocentric one. + const [ml, mb, dist] = moonEcliptic(t); + const geo = ecliptic(ml, mb, eps).map((c) => c * dist) as Vec3; + const u = Math.atan(0.99664719 * Math.tan(lat)); + const rc = 6378.14 * Math.cos(u); + const rs = 6378.14 * 0.99664719 * Math.sin(u); + const topo: Vec3 = [ + geo[0] - rc * Math.cos(lst), + geo[1] - rc * Math.sin(lst), + geo[2] - rs, + ]; + const range = Math.hypot(...topo); + const moon = normalize(apply(hor, topo)); + const sun = normalize(apply(hor, ecliptic(sunLongitude(t), 0, eps))); + const moonNorth = normalize(apply(hor, [0, -Math.sin(eps), Math.cos(eps)])); + + // Illuminated fraction from the sun–moon angle, faded by moon altitude. + const lit = + (1 - (sun[0] * moon[0] + sun[1] * moon[1] + sun[2] * moon[2])) / 2; + const alt = Math.asin(moon[1]) / RAD; + const up = Math.min(1, Math.max(0, (alt + 2) / 17)); + return { + stars: mul(hor, precession(t)), + moon, + moonRadius: Math.asin(1737.4 / range), + moonNorth, + sun, + moonlight: lit ** 1.5 * up * up * (3 - 2 * up), + }; +} diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts new file mode 100644 index 0000000..53c0158 --- /dev/null +++ b/apps/site/src/scripts/aurora.worker.ts @@ -0,0 +1,543 @@ +// Aurora background: the real night sky over Ísafjörður, in a Web Worker on +// an OffscreenCanvas so the page's main thread does no WebGL work at all. +// Passes per frame: +// 1. Footprint: each curtain's ground track and brightness into a small +// texture (see aurora.shaders.ts). +// 2. Sky: a fullscreen march through the aurora in perspective, plus the moon. +// 3. Stars from the Bright Star Catalogue, then the sparks, added on top. +// 4. The mountain skyline, drawn last so it hides everything behind it. +// The page sends the theme token values, sizes and visibility; we answer "on" +// once a frame is up and "off" if we give up. + +import { + FOOT_FS, + MAP_H, + MAP_W, + POINT_FS, + QUAD_VS, + RIDGE_FS, + RIDGE_VS, + SPARK_VS, + STAR_VS, + skyFs, +} from "./aurora.shaders"; +import { getUnixNow } from "@northguild/gmt/unix"; +import { MOON } from "./aurora.moon"; +import { SITES } from "./aurora.sites"; +import { columns, sky, type Sky } from "./aurora.sky"; +import { SKYLINE as HELSINKI } from "./skylines/helsinki"; +import { SKYLINE as ISAFJORDUR } from "./skylines/isafjordur"; + +const FRAME_MS = 1000 / 30; +const BASE_SCALE = 0.6; +const MAX_EDGE = 1280; +const SPARKS = 4000; +const RAD = Math.PI / 180; + +// Wall-clock epoch milliseconds from Temporal (via gmt): the stars and moon +// need the real time. Frame timing uses rAF stamps, not the wall clock. +let wallMs = 0; +const wall = () => (wallMs = getUnixNow() ?? wallMs); + +type Rgb = [number, number, number]; +type Skyline = typeof ISAFJORDUR; + +// Skylines by site id (see scripts/build-skyline.mjs). +const SKYLINES: Record = { + isafjordur: ISAFJORDUR, + helsinki: HELSINKI, +}; + +export type AuroraMessage = + | { + type: "init"; + canvas: OffscreenCanvas; + width: number; + height: number; + colours: string[]; + /** Index into SITES, chosen by the page. */ + site: number; + still: boolean; + hidden: boolean; + } + | { type: "site"; site: number } + | { type: "size"; width: number; height: number } + | { type: "still"; still: boolean } + | { type: "hidden"; hidden: boolean }; + +const scope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: "on" | "off"): void; +}; + +const nextFrame = + "requestAnimationFrame" in self + ? requestAnimationFrame + : (cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), FRAME_MS); +const cancelFrame = + "cancelAnimationFrame" in self ? cancelAnimationFrame : clearTimeout; + +function parseColours(values: string[]): Rgb[] { + const ctx = new OffscreenCanvas(1, 1).getContext("2d", { + willReadFrequently: true, + }); + return values.map((value) => { + if (!ctx) return [0, 0, 0]; + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = value; + ctx.fillRect(0, 0, 1, 1); + const [r = 0, g = 0, b = 0] = ctx.getImageData(0, 0, 1, 1).data; + return [r / 255, g / 255, b / 255]; + }); +} + +function compile( + gl: WebGLRenderingContext, + vs: string, + fs: string, +): WebGLProgram | null { + const program = gl.createProgram(); + for (const [type, src] of [ + [gl.VERTEX_SHADER, vs], + [gl.FRAGMENT_SHADER, fs], + ] as const) { + const shader = gl.createShader(type); + if (!shader) return null; + gl.shaderSource(shader, src); + gl.compileShader(shader); + gl.attachShader(program, shader); + } + gl.linkProgram(program); + if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program; + if (import.meta.env.DEV) { + for (const shader of gl.getAttachedShaders(program) ?? []) { + console.warn("aurora:", gl.getShaderInfoLog(shader)); + } + } + return null; +} + +const bytes = (b64: string) => + Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + +/** Ridge strip: for each azimuth, the ridge direction, then the far shore + * over water or a direction far below the frame. */ +function ridgeMesh(skyline: Skyline): Float32Array { + const elev = new Int16Array(bytes(skyline.elev).buffer); + const dist = new Uint16Array(bytes(skyline.dist).buffer); + const base = new Int16Array(bytes(skyline.base).buffer); + const out = new Float32Array(elev.length * 8); + elev.forEach((e, i) => { + const az = (skyline.from + i * skyline.step) * RAD; + // 0 within 1 km of the viewer, rising to 1 at 31 km: paler, and lit if a city. + const haze = Math.min(1, Math.max(0, ((dist[i] ?? 0) / 100 - 1) / 30)); + for (const [j, el] of [ + [0, (e / 100) * RAD], + [1, ((base[i] ?? -4000) / 100) * RAD], + ] as const) { + out.set( + [ + Math.sin(az) * Math.cos(el), + Math.sin(el), + Math.cos(az) * Math.cos(el), + j ? -1 - haze : haze, + ], + i * 8 + j * 4, + ); + } + }); + return out; +} + +/** Catalogue records (see scripts/build-stars.mjs) to direction + magnitude. */ +function starData(buf: ArrayBuffer): Float32Array { + const view = new DataView(buf); + const n = Math.floor(buf.byteLength / 6); + const out = new Float32Array(n * 5); + for (let i = 0; i < n; i++) { + const ra = (view.getUint16(i * 6, true) / 65536) * 2 * Math.PI; + const dec = (view.getInt16(i * 6 + 2, true) / 32767) * (Math.PI / 2); + out.set( + [ + Math.cos(dec) * Math.cos(ra), + Math.cos(dec) * Math.sin(ra), + Math.sin(dec), + view.getUint8(i * 6 + 4) / 20 - 2, + view.getInt8(i * 6 + 5) / 50, + ], + i * 5, + ); + } + return out; +} + +function start(init: Extract): void { + const { canvas } = init; + const gl = canvas.getContext("webgl", { + alpha: false, + antialias: false, + depth: false, + stencil: false, + powerPreference: "low-power", + }); + if (!gl || gl.getParameter(gl.MAX_TEXTURE_SIZE) < MAP_W) return; + const foot = compile(gl, QUAD_VS, FOOT_FS); + let skyProgram = compile(gl, QUAD_VS, skyFs(20, 320)); + const sparks = compile(gl, SPARK_VS, POINT_FS); + const stars = compile(gl, STAR_VS, POINT_FS); + const ridge = compile(gl, RIDGE_VS, RIDGE_FS); + if (!foot || !skyProgram || !sparks || !stars || !ridge) return; + + const [voidC, spring, cyan, teal, purple, ice] = parseColours(init.colours); + const colours: Record = { + uVoid: voidC, + uSpring: spring, + uCyan: cyan, + uTeal: teal, + uPurple: purple, + uIce: ice, + }; + // Footprint texture and framebuffer on unit 0. + const map = gl.createTexture(); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, map); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + MAP_W, + MAP_H, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + const params = (filter: number, wrapS: number) => { + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + }; + params(gl.NEAREST, gl.CLAMP_TO_EDGE); + const fbo = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + map, + 0, + ); + const complete = + gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + if (!complete) return; + + // Moon albedo on unit 1 (power-of-two, so it may repeat in longitude). + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.LUMINANCE, + MOON.width, + MOON.height, + 0, + gl.LUMINANCE, + gl.UNSIGNED_BYTE, + bytes(MOON.data), + ); + params(gl.LINEAR, gl.REPEAT); + gl.activeTexture(gl.TEXTURE0); + + const buffer = (data: Float32Array) => { + const b = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, b); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + return b; + }; + const tri = buffer(new Float32Array([-1, -1, 3, -1, -1, 3])); + const seeds = buffer(Float32Array.from({ length: SPARKS * 4 }, Math.random)); + const ridgeBuf = gl.createBuffer(); + let ridgeCount = 0; + let starBuf: WebGLBuffer | null = null; + let starCount = 0; + let starsAt = 0; + + // Uniforms that never change: colours and samplers. + const setup = (program: WebGLProgram) => { + gl.useProgram(program); + for (const [name, rgb] of Object.entries(colours)) { + const loc = gl.getUniformLocation(program, name); + if (loc && rgb) gl.uniform3fv(loc, rgb); + } + gl.uniform1i(gl.getUniformLocation(program, "uMap"), 0); + gl.uniform1i(gl.getUniformLocation(program, "uMoonTex"), 1); + }; + for (const p of [foot, skyProgram, sparks, stars, ridge]) setup(p); + const at = (program: WebGLProgram, name: string) => + gl.getUniformLocation(program, name); + + const attrib = ( + program: WebGLProgram, + name: string, + buf: WebGLBuffer | null, + size: number, + stride = 0, + offset = 0, + ) => { + const a = gl.getAttribLocation(program, name); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.enableVertexAttribArray(a); + gl.vertexAttribPointer(a, size, gl.FLOAT, false, stride, offset); + return a; + }; + + // Everything that depends on where the viewer stands. + let site = SITES[0]!; + let tilt = [0, 0]; + let cam = new Float32Array(9); + let tanY = 1; + const place = (index: number) => { + site = SITES[index] ?? SITES[0]!; + // Field lines climb toward magnetic south at the site's dip. + const south = (180 + site.decl) * RAD; + const lean = 1 / Math.tan(site.dip * RAD); + tilt = [lean * Math.sin(south), lean * Math.cos(south)]; + // Camera basis as columns (right, up, forward), heading from north. + const yaw = site.heading * RAD; + const pitch = site.pitch * RAD; + const fwd = [ + Math.sin(yaw) * Math.cos(pitch), + Math.sin(pitch), + Math.cos(yaw) * Math.cos(pitch), + ] as const; + const right = [Math.cos(yaw), 0, -Math.sin(yaw)] as const; + cam = new Float32Array([ + ...right, + fwd[1] * right[2] - fwd[2] * right[1], + fwd[2] * right[0] - fwd[0] * right[2], + fwd[0] * right[1] - fwd[1] * right[0], + ...fwd, + ]); + tanY = Math.tan((site.fov / 2) * RAD); + const skyline = SKYLINES[site.id]; + const mesh = skyline ? ridgeMesh(skyline) : new Float32Array(0); + gl.bindBuffer(gl.ARRAY_BUFFER, ridgeBuf); + gl.bufferData(gl.ARRAY_BUFFER, mesh, gl.STATIC_DRAW); + ridgeCount = mesh.length / 4; + }; + place(init.site); + + // Quality levels: 0 full, 1 lower resolution and fewer sky steps, 2 also + // half the sparks, 3 stopped. + let level = 0; + let sparkCount = SPARKS; + let width = init.width; + let height = init.height; + let still = init.still; + let hidden = init.hidden; + let t = 120; + let raf = 0; + let last = 0; + let samples = 0; + let total = 0; + let tuning = true; + let skyAt = wall(); + let now: Sky = sky(site, skyAt); + + const resize = () => { + const w = width || 1; + const h = height || 1; + const scale = + Math.min(BASE_SCALE, MAX_EDGE / Math.max(w, h)) * (level ? 0.7 : 1); + canvas.width = Math.round(w * scale); + canvas.height = Math.round(h * scale); + }; + + const quad = (program: WebGLProgram) => { + const a = attrib(program, "aPos", tri, 2); + gl.drawArrays(gl.TRIANGLES, 0, 3); + gl.disableVertexAttribArray(a); + }; + + const draw = () => { + // The real sky turns slowly; refresh it once a second. + if (wall() - skyAt > 1000) { + skyAt = wallMs; + now = sky(site, skyAt); + } + const tan = [tanY * (canvas.width / canvas.height), tanY]; + const px = Math.max(1, canvas.height / 540); + const view = (program: WebGLProgram) => { + gl.useProgram(program); + gl.uniformMatrix3fv(at(program, "uCam"), false, cam); + gl.uniform2fv(at(program, "uTan"), tan); + gl.uniform1f(at(program, "uT"), t); + gl.uniform1f(at(program, "uPx"), px); + gl.uniform1f(at(program, "uMoonlight"), now.moonlight); + gl.uniform2fv(at(program, "uTilt"), tilt); + gl.uniform1f(at(program, "uGlow"), site.glow); + gl.uniform1f(at(program, "uLights"), site.lights); + for (const [name, c] of [ + ["uC0", site.curtains[0]], + ["uC1", site.curtains[1]], + ] as const) { + gl.uniform3f(at(program, name), c.base, c.centre, c.half); + } + }; + + // 1. Footprint. + gl.disable(gl.BLEND); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.viewport(0, 0, MAP_W, MAP_H); + view(foot); + quad(foot); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, canvas.width, canvas.height); + + // 2. Sky and moon. + const sp = skyProgram; + if (!sp) return; + view(sp); + gl.uniform1f(at(sp, "uPxAng"), (2 * tanY) / canvas.height); + gl.uniform4f(at(sp, "uMoon"), ...now.moon, now.moonRadius); + gl.uniform3fv(at(sp, "uMoonN"), now.moonNorth); + gl.uniform3fv(at(sp, "uSun"), now.sun); + quad(sp); + + // 3. Stars and sparks, added on top. + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); + if (starBuf && starCount) { + view(stars); + gl.uniformMatrix3fv(at(stars, "uStars"), false, columns(now.stars)); + gl.uniform1f(at(stars, "uLimit"), site.limit - 2 * now.moonlight); + gl.uniform1f( + at(stars, "uFade"), + Math.min(1, (performance.now() - starsAt) / 1500), + ); + const a = attrib(stars, "aDir", starBuf, 3, 20, 0); + const b = attrib(stars, "aMag", starBuf, 2, 20, 12); + gl.drawArrays(gl.POINTS, 0, starCount); + gl.disableVertexAttribArray(a); + gl.disableVertexAttribArray(b); + } + view(sparks); + const s = attrib(sparks, "aSeed", seeds, 4); + gl.drawArrays(gl.POINTS, 0, sparkCount); + gl.disableVertexAttribArray(s); + + // 4. Mountains over everything. + gl.disable(gl.BLEND); + view(ridge); + const r = attrib(ridge, "aRidge", ridgeBuf, 4); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, ridgeCount); + gl.disableVertexAttribArray(r); + }; + + const stop = () => { + cancelFrame(raf); + raf = 0; + }; + + const giveUp = () => { + stop(); + level = 3; + scope.postMessage("off"); + }; + + // If frames run long, step quality down; past the last level give up and + // leave the page's CSS gradient showing. + const adapt = (elapsed: number) => { + if (!tuning || elapsed > 250) return; + total += elapsed; + if (++samples < 45) return; + const slow = total / samples > 45; + samples = 0; + total = 0; + if (!slow) { + tuning = false; + } else if (++level === 1) { + const lighter = compile(gl, QUAD_VS, skyFs(12, 260)); + if (lighter) { + setup(lighter); + skyProgram = lighter; + } + resize(); + } else if (level === 2) { + sparkCount = SPARKS / 2; + } else { + giveUp(); + } + }; + + const frame = (stamp: number) => { + raf = nextFrame(frame); + const elapsed = stamp - last; + if (elapsed < FRAME_MS - 2) return; + if (last) { + t += Math.min(elapsed, 100) / 1000; + adapt(elapsed); + } + last = stamp; + draw(); + }; + + const run = () => { + if (raf || level > 2 || still || hidden) return; + last = 0; + raf = nextFrame(frame); + }; + + canvas.addEventListener("webglcontextlost", giveUp); + resize(); + draw(); + scope.postMessage("on"); + run(); + + // Stars arrive after the first frame so they never delay it. Without them + // the aurora still runs. + fetch(new URL("/stars.bin", self.location.origin)) + .then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(res.status))) + .then((buf) => { + if (level > 2) return; + const data = starData(buf); + starBuf = buffer(data); + starCount = data.length / 5; + starsAt = still ? -Infinity : performance.now(); + if (!raf) draw(); + }) + .catch(() => {}); + + scope.onmessage = ({ data }) => { + if (level > 2) return; + if (data.type === "site") { + place(data.site); + skyAt = wall(); + now = sky(site, skyAt); + draw(); + scope.postMessage("on"); + } else if (data.type === "size") { + width = data.width; + height = data.height; + resize(); + draw(); + } else if (data.type === "still") { + still = data.still; + stop(); + draw(); + run(); + } else if (data.type === "hidden") { + hidden = data.hidden; + if (hidden) stop(); + else run(); + } + }; +} + +scope.onmessage = ({ data }) => { + if (data.type === "init") start(data); +}; From 25b48e5ae43a709ee00ac217732bb9540d39e956 Mon Sep 17 00:00:00 2001 From: "Craig C." Date: Tue, 20 Oct 2026 21:51:12 +0300 Subject: [PATCH 5/6] feat(site): open at the visitor's site, ping the site buttons, frame phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The worker picks the first site from the browser's time zone with `getSystemTimeZone()` from `@northguild/gmt`: Finland (Europe/Helsinki, Europe/Mariehamn) opens on Helsinki, Iceland (Atlantic/Reykjavik) on Ísafjörður, anywhere else at random. It reports the site to the page so the popover matches. Running it in the worker keeps Temporal off the main thread. - The site switcher uses real buttons (`gmt-button gmt-sonar-focus`, `aria-pressed`), so they ping on click and focus like the other buttons. - On portrait screens the view widens and tilts so the horizon sits about a third of the way down: the aurora shows above the cards instead of behind them, with the land or water below. --- apps/site/src/components/Aurora.astro | 77 ++++++++++---------------- apps/site/src/scripts/aurora.sites.ts | 10 +++- apps/site/src/scripts/aurora.worker.ts | 54 +++++++++++++----- 3 files changed, 76 insertions(+), 65 deletions(-) diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro index 43d4b9d..2a914bc 100644 --- a/apps/site/src/components/Aurora.astro +++ b/apps/site/src/components/Aurora.astro @@ -33,22 +33,20 @@ import {
-
- Seen from +
{ SITES.map((site, i) => ( -
+
{ SITES.map((site, i) => (