diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 8d198af..5117686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "drizzli", "version": "0.0.1", + "dependencies": { + "@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer", + "maplibre-gl": "^5.24.0" + }, "devDependencies": { "@eslint/compat": "^2.1.0", "@eslint/js": "^10.0.1", @@ -46,6 +50,43 @@ "vitest-browser-svelte": "^3.0.0" } }, + "../../open-meteo/weather-map-layer": { + "name": "@openmeteo/weather-map-layer", + "version": "0.0.19", + "dependencies": { + "@mapbox/tilebelt": "^2.0.3", + "@mapbox/vector-tile": "^3.0.0", + "@openmeteo/file-reader": "^0.0.16", + "maplibre-gl": "^5.20.1", + "pbf": "^5.1.0", + "point-in-polygon-hao": "^1.2.4" + }, + "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^6.0.0", + "@types/d3-interpolate": "^3.0.4", + "@types/geojson": "^7946.0.16", + "@types/node": "^26.0.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", + "@vitest/coverage-v8": "^4.0.15", + "concurrently": "^10.0.1", + "d3-color": "^3.1.0", + "d3-interpolate": "^3.0.1", + "eslint": "^10.0.1", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.0.0", + "jiti": "^2.6.1", + "live-server": "^1.2.2", + "npm-watch": "^0.13.0", + "prettier": "^3.6.2", + "tsx": "^4.20.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.58.1", + "unplugin-dts": "^1.0.0-beta.6", + "vite": "^8.0.12", + "vitest": "^4.0.4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -565,6 +606,119 @@ "svelte": "^5" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -597,6 +751,10 @@ "node": ">=12.0" } }, + "node_modules/@openmeteo/weather-map-layer": { + "resolved": "../../open-meteo/weather-map-layer", + "link": true + }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -1393,6 +1551,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2122,6 +2286,12 @@ "dev": true, "license": "MIT" }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/enhanced-resolve": { "version": "5.24.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", @@ -2570,6 +2740,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2728,6 +2904,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3102,6 +3290,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3118,6 +3340,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3145,6 +3376,12 @@ "dev": true, "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -3293,6 +3530,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3492,6 +3741,12 @@ "node": ">=4" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3608,6 +3863,12 @@ } } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3618,6 +3879,12 @@ "node": ">=6" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3632,6 +3899,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -4062,6 +4338,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", diff --git a/package.json b/package.json index 6b88f34..fd69abd 100644 --- a/package.json +++ b/package.json @@ -53,5 +53,9 @@ "vite": "^8.1.5", "vitest": "^4.1.10", "vitest-browser-svelte": "^3.0.0" + }, + "dependencies": { + "@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer", + "maplibre-gl": "^5.24.0" } } diff --git a/src/lib/components/unit-selector.svelte b/src/lib/components/unit-selector.svelte index b123eb1..7c7589c 100644 --- a/src/lib/components/unit-selector.svelte +++ b/src/lib/components/unit-selector.svelte @@ -34,6 +34,14 @@ { value: 'mm', label: 'mm' }, { value: 'inch', label: 'inch' } ] + }, + { + key: 'time_format', + label: 'Time', + options: [ + { value: '24h', label: '24h' }, + { value: '12h', label: 'AM/PM' } + ] } ]; diff --git a/src/lib/maps/WeatherMap.svelte b/src/lib/maps/WeatherMap.svelte new file mode 100644 index 0000000..3d6acae --- /dev/null +++ b/src/lib/maps/WeatherMap.svelte @@ -0,0 +1,723 @@ + + +
+
+ + +
+
+ {title} · {modelLabel($storedModel)} +
+ {#if source?.fellBack} +
+ No map for this model - showing global {source.domain} +
+ {/if} +
+ + {#if error} +
+
+ {error} +
+
+ {/if} + + +
+
+
+ + +
+ + {fmtTime(currentTime)} + + + {fmtDay(currentTime)} + +
+ +
+ {#if times.length} + + {/if} + +
+ {#each SPANS as s (s.label)} + + {/each} +
+ +
+ {#each SPEEDS as s (s.ms)} + + {/each} +
+
+
+ + +
+ + + + {#if times.length} +
+ {/if} + + + {#if times.length} +
+ {#each times as _t, i (i)} +
+ {/each} +
+ {/if} + + +
+ {#each dayMarkers as d (d.i)} + + {d.label} + + {/each} +
+
+
+
+
+ + diff --git a/src/lib/maps/frame-animator.ts b/src/lib/maps/frame-animator.ts new file mode 100644 index 0000000..c2d7af1 --- /dev/null +++ b/src/lib/maps/frame-animator.ts @@ -0,0 +1,379 @@ +import * as maplibregl from 'maplibre-gl'; + +/** + * Smooth cross-fade animator for a stack of MapLibre weather frames. + * + * Instead of tearing down and rebuilding a source per timestep (which flashes + * and stutters), each forecast step gets its own persistent layer(s). Playback + * drives a floating-point playhead and holds each frame crisp for most of its + * dwell, then cross-fades to the next over a short window - a fluid hand-off + * rather than a muddy full-interval dissolve. + * + * A frame can render several **channels** (e.g. a raster field plus a vector + * wind-arrows overlay); all a frame's channels fade together and it is only + * "ready" once every channel's source has loaded. + * + * Frames inside the active loop range stay resident so repeat loops are + * instant; frames visited only by scrubbing are evicted LRU. The playhead only + * advances once both the current and next frame have loaded, so a slow tile + * never produces a gap - it just briefly holds the current frame. + */ +export interface FrameChannel { + /** Unique per channel (part of source/layer ids). */ + key: string; + sourceSpec: (url: string) => maplibregl.SourceSpecification; + /** Add the layer at opacity 0 (it is faded in by the animator). */ + addLayer: ( + map: maplibregl.Map, + sourceId: string, + layerId: string, + beforeLayer: string | undefined + ) => void; + /** Paint property used to fade this channel, e.g. `raster-opacity`. */ + opacityProp: string; + /** Peak opacity for this channel (defaults to the animator's `opacity`). */ + peakOpacity?: number; +} + +export interface FrameAnimatorOptions { + map: maplibregl.Map; + /** `om://` source URL for frame `i`. */ + urlForFrame: (i: number) => string; + channels: FrameChannel[]; + beforeLayer?: string; + /** Default peak opacity for channels that don't set their own. */ + opacity?: number; + /** Cross-fade window in ms (0 = instant swaps). Default 130. */ + crossFadeMs?: number; + /** LRU cap for frames outside the loop range (scrub-visited). */ + outOfRangePool?: number; + /** Fired when the dominant integer frame changes (drives the scrubber). */ + onShow?: (i: number) => void; + /** Fired when frame `i` finishes loading (drives the buffer strip). */ + onReady?: (i: number) => void; + /** Fired once, when the very first frame becomes visible. */ + onFirstFrame?: () => void; +} + +interface LayerInfo { + layerId: string; + opacityProp: string; + peak: number; +} + +interface Entry { + sources: string[]; + layers: LayerInfo[]; + loadedSources: Set; + ready: boolean; + onData?: (e: maplibregl.MapSourceDataEvent) => void; +} + +export class FrameAnimator { + private map: maplibregl.Map; + private opt: FrameAnimatorOptions; + private target: number; + private crossFadeMs: number; + + private frames = new Map(); + private visible = new Set(); + private lru: number[] = []; // out-of-loop frames, oldest first + + private frameCount = 0; + private loopStart = 0; + private loopEnd = 0; + + private mode: 'still' | 'play' = 'still'; + private q = 0; // loop-local playhead (float) when playing + private stillIndex = 0; + private speedMs = 450; + + private raf = 0; + private lastTs = 0; + private lastShown = -1; + private firstShown = false; + private alive = true; + + // bounded-parallel preloading + private preloadQueue: number[] = []; + private preloading = new Set(); + private preloadInFlight = 0; + private preloadMax = 8; + + constructor(opt: FrameAnimatorOptions) { + this.map = opt.map; + this.opt = opt; + this.target = opt.opacity ?? 0.9; + this.crossFadeMs = opt.crossFadeMs ?? 200; + this.tick = this.tick.bind(this); + } + + setFrameCount(n: number): void { + this.frameCount = n; + } + + setLoopRange(start: number, end: number): void { + this.loopStart = Math.max(0, start); + this.loopEnd = Math.max(this.loopStart, end); + } + + setSpeed(ms: number): void { + this.speedMs = ms; + } + + get currentIndex(): number { + return this.mode === 'play' + ? this.loopStart + (Math.floor(this.q) % this.loopLen()) + : this.stillIndex; + } + + private loopLen(): number { + return Math.max(1, this.loopEnd - this.loopStart + 1); + } + + private inLoop(i: number): boolean { + return i >= this.loopStart && i <= this.loopEnd; + } + + /** Show a single frame and stop animating (used for scrubbing / paused). */ + scrubTo(i: number): void { + this.mode = 'still'; + this.stillIndex = Math.max(0, Math.min(this.frameCount - 1, i)); + this.start(); + } + + /** Start looping over the loop range, entering at `fromIndex` when given. */ + play(speedMs: number, fromIndex?: number): void { + this.speedMs = speedMs; + this.mode = 'play'; + const L = this.loopLen(); + this.q = fromIndex != null && this.inLoop(fromIndex) ? (fromIndex - this.loopStart) % L : 0; + this.start(); + } + + pause(): void { + if (this.mode === 'play') this.stillIndex = this.currentIndex; + this.mode = 'still'; + } + + private start(): void { + if (!this.alive) return; + this.lastTs = 0; + if (!this.raf) this.raf = requestAnimationFrame(this.tick); + } + + private stopRaf(): void { + if (this.raf) cancelAnimationFrame(this.raf); + this.raf = 0; + } + + private tick(ts: number): void { + if (!this.alive) return; + const dt = this.lastTs ? ts - this.lastTs : 16; + this.lastTs = ts; + if (this.mode === 'play') this.tickPlay(dt); + else this.tickStill(); + this.raf = requestAnimationFrame(this.tick); + } + + private tickStill(): void { + const i = this.stillIndex; + this.ensure(i); + if (!this.frames.get(i)?.ready) return; // hold previous frame until ready + this.markFirst(); + this.setOp(i, 1); + this.hideExcept(new Set([i])); + this.emitShown(i); + } + + private tickPlay(dt: number): void { + const L = this.loopLen(); + const baseLocal = Math.floor(this.q) % L; + const curr = this.loopStart + baseLocal; + const next = this.loopStart + ((baseLocal + 1) % L); + this.ensure(curr); + this.ensure(next); + this.ensure(this.loopStart + ((baseLocal + 2) % L)); + + const currReady = this.frames.get(curr)?.ready; + const nextReady = this.frames.get(next)?.ready; + if (currReady) this.markFirst(); + + if (currReady && nextReady) { + this.q += dt / this.speedMs; + if (this.q >= L) this.q -= L; + const bl = Math.floor(this.q) % L; + const cB = this.loopStart + bl; + const cN = this.loopStart + ((bl + 1) % L); + const frac = this.q - Math.floor(this.q); + // Hold the current frame crisp for most of its dwell, then cross-fade + // to the next only over the short crossFadeMs window at the end. + const fadeFrac = Math.min(0.9, this.crossFadeMs / this.speedMs); + const fadeStart = 1 - fadeFrac; + if (fadeFrac <= 0 || frac < fadeStart) { + this.setOp(cB, 1); + this.hideExcept(new Set([cB])); + this.emitShown(cB); + } else { + const blend = (frac - fadeStart) / fadeFrac; + this.setOp(cB, 1 - blend); + this.setOp(cN, blend); + this.hideExcept(new Set([cB, cN])); + this.emitShown(blend < 0.5 ? cB : cN); + } + } else if (currReady) { + // next frame still loading - hold the current frame at full opacity + this.setOp(curr, 1); + this.hideExcept(new Set([curr])); + this.emitShown(curr); + } + } + + private markFirst(): void { + if (this.firstShown) return; + this.firstShown = true; + this.opt.onFirstFrame?.(); + } + + private emitShown(i: number): void { + if (i === this.lastShown) return; + this.lastShown = i; + this.opt.onShow?.(i); + } + + private ensure(i: number): void { + if (i < 0 || i >= this.frameCount) return; + if (this.frames.has(i)) { + this.touch(i); + return; + } + if (!this.map.isStyleLoaded()) return; + + const url = this.opt.urlForFrame(i); + const sources: string[] = []; + const layers: LayerInfo[] = []; + for (const ch of this.opt.channels) { + const sourceId = `omAnim_${ch.key}_${i}`; + const layerId = `omAnimL_${ch.key}_${i}`; + this.map.addSource(sourceId, ch.sourceSpec(url)); + ch.addLayer(this.map, sourceId, layerId, this.opt.beforeLayer); + if (this.map.getLayer(layerId)) { + sources.push(sourceId); + layers.push({ layerId, opacityProp: ch.opacityProp, peak: ch.peakOpacity ?? this.target }); + } + } + + const entry: Entry = { sources, layers, loadedSources: new Set(), ready: false }; + const allLoaded = () => entry.sources.every((s) => this.map.getSource(s)?.loaded()); + const markReady = () => { + entry.ready = true; + if (entry.onData) { + this.map.off('sourcedata', entry.onData); + entry.onData = undefined; + } + this.opt.onReady?.(i); + if (this.preloading.has(i)) { + this.preloading.delete(i); + this.preloadInFlight = Math.max(0, this.preloadInFlight - 1); + queueMicrotask(() => this.pumpPreload()); + } + }; + if (allLoaded()) { + markReady(); + } else { + const onData = (e: maplibregl.MapSourceDataEvent) => { + if (!e.sourceId || !entry.sources.includes(e.sourceId)) return; + if (!e.isSourceLoaded) return; + if (allLoaded()) markReady(); + }; + entry.onData = onData; + this.map.on('sourcedata', onData); + } + + this.frames.set(i, entry); + this.touch(i); + this.evict(); + } + + private touch(i: number): void { + if (this.inLoop(i)) return; // loop frames are never evicted + const at = this.lru.indexOf(i); + if (at >= 0) this.lru.splice(at, 1); + this.lru.push(i); + } + + private evict(): void { + const cap = this.opt.outOfRangePool ?? 6; + while (this.lru.length > cap) { + const i = this.lru.shift(); + if (i === undefined || i === this.stillIndex) continue; + this.remove(i); + } + } + + private remove(i: number): void { + const e = this.frames.get(i); + if (!e) return; + if (e.onData) this.map.off('sourcedata', e.onData); + for (const { layerId } of e.layers) + if (this.map.getLayer(layerId)) this.map.removeLayer(layerId); + for (const s of e.sources) if (this.map.getSource(s)) this.map.removeSource(s); + this.frames.delete(i); + this.visible.delete(i); + } + + /** Set every channel of frame `i` to `mul` × its peak opacity. */ + private setOp(i: number, mul: number): void { + const e = this.frames.get(i); + if (!e) return; + for (const { layerId, opacityProp, peak } of e.layers) { + if (this.map.getLayer(layerId)) this.map.setPaintProperty(layerId, opacityProp, peak * mul); + } + if (mul > 0.001) this.visible.add(i); + else this.visible.delete(i); + } + + private hideExcept(keep: Set): void { + for (const i of [...this.visible]) if (!keep.has(i)) this.setOp(i, 0); + } + + /** + * Eagerly load `indices` with at most `maxInFlight` frames loading at once. + * Each frame is a set of layers, so MapLibre + the tile worker pool fetch and + * render them in parallel; the cap bounds the number of open tile requests. + */ + preload(indices: number[], maxInFlight = 8): void { + this.preloadMax = Math.max(1, maxInFlight); + this.preloadQueue = indices.slice(); + this.pumpPreload(); + } + + private pumpPreload(): void { + while (this.preloadInFlight < this.preloadMax && this.preloadQueue.length) { + const i = this.preloadQueue.shift(); + if (i === undefined || i < 0 || i >= this.frameCount) continue; + if (this.frames.get(i)?.ready || this.preloading.has(i)) continue; + this.preloading.add(i); + this.preloadInFlight++; + this.ensure(i); // synchronous cache hits settle via markReady immediately + } + } + + /** Remove every layer/source (e.g. before a style reload). */ + reset(): void { + this.stopRaf(); + for (const i of [...this.frames.keys()]) this.remove(i); + this.lru = []; + this.visible.clear(); + this.lastShown = -1; + this.firstShown = false; + this.preloadQueue = []; + this.preloading.clear(); + this.preloadInFlight = 0; + } + + destroy(): void { + this.alive = false; + this.reset(); + } +} diff --git a/src/lib/maps/map-sync.ts b/src/lib/maps/map-sync.ts new file mode 100644 index 0000000..208931e --- /dev/null +++ b/src/lib/maps/map-sync.ts @@ -0,0 +1,33 @@ +import { writable } from 'svelte/store'; + +/** + * Cross-map sync state, shared by every WeatherMap on the page so the maps stay + * matched. Split into one store per concern so a change to one (e.g. speed) + * doesn't wake effects that only care about another (e.g. the playhead). + */ + +/** Camera shared across maps; the last map the user moved writes it. */ +export interface SharedCamera { + center: [number, number]; + zoom: number; +} +export const sharedCamera = writable(null); + +/** Whether playback is running. */ +export const clockPlaying = writable(true); + +/** Minimum wall-time per frame, in ms. */ +export const clockSpeed = writable(450); + +/** Loop / preload span in days from today 00:00 (Infinity = the whole run). */ +export const clockSpan = writable(2.5); + +/** + * The scrub target. `epoch` bumps on every scrub so followers re-apply even if + * the index repeats; `index` is -1 before the first scrub. + */ +export interface ScrubState { + index: number; + epoch: number; +} +export const clockScrub = writable({ index: -1, epoch: 0 }); diff --git a/src/lib/maps/om.ts b/src/lib/maps/om.ts new file mode 100644 index 0000000..b7dbbc7 --- /dev/null +++ b/src/lib/maps/om.ts @@ -0,0 +1,166 @@ +/** + * Thin integration layer over @openmeteo/weather-map-layer for the native + * animated weather maps. Responsibilities: + * + * - register the `om://` MapLibre protocol once (shared settings/cache) + * - resolve drizzli's selected model id to a maps domain that actually serves + * the requested variable, falling back to a global model when the model has + * no map domain (or its run lacks the variable) - "use the global models as + * fallback" + * - build the fully-resolved `.om` tile-source URLs for a given model run and + * forecast time (the timestamp lives in the path, the variable in the query) + */ +import { + type DomainMetaDataJson, + defaultOmProtocolSettings, + omProtocol +} from '@openmeteo/weather-map-layer'; +import * as maplibregl from 'maplibre-gl'; + +import { modelDomainCandidates } from '$lib/utils/maps-domain'; + +/** Tile host. Always the production CDN (serves CORS/CORP for our headers). */ +const BASE = 'https://map-tiles.open-meteo.com'; + +/** + * Global models (widest coverage) tried after the selected model's own + * candidates. dwd_icon leads: it is truly global, hourly, and carries the + * longest run, which makes for the smoothest animation. + */ +const GLOBAL_FALLBACKS = ['dwd_icon', 'ecmwf_ifs025', 'ncep_gfs013']; + +// Register the protocol exactly once with a single shared settings object. +export const omSettings = { ...defaultOmProtocolSettings }; + +let registered = false; +export const registerOmProtocol = (): void => { + if (registered) return; + registered = true; + maplibregl.addProtocol('om', (params, abortController) => + omProtocol(params, abortController, omSettings) + ); +}; + +const pad = (n: number): string => String(n).padStart(2, '0'); + +/** Model-run directory, e.g. `2026/07/25/0600Z` (UTC). */ +export const fmtModelRun = (run: Date): string => + `${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/${pad( + run.getUTCHours() + )}${pad(run.getUTCMinutes())}Z`; + +/** Forecast-time filename stem, e.g. `2026-07-25T1400` (UTC). */ +export const fmtValidTime = (t: Date): string => + `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}T${pad( + t.getUTCHours() + )}${pad(t.getUTCMinutes())}`; + +/** Plain https `.om` URL for a single forecast step. */ +export const omFileUrl = (domain: string, run: Date, time: Date): string => + `${BASE}/data_spatial/${domain}/${fmtModelRun(run)}/${fmtValidTime(time)}.om`; + +/** + * `om://` source URL for one forecast step of a given variable. The same URL + * serves both a raster field and (with `arrows`) a vector `wind-arrows` layer; + * MapLibre asks the protocol for image vs vector tiles per source type. + */ +export const omSourceUrl = ( + domain: string, + run: Date, + time: Date, + variable: string, + dark: boolean, + arrows = false +): string => + `om://${omFileUrl(domain, run, time)}?variable=${variable}${arrows ? '&arrows=true' : ''}${ + dark ? '&dark=true' : '' + }`; + +/** Ordered, de-duplicated domain candidates for a model, then global fallbacks. */ +const domainCandidates = (model: string): string[] => { + const seen = new Set(); + const out: string[] = []; + for (const d of [...(modelDomainCandidates[model] ?? [model]), ...GLOBAL_FALLBACKS]) { + if (!seen.has(d)) { + seen.add(d); + out.push(d); + } + } + return out; +}; + +const metaCache = new Map>(); + +const fetchLatestMeta = ( + domain: string, + signal?: AbortSignal +): Promise => { + let pending = metaCache.get(domain); + if (!pending) { + pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .catch(() => null); + metaCache.set(domain, pending); + // short TTL so a fresh model run is picked up without a reload + setTimeout(() => metaCache.delete(domain), 60_000); + } + return pending; +}; + +export interface ResolvedSource { + /** The maps domain we actually render (may be a global fallback). */ + domain: string; + /** Model run of this domain's latest data. */ + modelRun: Date; + /** Forecast steps as Dates, ascending. */ + times: Date[]; + /** True when we fell back off the user's selected model. */ + fellBack: boolean; +} + +export interface ResolveOptions { + signal?: AbortSignal; + /** + * Drop the first timestep. Accumulation variables (e.g. precipitation, an + * hourly sum) have no value at the analysis time; instantaneous variables + * (e.g. wind) do, so leave this off for them. + */ + skipFirst?: boolean; +} + +/** + * Resolve the selected model to a renderable source for `variable`. Probes each + * candidate domain's `latest.json` in order and returns the first that serves + * the variable. Global fallbacks are appended so there is (almost) always a + * result. + */ +export const resolveSource = async ( + model: string, + variable: string, + opts: ResolveOptions = {} +): Promise => { + const { signal, skipFirst } = opts; + const candidates = domainCandidates(model); + const preferred = new Set(modelDomainCandidates[model] ?? [model]); + + for (const domain of candidates) { + const meta = await fetchLatestMeta(domain, signal); + if (signal?.aborted) return null; + if (!meta || !meta.variables?.includes(variable) || !meta.valid_times?.length) continue; + + const sorted = meta.valid_times + .map((s) => new Date(s)) + .filter((d) => !isNaN(d.getTime())) + .sort((a, b) => a.getTime() - b.getTime()); + const times = skipFirst && sorted.length > 1 ? sorted.slice(1) : sorted; + if (!times.length) continue; + + return { + domain, + modelRun: new Date(meta.reference_time), + times, + fellBack: !preferred.has(domain) + }; + } + return null; +}; diff --git a/src/lib/paywall/PremiumBadge.svelte b/src/lib/paywall/PremiumBadge.svelte index 49f11f8..eba18b4 100644 --- a/src/lib/paywall/PremiumBadge.svelte +++ b/src/lib/paywall/PremiumBadge.svelte @@ -12,7 +12,7 @@ diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index f066350..1f40174 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -115,12 +115,15 @@ export interface UnitPrefs { temperature_unit: 'celsius' | 'fahrenheit'; wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn'; precipitation_unit: 'mm' | 'inch'; + /** Clock format for displayed times: 24-hour or 12-hour AM/PM. */ + time_format: '24h' | '12h'; } export const defaultUnits: UnitPrefs = { temperature_unit: 'celsius', wind_speed_unit: 'kmh', - precipitation_unit: 'mm' + precipitation_unit: 'mm', + time_format: '24h' }; export const storedUnits = persisted('units_v1', defaultUnits); diff --git a/src/lib/utils/maps-domain.ts b/src/lib/utils/maps-domain.ts index 1e9f0ea..300464a 100644 --- a/src/lib/utils/maps-domain.ts +++ b/src/lib/utils/maps-domain.ts @@ -12,7 +12,7 @@ * the map does not serve at all (best_match, bom, google, UKMO ensembles) * resolve to null. */ -const modelDomainCandidates: Record = { +export const modelDomainCandidates: Record = { // DWD Germany icon_seamless: ['dwd_icon_seamless', 'dwd_icon'], icon_global: ['dwd_icon'], diff --git a/src/routes/weather/week/[location]/+page.svelte b/src/routes/weather/week/[location]/+page.svelte index 0670cc2..d6e3ebb 100644 --- a/src/routes/weather/week/[location]/+page.svelte +++ b/src/routes/weather/week/[location]/+page.svelte @@ -1,5 +1,5 @@