Author SHA1 Message Date
Vincent van der Wal ced7fff8e5 maps components 2026-07-25 18:42:03 +02:00
13 changed files with 1677 additions and 8 deletions
View File
+282
View File
@@ -7,6 +7,10 @@
"": { "": {
"name": "drizzli", "name": "drizzli",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
},
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -46,6 +50,43 @@
"vitest-browser-svelte": "^3.0.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -565,6 +606,119 @@
"svelte": "^5" "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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -597,6 +751,10 @@
"node": ">=12.0" "node": ">=12.0"
} }
}, },
"node_modules/@openmeteo/weather-map-layer": {
"resolved": "../../open-meteo/weather-map-layer",
"link": true
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -1393,6 +1551,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2122,6 +2286,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/enhanced-resolve": {
"version": "5.24.2", "version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", "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": "^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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2728,6 +2904,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3102,6 +3290,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -3118,6 +3340,15 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3145,6 +3376,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -3293,6 +3530,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3492,6 +3741,12 @@
"node": ">=4" "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": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3618,6 +3879,12 @@
"node": ">=6" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3632,6 +3899,15 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -4062,6 +4338,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tinyrainbow": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+4
View File
@@ -53,5 +53,9 @@
"vite": "^8.1.5", "vite": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0" "vitest-browser-svelte": "^3.0.0"
},
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
} }
} }
+8
View File
@@ -34,6 +34,14 @@
{ value: 'mm', label: 'mm' }, { value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' } { value: 'inch', label: 'inch' }
] ]
},
{
key: 'time_format',
label: 'Time',
options: [
{ value: '24h', label: '24h' },
{ value: '12h', label: 'AM/PM' }
]
} }
]; ];
+723
View File
@@ -0,0 +1,723 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
import PauseIcon from '@lucide/svelte/icons/pause';
import PlayIcon from '@lucide/svelte/icons/play';
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
import {
clockPlaying,
clockScrub,
clockSpan,
clockSpeed,
sharedCamera
} from '$lib/maps/map-sync';
import {
type ResolvedSource,
omSourceUrl,
registerOmProtocol,
resolveSource
} from '$lib/maps/om';
import { modelGroups } from '../../routes/weather/options';
interface Props {
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
variable: string;
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
title: string;
/**
* Accumulation variable (e.g. precipitation): its first timestep is the
* analysis time with no accumulation window, so skip it. Leave off for
* instantaneous variables like wind.
*/
accumulation?: boolean;
/** Overlay animated wind-arrow vectors on top of the raster field. */
arrows?: boolean;
}
let { variable, title, accumulation = false, arrows = false }: Props = $props();
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
const DAY_MS = 86_400_000;
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
// tiles) - the requested preload parallelism.
const PRELOAD_MAX_FRAMES = 8;
// Playback speeds: ms of minimum wall-time per forecast frame.
const SPEEDS = [
{ label: '0.5×', ms: 900 },
{ label: '1×', ms: 450 },
{ label: '2×', ms: 220 }
];
// Loop / preload span presets (Infinity = the complete model run).
const SPANS = [
{ label: '2½ d', days: 2.5 },
{ label: '5 d', days: 5 },
{ label: 'Full run', days: Infinity }
];
const modelLabel = (id: string): string => {
for (const g of modelGroups) {
for (const m of g.models) if (m.value === id) return m.label;
}
return id;
};
let mapEl: HTMLDivElement;
let map: maplibregl.Map | undefined;
let animator: FrameAnimator | undefined;
let marker: maplibregl.Marker | undefined;
let syncingCamera = false;
// resolved source (full run) + windowed timeline
let sourceBase: ResolvedSource | null = null;
let fullTimes: Date[] = [];
let source = $state<ResolvedSource | null>(null);
let index = $state(0);
let loading = $state(true);
let firstFrameLoading = $state(true);
let error = $state<string | null>(null);
/** Per-frame load state, aligned with `times`. */
let loaded = $state<boolean[]>([]);
// dark mode mirrors the root layout's `.dark` class on <html>
let dark = $state(false);
// preload covers the whole windowed timeline (the loop == the window)
let loopStart = 0;
let loopEnd = 0;
let loadController: AbortController | undefined;
let lastKey = '';
let appliedSpan = get(clockSpan);
let appliedScrubEpoch = get(clockScrub).epoch;
const times = $derived(source?.times ?? []);
const currentTime = $derived(times[index]);
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
// ---- helpers -------------------------------------------------------------
const closestIndexToNow = (list: Date[]): number => {
const now = Date.now();
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < list.length; i++) {
const delta = Math.abs(list[i].getTime() - now);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
};
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
const windowTimes = (all: Date[], spanDays: number): Date[] => {
if (!isFinite(spanDays) || spanDays <= 0) return all;
const dayStart = new SvelteDate();
dayStart.setHours(0, 0, 0, 0);
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
return windowed.length ? windowed : all;
};
const buildUrl = (i: number): string => {
const s = source;
if (!s) return '';
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
};
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
// ---- animator / channels -------------------------------------------------
const makeChannels = (): FrameChannel[] => {
const raster: FrameChannel = {
key: 'raster',
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': 0,
'raster-opacity-transition': { duration: 0, delay: 0 },
'raster-fade-duration': 0
}
},
before
),
opacityProp: 'raster-opacity',
peakOpacity: dark ? 0.85 : 0.9
};
if (!arrows) return [raster];
const arrowLayer: FrameChannel = {
key: 'arrows',
sourceSpec: (url) => ({ type: 'vector', url }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'line',
source: sourceId,
'source-layer': 'wind-arrows',
layout: { 'line-cap': 'round' },
paint: {
'line-opacity': 0,
'line-opacity-transition': { duration: 0, delay: 0 },
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
}
},
before
),
opacityProp: 'line-opacity',
peakOpacity: 1
};
return [raster, arrowLayer];
};
const makeAnimator = (m: maplibregl.Map): void => {
animator?.destroy();
animator = new FrameAnimator({
map: m,
urlForFrame: buildUrl,
channels: makeChannels(),
beforeLayer: firstSymbolLayer(m),
opacity: dark ? 0.85 : 0.9,
onShow: (i) => {
index = i;
if (i < loaded.length) loaded[i] = true;
},
onReady: (i) => {
if (i < loaded.length) loaded[i] = true;
},
onFirstFrame: () => {
firstFrameLoading = false;
}
});
};
/** Chronological preload so the current day fills front-to-back, no gap. */
const preloadOrder = (): number[] => {
const order: number[] = [];
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
return order;
};
const startAnimation = (m: maplibregl.Map): void => {
if (!source) return;
makeAnimator(m);
animator!.setFrameCount(source.times.length);
animator!.setLoopRange(loopStart, loopEnd);
const speed = get(clockSpeed);
animator!.setSpeed(speed);
const initial = closestIndexToNow(source.times);
index = initial;
if (get(clockPlaying)) animator!.play(speed, initial);
else animator!.scrubTo(initial);
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
};
// ---- windowing / (re)load ------------------------------------------------
const applyWindow = (): void => {
if (!sourceBase || !map) return;
const list = windowTimes(fullTimes, get(clockSpan));
source = { ...sourceBase, times: list };
loopStart = 0;
loopEnd = Math.max(0, list.length - 1);
loaded = new Array(list.length).fill(false);
firstFrameLoading = true;
startAnimation(map);
};
const load = async (model: string): Promise<void> => {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
loading = true;
error = null;
const resolved = await resolveSource(model, variable, {
signal: controller.signal,
skipFirst: accumulation
});
if (controller.signal.aborted) return;
if (!resolved) {
error = `No ${title.toLowerCase()} data available for this model right now.`;
loading = false;
return;
}
sourceBase = resolved;
fullTimes = resolved.times;
loading = false;
applyWindow();
};
// ---- theme ---------------------------------------------------------------
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
applyPadding(m);
// setStyle wipes all layers; rebuild the animator for the new theme
if (source) startAnimation(m);
};
// ---- camera / marker -----------------------------------------------------
// 20% bottom padding keeps the focused location above the time scrubber.
const applyPadding = (m: maplibregl.Map): void => {
const h = mapEl?.clientHeight ?? 0;
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
};
const makeMarkerEl = (): HTMLDivElement => {
const el = document.createElement('div');
el.className = 'wx-marker';
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
return el;
};
// ---- lifecycle -----------------------------------------------------------
onMount(() => {
registerOmProtocol();
dark = document.documentElement.classList.contains('dark');
lastKey = `${$storedModel}`;
map = new maplibregl.Map({
container: mapEl,
style: dark ? STYLE_DARK : STYLE_LIGHT,
center: [$storedLocation.longitude, $storedLocation.latitude],
zoom: 7.5,
attributionControl: { compact: true },
maxPitch: 0,
dragRotate: false
});
const m = map;
m.scrollZoom.disable(); // don't hijack page scroll to zoom
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
const pushBounds = () => {
const b = m.getBounds();
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
// mirror this map's camera to siblings (unless the move came from a sync)
m.on('move', () => {
pushBounds();
if (syncingCamera) return;
const c = m.getCenter();
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
});
m.on('resize', () => applyPadding(m));
m.on('load', () => {
pushBounds();
applyPadding(m);
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
.addTo(m);
void load($storedModel);
});
// follow a sibling map's camera
const unsubCamera = sharedCamera.subscribe((cam) => {
if (!cam || !map) return;
const c = map.getCenter();
if (
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
Math.abs(map.getZoom() - cam.zoom) < 1e-6
)
return;
syncingCamera = true;
map.jumpTo({ center: cam.center, zoom: cam.zoom });
syncingCamera = false;
});
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
if (isDark !== dark) {
dark = isDark;
if (map && map.isStyleLoaded()) void applyStyle(map);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
unsubCamera();
animator?.destroy();
loadController?.abort();
marker?.remove();
map?.remove();
map = undefined;
};
});
// reload when the shared model selection changes
$effect(() => {
const key = `${$storedModel}`;
if (map && map.isStyleLoaded() && key !== lastKey) {
lastKey = key;
void load($storedModel);
}
});
// recenter + move the marker when the user picks a new location
$effect(() => {
const loc = $storedLocation;
marker?.setLngLat([loc.longitude, loc.latitude]);
if (map && map.isStyleLoaded()) {
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
}
});
// ---- shared-clock effects ------------------------------------------------
// play / pause together
$effect(() => {
const playing = $clockPlaying;
if (!animator) return;
if (playing) {
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
animator.play(get(clockSpeed), from);
} else {
animator.pause();
}
});
// speed together
$effect(() => {
const ms = $clockSpeed;
animator?.setSpeed(ms);
});
// span together (re-window + restart)
$effect(() => {
const days = $clockSpan;
if (days !== appliedSpan) {
appliedSpan = days;
if (sourceBase) applyWindow();
}
});
// scrub together
$effect(() => {
const s = $clockScrub;
if (s.epoch !== appliedScrubEpoch) {
appliedScrubEpoch = s.epoch;
if (s.index >= 0) {
index = s.index;
animator?.scrubTo(s.index);
}
}
});
// ---- controls (write to the shared clock so every map follows) -----------
const togglePlay = (): void => clockPlaying.update((p) => !p);
const setSpeed = (ms: number): void => clockSpeed.set(ms);
const setSpan = (days: number): void => clockSpan.set(days);
const onScrubInput = (e: Event): void => {
const v = Number((e.currentTarget as HTMLInputElement).value);
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
};
const onScrubStart = (): void => clockPlaying.set(false);
const onScrubEnd = (): void => clockPlaying.set(true);
// ---- formatting / scrubber decorations -----------------------------------
const hour12 = $derived($storedUnits.time_format === '12h');
const fmtTime = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
: '--:--';
const fmtDay = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short'
}).format(d)
: '';
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
const dayMarkers = $derived.by(() => {
const out: { i: number; label: string; left: number }[] = [];
let prevDay = '';
times.forEach((t, i) => {
const day = t.toDateString();
if (day !== prevDay) {
prevDay = day;
out.push({
i,
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
left: pct(i)
});
}
});
return out;
});
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
</script>
<div class="relative h-full w-full">
<div bind:this={mapEl} class="h-full w-full"></div>
<!-- top-left status: variable + model + fallback notice -->
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
<div
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
>
{title} · {modelLabel($storedModel)}
</div>
{#if source?.fellBack}
<div
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
>
No map for this model - showing global {source.domain}
</div>
{/if}
</div>
{#if error}
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
{error}
</div>
</div>
{/if}
<!-- big scrubber -->
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
<div
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
>
<div class="flex items-center gap-3 sm:gap-4">
<button
type="button"
onclick={togglePlay}
disabled={loading || !!error || times.length < 2}
aria-label={$clockPlaying ? 'Pause' : 'Play'}
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
>
{#if loading || firstFrameLoading}
<LoaderIcon class="size-6 animate-spin" />
{:else if $clockPlaying}
<PauseIcon class="size-6" />
{:else}
<PlayIcon class="size-6 translate-x-0.5" />
{/if}
</button>
<div class="flex min-w-0 flex-col">
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
{fmtTime(currentTime)}
</span>
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
{fmtDay(currentTime)}
</span>
</div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
{#if times.length}
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
{loadedCount}/{times.length} cached
</span>
{/if}
<!-- range: how much to loop / preload -->
<div
class="flex items-center gap-1 rounded-lg bg-muted p-1"
title="Loop &amp; preload range"
>
{#each SPANS as s (s.label)}
<button
type="button"
onclick={() => setSpan(s.days)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpan === s.days
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
<!-- playback speed -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
{#each SPEEDS as s (s.ms)}
<button
type="button"
onclick={() => setSpeed(s.ms)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpeed === s.ms
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
</div>
</div>
<!-- track -->
<div class="relative mt-3">
<input
type="range"
class="scrubber relative"
min="0"
max={Math.max(0, times.length - 1)}
step="1"
value={index}
disabled={loading || !!error || !times.length}
oninput={onScrubInput}
onpointerdown={onScrubStart}
onpointerup={onScrubEnd}
onpointercancel={onScrubEnd}
aria-label="Forecast time"
/>
<!-- now marker -->
{#if times.length}
<div
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
style="left: {nowLeft}%"
title="Now"
></div>
{/if}
<!-- buffered-frames strip: shows which steps are cached -->
{#if times.length}
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
{#each times as _t, i (i)}
<div
class="h-full flex-1 transition-colors {loaded[i]
? 'bg-primary'
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
></div>
{/each}
</div>
{/if}
<!-- day labels -->
<div class="relative mt-1 h-4">
{#each dayMarkers as d (d.i)}
<span
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
style="left: {Math.min(96, Math.max(4, d.left))}%"
>
{d.label}
</span>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
/* Big, prominent scrubber that works in light and dark. */
.scrubber {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
cursor: pointer;
outline: none;
}
.scrubber:disabled {
opacity: 0.5;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-webkit-slider-thumb:active {
cursor: grabbing;
}
.scrubber::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-moz-range-progress {
height: 10px;
border-radius: 9999px;
background: var(--color-primary);
}
/* Pulsing "current location" marker (added to the map's own DOM, so styles
must be global). */
:global(.wx-marker) {
position: relative;
width: 20px;
height: 20px;
pointer-events: none;
}
:global(.wx-marker__dot) {
position: absolute;
inset: 6px;
border-radius: 9999px;
background: #ef4444;
box-shadow:
0 0 0 2px #fff,
0 1px 3px rgba(0, 0, 0, 0.5);
}
:global(.wx-marker__ring) {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.55);
animation: wx-pulse 1.5s ease-out infinite;
}
@keyframes -global-wx-pulse {
0% {
transform: scale(0.4);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
</style>
+379
View File
@@ -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<string>;
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<number, Entry>();
private visible = new Set<number>();
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<number>();
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<number>): 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();
}
}
+33
View File
@@ -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<SharedCamera | null>(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<ScrubState>({ index: -1, epoch: 0 });
+166
View File
@@ -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<string>();
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<string, Promise<DomainMetaDataJson | null>>();
const fetchLatestMeta = (
domain: string,
signal?: AbortSignal
): Promise<DomainMetaDataJson | null> => {
let pending = metaCache.get(domain);
if (!pending) {
pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal })
.then((r) => (r.ok ? (r.json() as Promise<DomainMetaDataJson>) : 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<ResolvedSource | null> => {
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;
};
+3 -5
View File
@@ -12,7 +12,7 @@
<button <button
type="button" type="button"
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-semibold transition-colors {$isPremium class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors {$isPremium
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300' ? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}" : 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => (open = true)} onclick={() => (open = true)}
@@ -21,16 +21,15 @@
> >
{#if $isPremium} {#if $isPremium}
<!-- star --> <!-- star -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> <svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path <path
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z" d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/> />
</svg> </svg>
<span class="hidden sm:inline">Premium</span>
{:else} {:else}
<!-- padlock --> <!-- padlock -->
<svg <svg
class="h-4 w-4" class="h-4.5 w-4.5"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -40,7 +39,6 @@
<rect x="4" y="10" width="16" height="11" rx="2" /> <rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" /> <path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg> </svg>
<span class="hidden sm:inline">Premium</span>
{/if} {/if}
</button> </button>
+4 -1
View File
@@ -115,12 +115,15 @@ export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit'; temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn'; wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch'; precipitation_unit: 'mm' | 'inch';
/** Clock format for displayed times: 24-hour or 12-hour AM/PM. */
time_format: '24h' | '12h';
} }
export const defaultUnits: UnitPrefs = { export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius', temperature_unit: 'celsius',
wind_speed_unit: 'kmh', wind_speed_unit: 'kmh',
precipitation_unit: 'mm' precipitation_unit: 'mm',
time_format: '24h'
}; };
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits); export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
+1 -1
View File
@@ -12,7 +12,7 @@
* the map does not serve at all (best_match, bom, google, UKMO ensembles) * the map does not serve at all (best_match, bom, google, UKMO ensembles)
* resolve to null. * resolve to null.
*/ */
const modelDomainCandidates: Record<string, string[]> = { export const modelDomainCandidates: Record<string, string[]> = {
// DWD Germany // DWD Germany
icon_seamless: ['dwd_icon_seamless', 'dwd_icon'], icon_seamless: ['dwd_icon_seamless', 'dwd_icon'],
icon_global: ['dwd_icon'], icon_global: ['dwd_icon'],
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { type Component, onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -71,6 +71,19 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
let requestVersion = 0; let requestVersion = 0;
// The weather maps (MapLibre + WebGL + web workers) are browser-only and
// heavy, so the component is imported lazily - and only once the maps area
// scrolls near the viewport - keeping this prerendered page SSR-safe and the
// initial load light.
type WeatherMapProps = {
variable: string;
title: string;
accumulation?: boolean;
arrows?: boolean;
};
let WeatherMap = $state<Component<WeatherMapProps> | null>(null);
let mapSection: HTMLElement | undefined;
// 7 by default; the user can extend to the model's longer range (up to 16 days) // 7 by default; the user can extend to the model's longer range (up to 16 days)
let forecastDays = $state(7); let forecastDays = $state(7);
// 0 by default; the user can pull in a few recent past days // 0 by default; the user can pull in a few recent past days
@@ -91,6 +104,20 @@
// preselect the persisted model (client-only so prerendered HTML stays stable) // preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)]; params.models = [get(storedModel)];
mounted = true; mounted = true;
// load the map bundle only when its section is about to enter the viewport
if (!mapSection) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
import('$lib/maps/WeatherMap.svelte').then((m) => (WeatherMap = m.default));
}
},
{ rootMargin: '300px' }
);
io.observe(mapSection);
return () => io.disconnect();
}); });
$effect(() => { $effect(() => {
@@ -258,5 +285,37 @@
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} /> <ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section> </section>
{/if} {/if}
<!-- Animated weather maps for the shown forecast window. The wrapper is
always present so it can be observed; the browser-only map bundle is
fetched only once it scrolls near the viewport, then each map follows
the selected model / location. -->
<div bind:this={mapSection}>
<section class="mt-8">
<h2 class="mb-3 text-lg font-semibold tracking-tight">Precipitation map</h2>
<div
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
>
{#if WeatherMap}
<WeatherMap variable="precipitation" title="Precipitation" accumulation />
{:else}
<div class="h-full w-full animate-pulse bg-muted"></div>
{/if}
</div>
</section>
<section class="mt-8">
<h2 class="mb-3 text-lg font-semibold tracking-tight">Wind map</h2>
<div
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
>
{#if WeatherMap}
<WeatherMap variable="wind_u_component_10m" title="Wind" arrows />
{:else}
<div class="h-full w-full animate-pulse bg-muted"></div>
{/if}
</div>
</section>
</div>
</div> </div>
</div> </div>
+14
View File
@@ -32,6 +32,20 @@ const viteServerConfig = (): Plugin => ({
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), viteServerConfig()], plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
// @openmeteo/weather-map-layer is linked from ../../open-meteo/weather-map-layer
// (a file: dependency), so allow Vite to serve files from the shared Projects
// root and keep a single maplibre-gl instance across drizzli and the layer.
server: {
fs: { allow: ['../..'] }
},
resolve: {
dedupe: ['maplibre-gl']
},
optimizeDeps: {
// these ship WASM and must not be pre-bundled by esbuild
exclude: ['@openmeteo/file-reader', '@openmeteo/file-format-wasm']
},
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },