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}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {fmtTime(currentTime)}
+
+
+ {fmtDay(currentTime)}
+
+
+
+
+ {#if times.length}
+
+ {loadedCount}/{times.length} cached
+
+ {/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 @@