Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced7fff8e5 | ||
|
|
6b2db975b3 | ||
|
|
d37c95a46d | ||
|
|
cc1589b017 | ||
|
|
61d84e4450 | ||
|
|
f8d7c7699f | ||
|
|
1d7d9d3a53 | ||
|
|
c7924bb0ae |
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
# Local agent/editor config
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Drizzli
|
# Drizz.li
|
||||||
|
|
||||||
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
|
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
|
||||||
|
|
||||||
|
|||||||
Generated
+282
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,32 +250,6 @@
|
|||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiply an rgb/hex colour toward black by `amount` (0 = unchanged, 1 = black).
|
|
||||||
function darken(color: string, amount: number): string {
|
|
||||||
const f = 1 - amount;
|
|
||||||
const m = color.match(/rgba?\(([^)]+)\)/);
|
|
||||||
if (m) {
|
|
||||||
const [r, g, b, a] = m[1].split(',').map((p) => parseFloat(p));
|
|
||||||
const alpha = isNaN(a) ? 1 : a;
|
|
||||||
return `rgba(${Math.round(r * f)}, ${Math.round(g * f)}, ${Math.round(b * f)}, ${alpha})`;
|
|
||||||
}
|
|
||||||
if (color[0] === '#') {
|
|
||||||
const h = color.slice(1);
|
|
||||||
const n =
|
|
||||||
h.length === 3
|
|
||||||
? h
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('')
|
|
||||||
: h;
|
|
||||||
const r = Math.round(parseInt(n.slice(0, 2), 16) * f);
|
|
||||||
const g = Math.round(parseInt(n.slice(2, 4), 16) * f);
|
|
||||||
const b = Math.round(parseInt(n.slice(4, 6), 16) * f);
|
|
||||||
return `rgb(${r}, ${g}, ${b})`;
|
|
||||||
}
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let containerEl: HTMLDivElement | undefined = $state();
|
let containerEl: HTMLDivElement | undefined = $state();
|
||||||
@@ -323,17 +297,20 @@
|
|||||||
let isNarrow = $derived(width > 0 && width < 520);
|
let isNarrow = $derived(width > 0 && width < 520);
|
||||||
let padLeft = $derived(isNarrow ? 26 : 60);
|
let padLeft = $derived(isNarrow ? 26 : 60);
|
||||||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||||||
// has a right axis, so a stacked row of charts share the same plot width.
|
// has a right axis, so a stacked row of charts share the same plot width. On
|
||||||
let padRight = $derived(
|
// narrow (mobile) screens we don't reserve it — the graph uses the full width
|
||||||
hasRightAxis || reserveRightAxis ? (isNarrow ? 34 : 56) : isNarrow ? 6 : 20
|
// and the right-axis labels are drawn overlaid on top instead (see below).
|
||||||
);
|
let padRight = $derived(isNarrow ? 6 : hasRightAxis || reserveRightAxis ? 56 : 20);
|
||||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||||||
// a stacked row of charts the same height even if some have fewer icon rows.
|
// a stacked row of charts the same height even if some have fewer icon rows.
|
||||||
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
||||||
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
|
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
|
||||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
|
// tighter top/bottom gutters on mobile so charts don't waste vertical space
|
||||||
|
const iconRowH = $derived(isNarrow ? 30 : ICON_ROW_H);
|
||||||
|
let padTop = $derived((title ? (subtitle ? 66 : 46) : isNarrow ? 14 : 28) + iconRows * iconRowH);
|
||||||
|
let padBottom = $derived(isNarrow ? 24 : PAD_BOTTOM);
|
||||||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
||||||
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
let plotH = $derived(Math.max(1, height - padTop - padBottom));
|
||||||
|
|
||||||
interface Scale {
|
interface Scale {
|
||||||
min: number;
|
min: number;
|
||||||
@@ -452,6 +429,153 @@
|
|||||||
return canvasEl ? canvasEl.toDataURL('image/png') : null;
|
return canvasEl ? canvasEl.toDataURL('image/png') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Full export (title + icon bands + legend composited onto one canvas) ────
|
||||||
|
// The canvas alone omits the DOM overlays (weather/wind icons), the panel
|
||||||
|
// title (rendered by the parent), and the legend (an HTML row). This builds a
|
||||||
|
// standalone canvas that includes all of them so downloads look like the page.
|
||||||
|
const iconImageCache = new Map<string, Promise<HTMLImageElement>>();
|
||||||
|
function loadColoredIcon(name: string, color: string): Promise<HTMLImageElement> {
|
||||||
|
const key = `${name}|${color}`;
|
||||||
|
let p = iconImageCache.get(key);
|
||||||
|
if (!p) {
|
||||||
|
p = fetch(`/images/weather-icons/${name}.svg`)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then(
|
||||||
|
(svg) =>
|
||||||
|
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
// the paths carry no fill, so a root fill tints the whole glyph
|
||||||
|
const colored = svg.replace(/<svg\b/, `<svg fill="${color}"`);
|
||||||
|
const url = URL.createObjectURL(new Blob([colored], { type: 'image/svg+xml' }));
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(img);
|
||||||
|
};
|
||||||
|
img.onerror = (e) => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
iconImageCache.set(key, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composite the chart, its icon bands, an optional title, and the legend onto
|
||||||
|
* a fresh canvas for export. Async because the icon SVGs are rasterized.
|
||||||
|
*/
|
||||||
|
export async function getExportImage(opts?: {
|
||||||
|
title?: string;
|
||||||
|
}): Promise<HTMLCanvasElement | null> {
|
||||||
|
if (!canvasEl || !containerEl || width <= 0) return null;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const styles = getComputedStyle(containerEl);
|
||||||
|
const cssVar = (n: string, f: string) => styles.getPropertyValue(n).trim() || f;
|
||||||
|
const strong = cssVar('--foreground', '#374151');
|
||||||
|
const muted = cssVar('--muted-foreground', '#6b7280');
|
||||||
|
const bg = cssVar('--card', '#ffffff');
|
||||||
|
|
||||||
|
const title = opts?.title;
|
||||||
|
const titleH = title ? 30 : 0;
|
||||||
|
const legendItems = showLegend ? series.filter((s) => s.showInLegend !== false) : [];
|
||||||
|
const legendH = legendItems.length > 0 ? 28 : 0;
|
||||||
|
const totalH = titleH + height + legendH;
|
||||||
|
|
||||||
|
const out = document.createElement('canvas');
|
||||||
|
out.width = Math.round(width * dpr);
|
||||||
|
out.height = Math.round(totalH * dpr);
|
||||||
|
const ctx = out.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, width, totalH);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
ctx.fillStyle = strong;
|
||||||
|
ctx.font = '600 14px system-ui, -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(title, 4, titleH / 2 + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the plot itself (canvasEl is a dpr-scaled bitmap of the css-sized chart)
|
||||||
|
ctx.drawImage(canvasEl, 0, titleH, width, height);
|
||||||
|
|
||||||
|
// weather pictograms
|
||||||
|
for (const p of visiblePictograms) {
|
||||||
|
try {
|
||||||
|
const img = await loadColoredIcon(p.icon, strong);
|
||||||
|
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x));
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
iconBandLeft + cx - ICON_PX / 2,
|
||||||
|
titleH + pictoRowTop + (ICON_BAND_H - ICON_PX) / 2,
|
||||||
|
ICON_PX,
|
||||||
|
ICON_PX
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* skip an icon that failed to rasterize */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// wind-direction arrows, rotated about their centre
|
||||||
|
for (const a of visibleWindArrows) {
|
||||||
|
try {
|
||||||
|
const img = await loadColoredIcon('wi-direction-down', strong);
|
||||||
|
const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x));
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(iconBandLeft + cx, titleH + windRowTop + ICON_BAND_H / 2);
|
||||||
|
ctx.rotate((a.deg * Math.PI) / 180);
|
||||||
|
ctx.globalAlpha = 0.8;
|
||||||
|
ctx.drawImage(img, -ARROW_PX / 2, -ARROW_PX / 2, ARROW_PX, ARROW_PX);
|
||||||
|
ctx.restore();
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// legend row, centred like the on-screen one
|
||||||
|
if (legendItems.length > 0) {
|
||||||
|
ctx.font = '12px system-ui, -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
const gap = 12;
|
||||||
|
const dot = 9;
|
||||||
|
const dotGap = 6;
|
||||||
|
const widths = legendItems.map((s) => dot + dotGap + ctx.measureText(s.name).width);
|
||||||
|
const totalW = widths.reduce((a, b) => a + b, 0) + gap * (legendItems.length - 1);
|
||||||
|
let x = Math.max(4, (width - totalW) / 2);
|
||||||
|
const y = titleH + height + legendH / 2;
|
||||||
|
for (let i = 0; i < legendItems.length; i++) {
|
||||||
|
const s = legendItems[i];
|
||||||
|
ctx.fillStyle = s.color;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + dot / 2, y, dot / 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = muted;
|
||||||
|
ctx.fillText(s.name, x + dot + dotGap, y);
|
||||||
|
x += widths[i] + gap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// credit hugging the bottom-right corner of the export
|
||||||
|
if (showCredit) {
|
||||||
|
ctx.font = '10px system-ui, -apple-system, sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
ctx.fillStyle = muted;
|
||||||
|
ctx.globalAlpha = 0.8;
|
||||||
|
ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
|
/** Zooms the x axis to the given epoch-second range (clamped to the data). */
|
||||||
export function setRange(startEpoch: number, endEpoch: number): void {
|
export function setRange(startEpoch: number, endEpoch: number): void {
|
||||||
applyRange(startEpoch, endEpoch);
|
applyRange(startEpoch, endEpoch);
|
||||||
@@ -551,8 +675,8 @@
|
|||||||
// Vertical offset (px from container top) of each icon row's top edge, anchored
|
// Vertical offset (px from container top) of each icon row's top edge, anchored
|
||||||
// just above the plot. When both rows are present, pictograms sit above the
|
// just above the plot. When both rows are present, pictograms sit above the
|
||||||
// wind arrows (which stay closest to the plot).
|
// wind arrows (which stay closest to the plot).
|
||||||
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
|
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * iconRowH + 2);
|
||||||
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
|
let windRowTop = $derived(padTop - iconRowH + 2);
|
||||||
|
|
||||||
// ─── Local minima / maxima (for value labels) ────────────────────────────────
|
// ─── Local minima / maxima (for value labels) ────────────────────────────────
|
||||||
|
|
||||||
@@ -608,13 +732,25 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A "EEE d" day label needs ~46px of room; when days are packed tighter
|
||||||
|
// (long ranges on a narrow screen) keep every day's gridline but only
|
||||||
|
// label every Nth one so the dates never overlap.
|
||||||
|
const pxPerDay = 24 * pxPerHour;
|
||||||
|
const dayStride = pxPerDay >= 46 ? 1 : Math.max(1, Math.ceil(46 / pxPerDay));
|
||||||
const ticks: XTick[] = [];
|
const ticks: XTick[] = [];
|
||||||
const first = Math.ceil(viewStart / HOUR) * HOUR;
|
const first = Math.ceil(viewStart / HOUR) * HOUR;
|
||||||
|
let dayCount = 0;
|
||||||
for (let t = first; t <= viewEnd; t += HOUR) {
|
for (let t = first; t <= viewEnd; t += HOUR) {
|
||||||
const date = new Date(t * 1000);
|
const date = new Date(t * 1000);
|
||||||
const hour = getZonedHour(date, timezone);
|
const hour = getZonedHour(date, timezone);
|
||||||
if (hour === 0) {
|
if (hour === 0) {
|
||||||
ticks.push({ t, label: formatZoned(date, timezone, 'EEE d'), isDay: true });
|
const showLabel = dayCount % dayStride === 0;
|
||||||
|
dayCount++;
|
||||||
|
ticks.push({
|
||||||
|
t,
|
||||||
|
label: showLabel ? formatZoned(date, timezone, 'EEE d') : '',
|
||||||
|
isDay: true
|
||||||
|
});
|
||||||
} else if (step < 24 && hour % step === 0) {
|
} else if (step < 24 && hour % step === 0) {
|
||||||
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
|
ticks.push({ t, label: formatZoned(date, timezone, 'HH:mm'), isDay: false });
|
||||||
}
|
}
|
||||||
@@ -661,14 +797,16 @@
|
|||||||
const plotBottom = padTop + plotH;
|
const plotBottom = padTop + plotH;
|
||||||
const font = '11px system-ui, sans-serif';
|
const font = '11px system-ui, sans-serif';
|
||||||
|
|
||||||
// Daylight bands
|
// Daylight bands (kept subtle so they don't compete with the data)
|
||||||
ctx.fillStyle = CHART_COLORS.daylight;
|
ctx.fillStyle = CHART_COLORS.daylight;
|
||||||
|
ctx.globalAlpha = 0.65;
|
||||||
for (const band of bands) {
|
for (const band of bands) {
|
||||||
if (band.end < viewStart || band.start > viewEnd) continue;
|
if (band.end < viewStart || band.start > viewEnd) continue;
|
||||||
const x1 = Math.max(padLeft, xPix(band.start));
|
const x1 = Math.max(padLeft, xPix(band.start));
|
||||||
const x2 = Math.min(plotRight, xPix(band.end));
|
const x2 = Math.min(plotRight, xPix(band.end));
|
||||||
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
|
||||||
}
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
// Selected-day highlight: soft tint + dashed edge lines
|
// Selected-day highlight: soft tint + dashed edge lines
|
||||||
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
|
||||||
@@ -713,8 +851,10 @@
|
|||||||
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right axis labels (only when a unit is provided)
|
// Right axis labels in the gutter (desktop only). On mobile there is no
|
||||||
if (hasRightAxis && unitRight !== undefined) {
|
// gutter — the labels are drawn on top of the graph with halos after the
|
||||||
|
// series (see drawOverlaidAxisLabels below), so the data can't cover them.
|
||||||
|
if (hasRightAxis && unitRight !== undefined && !isNarrow) {
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
ctx.fillStyle = textColor;
|
ctx.fillStyle = textColor;
|
||||||
for (
|
for (
|
||||||
@@ -843,11 +983,11 @@
|
|||||||
// anchor = opaque end (far-from-zero extreme); fade = transparent end
|
// anchor = opaque end (far-from-zero extreme); fade = transparent end
|
||||||
const anchorY = goUp ? minYp : maxYp;
|
const anchorY = goUp ? minYp : maxYp;
|
||||||
const nearY = goUp ? maxYp : minYp;
|
const nearY = goUp ? maxYp : minYp;
|
||||||
// fade runs a good stretch past the near extreme, but stops short of
|
// fade runs a good stretch past the near extreme, flowing most of the
|
||||||
// the plot edge (~60% of the way there)
|
// way to the plot edge (~78% of the way there)
|
||||||
const fadeY = goUp
|
const fadeY = goUp
|
||||||
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.6)
|
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78)
|
||||||
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.6);
|
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78);
|
||||||
const anchorV = goUp ? minV : maxV;
|
const anchorV = goUp ? minV : maxV;
|
||||||
const nearV = goUp ? maxV : minV;
|
const nearV = goUp ? maxV : minV;
|
||||||
const span = fadeY - anchorY;
|
const span = fadeY - anchorY;
|
||||||
@@ -876,7 +1016,11 @@
|
|||||||
ctx.lineTo(points[0][0], fadeY);
|
ctx.lineTo(points[0][0], fadeY);
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fillStyle = grad;
|
ctx.fillStyle = grad;
|
||||||
|
// the bright temperature colours glow over a dark background, so
|
||||||
|
// knock the whole fill back to 75% in dark mode
|
||||||
|
ctx.globalAlpha = dark ? 0.75 : 1;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.fill && points.length > 1) {
|
if (s.fill && points.length > 1) {
|
||||||
@@ -946,16 +1090,15 @@
|
|||||||
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
|
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
|
||||||
if (t > 1) t = 1;
|
if (t > 1) t = 1;
|
||||||
prevT = t;
|
prevT = t;
|
||||||
// a touch darker than the fill so the line reads as its edge
|
// exact same colour as the fill so the line and gradient match
|
||||||
grad.addColorStop(
|
grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3]));
|
||||||
t,
|
|
||||||
darken(s.segmentColor(s.data[points[i][3]] as number, points[i][3]), 0.05)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
ctx.strokeStyle = grad;
|
ctx.strokeStyle = grad;
|
||||||
} else {
|
} else {
|
||||||
ctx.strokeStyle = strongColor;
|
ctx.strokeStyle = strongColor;
|
||||||
}
|
}
|
||||||
|
// keep the line crisp (full opacity) as a clean edge; only the
|
||||||
|
// large fill area is dimmed in dark mode
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
} else {
|
} else {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -1001,6 +1144,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile: right-axis labels overlaid on top of the data with a halo (no
|
||||||
|
// gutter is reserved on narrow screens, so the graph runs full width).
|
||||||
|
if (isNarrow && hasRightAxis && unitRight !== undefined) {
|
||||||
|
ctx.font = font;
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
for (
|
||||||
|
let v = rightScale.min;
|
||||||
|
v <= rightScale.max + rightScale.step / 2;
|
||||||
|
v += rightScale.step
|
||||||
|
) {
|
||||||
|
const y = yPix(v, 'right');
|
||||||
|
const label = v.toFixed(tickDecimals(rightScale.step));
|
||||||
|
ctx.strokeStyle = bgColor;
|
||||||
|
ctx.strokeText(label, plotRight - 2, y);
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(label, plotRight - 2, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Current time marker
|
// Current time marker
|
||||||
if (showNow) {
|
if (showNow) {
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -1042,9 +1207,20 @@
|
|||||||
ctx.fillText(unit, padLeft - 4, padTop - 8);
|
ctx.fillText(unit, padLeft - 4, padTop - 8);
|
||||||
}
|
}
|
||||||
if (hasRightAxis && unitRight) {
|
if (hasRightAxis && unitRight) {
|
||||||
ctx.textAlign = 'left';
|
if (isNarrow) {
|
||||||
ctx.fillStyle = textColor;
|
// overlaid inside the plot's top-right corner (no right gutter) with a halo
|
||||||
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
|
ctx.textAlign = 'right';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.strokeStyle = bgColor;
|
||||||
|
ctx.strokeText(unitRight, plotRight - 2, padTop - 8);
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(unitRight, plotRight - 2, padTop - 8);
|
||||||
|
} else {
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillStyle = textColor;
|
||||||
|
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Title / subtitle
|
// Title / subtitle
|
||||||
@@ -1060,15 +1236,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credit watermark
|
// Credit is a DOM overlay (below) so its two sources can be links; the
|
||||||
if (showCredit) {
|
// export path redraws it onto the exported canvas in getExportImage().
|
||||||
ctx.textAlign = 'right';
|
|
||||||
ctx.font = '10px system-ui, sans-serif';
|
|
||||||
ctx.globalAlpha = 0.4;
|
|
||||||
ctx.fillStyle = strongColor;
|
|
||||||
ctx.fillText('Open-Meteo.com', width - 10, height - 6);
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -1369,7 +1538,9 @@
|
|||||||
|
|
||||||
<!-- Legend sits below the graph -->
|
<!-- Legend sits below the graph -->
|
||||||
{#if showLegend && series.length > 0}
|
{#if showLegend && series.length > 0}
|
||||||
<div class="mt-1.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 px-1">
|
<div
|
||||||
|
class="mt-0.5 flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 px-1 md:mt-1.5 md:gap-y-1"
|
||||||
|
>
|
||||||
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
{#each series.filter((s) => s.showInLegend !== false) as s (s.name)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1392,4 +1563,24 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Credit: below the legend, in the bottom-right corner, with linked sources -->
|
||||||
|
{#if showCredit}
|
||||||
|
<div
|
||||||
|
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
|
||||||
|
>
|
||||||
|
Weather data by <a
|
||||||
|
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href="https://open-meteo.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Open-Meteo</a
|
||||||
|
>, visualisation by
|
||||||
|
<a
|
||||||
|
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
href="https://drizz.li"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Drizz.li</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -124,9 +124,9 @@
|
|||||||
min-width: var(--chart-min-width);
|
min-width: var(--chart-min-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: fit the chart to the viewport instead of forcing a min-width
|
/* Below lg: fit the chart to the viewport instead of forcing a min-width
|
||||||
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
|
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 1023px) {
|
||||||
.chart-container {
|
.chart-container {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 1024px) {
|
||||||
.chart-bleed {
|
.chart-bleed {
|
||||||
margin-left: -1.5rem;
|
margin-left: -1.5rem;
|
||||||
margin-right: -1.5rem;
|
margin-right: -1.5rem;
|
||||||
|
|||||||
@@ -19,11 +19,11 @@
|
|||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
-->
|
-->
|
||||||
<script module lang="ts">
|
<script module lang="ts">
|
||||||
export type { DownloadableChart } from './downloadChartsPng';
|
export type { ExportableChart } from './downloadChartsPng';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type DownloadableChart, downloadChartsPng } from './downloadChartsPng';
|
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
|
||||||
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Chart components available for download (undefined entries are skipped) */
|
/** Chart components available for download (undefined entries are skipped) */
|
||||||
charts?: Array<DownloadableChart | undefined | null>;
|
charts?: Array<ExportableChart | undefined | null>;
|
||||||
/** Base file name for downloaded images (without extension) */
|
/** Base file name for downloaded images (without extension) */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/** Optional CSS class for the outer container */
|
/** Optional CSS class for the outer container */
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Shared PNG export for charts.
|
* Shared PNG export for charts.
|
||||||
*
|
*
|
||||||
* Stitches one or more chart images vertically onto a single canvas (over the
|
* Each chart composites itself (plot + icon bands + optional title + legend)
|
||||||
* current theme background) and triggers a download. Used by both the standalone
|
* onto a canvas via `getExportImage`; those are stacked vertically over the
|
||||||
* ChartToolbar and the inline toolbar buttons.
|
* current theme background and downloaded as one PNG.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
/** A chart that can composite itself (plot + icons + legend) for export. */
|
||||||
export interface DownloadableChart {
|
export interface ExportableChart {
|
||||||
getPngDataUrl(): string | null;
|
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
/** One chart to export, with the title to render above it (e.g. panel name). */
|
||||||
return new Promise((resolve) => {
|
export interface ChartExportItem {
|
||||||
const img = new Image();
|
chart: ExportableChart | null | undefined;
|
||||||
img.onload = () => resolve(img);
|
title?: string;
|
||||||
img.onerror = () => resolve(img);
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the page background so exports match the current theme. */
|
/** Resolves the page background so exports match the current theme. */
|
||||||
@@ -38,25 +35,30 @@ function triggerDownload(url: string, name: string): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalise either a bare chart or a {chart, title} item. */
|
||||||
|
function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartExportItem {
|
||||||
|
if (x && 'getExportImage' in x) return { chart: x };
|
||||||
|
return (x as ChartExportItem) ?? { chart: null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stitch the given charts into one PNG and download it. Resolves once the
|
* Stitch the given charts into one PNG and download it. Resolves once the
|
||||||
* download has been triggered (or immediately if there is nothing to export).
|
* download has been triggered (or immediately if there is nothing to export).
|
||||||
*/
|
*/
|
||||||
export async function downloadChartsPng(
|
export async function downloadChartsPng(
|
||||||
charts: Array<DownloadableChart | undefined | null>,
|
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
|
||||||
fileName: string
|
fileName: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const dataUrls = charts
|
const items = charts.map(toItem).filter((it) => it.chart != null);
|
||||||
.filter((chart): chart is DownloadableChart => chart != null)
|
if (items.length === 0) return;
|
||||||
.map((chart) => chart.getPngDataUrl())
|
|
||||||
.filter((url): url is string => url !== null);
|
|
||||||
if (dataUrls.length === 0) return;
|
|
||||||
|
|
||||||
const images = (await Promise.all(dataUrls.map(loadImage))).filter((img) => img.naturalWidth > 0);
|
const canvases = (
|
||||||
if (images.length === 0) return;
|
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
|
||||||
|
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
|
||||||
|
if (canvases.length === 0) return;
|
||||||
|
|
||||||
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
||||||
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
|
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = maxWidth;
|
canvas.width = maxWidth;
|
||||||
@@ -69,9 +71,9 @@ export async function downloadChartsPng(
|
|||||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||||
|
|
||||||
let y = 0;
|
let y = 0;
|
||||||
for (const img of images) {
|
for (const c of canvases) {
|
||||||
ctx.drawImage(img, 0, y);
|
ctx.drawImage(c, 0, y);
|
||||||
y += img.naturalHeight;
|
y += c.height;
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||||
|
|||||||
@@ -9,4 +9,4 @@
|
|||||||
|
|
||||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||||
export { downloadChartsPng, type DownloadableChart } from './downloadChartsPng';
|
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||||
|
|
||||||
|
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,8 @@
|
|||||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/historical')) {
|
||||||
|
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||||
}
|
}
|
||||||
@@ -57,7 +61,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Mobile menu toggle -->
|
<!-- Mobile menu toggle -->
|
||||||
<button
|
<button
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
|
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
||||||
onclick={onMenuToggle}
|
onclick={onMenuToggle}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
@@ -105,6 +109,9 @@
|
|||||||
<!-- Measurement units -->
|
<!-- Measurement units -->
|
||||||
<UnitSelector />
|
<UnitSelector />
|
||||||
|
|
||||||
|
<!-- Premium status / unlock -->
|
||||||
|
<PremiumBadge />
|
||||||
|
|
||||||
<!-- Theme toggle: system → light → dark -->
|
<!-- Theme toggle: system → light → dark -->
|
||||||
<button
|
<button
|
||||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Historical',
|
||||||
|
url: '/weather/historical' as const,
|
||||||
|
// clock with a counter-clockwise arrow (history)
|
||||||
|
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Maps',
|
title: 'Maps',
|
||||||
url: '/weather/maps' as const,
|
url: '/weather/maps' as const,
|
||||||
@@ -87,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
|
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
|
||||||
Drizzli
|
Drizz.li
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
@@ -129,7 +135,7 @@
|
|||||||
{#if onToggle}
|
{#if onToggle}
|
||||||
<div class="border-t border-sidebar-border px-2 py-3">
|
<div class="border-t border-sidebar-border px-2 py-3">
|
||||||
<button
|
<button
|
||||||
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||||
onclick={onToggle}
|
onclick={onToggle}
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
||||||
|
|||||||
@@ -22,6 +22,26 @@
|
|||||||
children: Snippet;
|
children: Snippet;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// Keep the (fixed, layout-viewport-centered) dialog centered within the
|
||||||
|
// VISUAL viewport, so the mobile keyboard shifts it up instead of covering it.
|
||||||
|
let kbShift = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
const update = () => {
|
||||||
|
const visualCenter = vv.offsetTop + vv.height / 2;
|
||||||
|
// negative when the keyboard eats the bottom → moves the dialog up
|
||||||
|
kbShift = Math.min(0, visualCenter - window.innerHeight / 2);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPortal {...portalProps}>
|
<DialogPortal {...portalProps}>
|
||||||
@@ -33,6 +53,7 @@
|
|||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style="margin-top:{kbShift}px;transition:margin-top 0.18s ease"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,7 +52,7 @@
|
|||||||
|
|
||||||
<Popover.Root>
|
<Popover.Root>
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||||
aria-label="Choose measurement units"
|
aria-label="Choose measurement units"
|
||||||
title="Units"
|
title="Units"
|
||||||
>
|
>
|
||||||
@@ -63,7 +71,6 @@
|
|||||||
/>
|
/>
|
||||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden text-xs font-semibold sm:inline">Units</span>
|
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content align="end" class="w-64 border-border">
|
<Popover.Content align="end" class="w-64 border-border">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
|
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
||||||
|
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Short feature name shown in the locked panel headline. */
|
||||||
|
feature?: string;
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { feature = 'This page', children }: Props = $props();
|
||||||
|
|
||||||
|
let unlockOpen = $state(false);
|
||||||
|
const price = getPremiumPrice();
|
||||||
|
|
||||||
|
// Re-verify the stored key whenever the gate mounts.
|
||||||
|
onMount(refreshPremium);
|
||||||
|
|
||||||
|
// Show a brief spinner only when we have no cached answer yet and are checking.
|
||||||
|
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $isPremium}
|
||||||
|
{@render children()}
|
||||||
|
{:else if initialChecking}
|
||||||
|
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||||
|
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Checking your subscription…
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mx-auto max-w-xl rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<!-- padlock -->
|
||||||
|
<svg
|
||||||
|
class="h-7 w-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
|
||||||
|
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||||
|
Support this open-source weather project and unlock the extras from {price}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||||
|
{#each PREMIUM_PERKS as perk (perk)}
|
||||||
|
<li class="flex items-start gap-2.5 text-sm">
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
<span>{perk}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<a
|
||||||
|
href={SIGNUP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
|
||||||
|
onclick={() => (unlockOpen = true)}
|
||||||
|
>
|
||||||
|
I have a key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $premiumState.status === 'invalid'}
|
||||||
|
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Your saved key is no longer valid or has expired.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<UnlockDialog bind:open={unlockOpen} />
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
|
import { isPremium, refreshPremium } from './premium';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// Verify once on load so the badge reflects real status site-wide.
|
||||||
|
onMount(refreshPremium);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||||
|
onclick={() => (open = true)}
|
||||||
|
title={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||||
|
aria-label={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||||
|
>
|
||||||
|
{#if $isPremium}
|
||||||
|
<!-- star -->
|
||||||
|
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- padlock -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<UnlockDialog bind:open />
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
import { SIGNUP_URL, getPremiumPrice } from './config';
|
||||||
|
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
const price = getPremiumPrice();
|
||||||
|
|
||||||
|
// Prefill with the stored key so an existing subscriber sees their key.
|
||||||
|
let keyInput = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let localError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
keyInput = $storedLicenseKey;
|
||||||
|
localError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
localError = null;
|
||||||
|
const result = await verifyKey(keyInput);
|
||||||
|
submitting = false;
|
||||||
|
if (result.valid) {
|
||||||
|
// close shortly so the success state is visible for a beat
|
||||||
|
setTimeout(() => (open = false), 700);
|
||||||
|
} else {
|
||||||
|
localError = result.error ?? 'That key is not valid or has expired.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKey() {
|
||||||
|
clearLicense();
|
||||||
|
keyInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresLabel = $derived.by(() => {
|
||||||
|
const exp = $premiumState.expires;
|
||||||
|
if ($premiumState.status !== 'valid') return null;
|
||||||
|
if (!exp) return 'Lifetime access';
|
||||||
|
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Drizz.li Premium</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Paste the access key from your subscription email to unlock premium pages.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if $isPremium && $premiumState.status === 'valid'}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4" />
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">Premium is active</p>
|
||||||
|
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={submit} class="grid gap-3">
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<Label for="license-key">Access key</Label>
|
||||||
|
<Input
|
||||||
|
id="license-key"
|
||||||
|
bind:value={keyInput}
|
||||||
|
placeholder="DRZ-XXXX-XXXX-XXXX"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
class="font-mono tracking-wide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localError}
|
||||||
|
<p class="text-sm text-destructive">{localError}</p>
|
||||||
|
{:else if $premiumState.status === 'error'}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Couldn't reach the server. Check your connection and try again.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
||||||
|
{#if submitting}Verifying…{:else}Unlock{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||||
|
{#if $isPremium}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
onclick={removeKey}
|
||||||
|
>
|
||||||
|
Remove key from this device
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-xs text-muted-foreground">
|
||||||
|
No key yet?
|
||||||
|
<a
|
||||||
|
href={SIGNUP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Subscribe from {price}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Paywall configuration.
|
||||||
|
*
|
||||||
|
* The frontend stays fully static and open source; the only server piece is the
|
||||||
|
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
||||||
|
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
|
||||||
|
* `.env` file), otherwise the sensible drizz.li defaults are used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const env = import.meta.env as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
||||||
|
export const PAYWALL_API_BASE = stripTrailingSlash(
|
||||||
|
env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
||||||
|
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
||||||
|
|
||||||
|
// ─── Location-based pricing ──────────────────────────────────────────────────
|
||||||
|
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
||||||
|
// from their location. Mirror that here so the paywall copy matches. Detection
|
||||||
|
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
||||||
|
const US_ZONES =
|
||||||
|
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||||
|
|
||||||
|
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
||||||
|
|
||||||
|
export function detectCurrency(): PremiumCurrency {
|
||||||
|
if (typeof Intl === 'undefined') return 'EUR';
|
||||||
|
let tz = '';
|
||||||
|
try {
|
||||||
|
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
|
||||||
|
let region = '';
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
region = new Intl.Locale(navigator.language).maximize().region || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (region === 'CH') return 'CHF';
|
||||||
|
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
|
||||||
|
return 'EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||||
|
export function getPremiumPrice(): string {
|
||||||
|
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
||||||
|
const currency = detectCurrency();
|
||||||
|
const symbol = CURRENCY_SYMBOL[currency];
|
||||||
|
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, human list of what premium unlocks (shown on the locked panel). */
|
||||||
|
export const PREMIUM_PERKS = [
|
||||||
|
'Historical weather & climate-normal comparisons',
|
||||||
|
'New premium features as they land'
|
||||||
|
];
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Premium (subscription) state.
|
||||||
|
*
|
||||||
|
* The user pastes an access key once; it is stored locally and re-verified
|
||||||
|
* against the self-hosted verify API on load. The last good result is cached so
|
||||||
|
* gated content shows instantly on reload (and keeps working briefly offline)
|
||||||
|
* without waiting for the network round-trip.
|
||||||
|
*
|
||||||
|
* This gate is a convenience/honor-system gate: the frontend is open source and
|
||||||
|
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
||||||
|
* paywall repo) is what makes it meaningful in practice.
|
||||||
|
*/
|
||||||
|
import { derived, get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
import { persisted } from 'svelte-persisted-store';
|
||||||
|
|
||||||
|
import { PAYWALL_API_BASE } from './config';
|
||||||
|
|
||||||
|
/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||||
|
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||||
|
|
||||||
|
export interface PremiumCache {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||||
|
expires?: string | null;
|
||||||
|
/** epoch ms of the last verify call */
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||||
|
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
||||||
|
|
||||||
|
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||||
|
|
||||||
|
export interface PremiumState {
|
||||||
|
status: PremiumStatus;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live verification state for the current session. */
|
||||||
|
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
||||||
|
|
||||||
|
function notExpired(expires: string | null | undefined): boolean {
|
||||||
|
if (!expires) return true; // lifetime key
|
||||||
|
const t = Date.parse(expires);
|
||||||
|
return Number.isFinite(t) && t > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether premium content should be shown. A live "valid"/"invalid" result wins;
|
||||||
|
* otherwise we fall back to the cached result (so a reload or a brief network
|
||||||
|
* blip doesn't lock a paying user out).
|
||||||
|
*/
|
||||||
|
export const isPremium = derived(
|
||||||
|
[premiumState, storedPremiumCache],
|
||||||
|
([$state, $cache]): boolean => {
|
||||||
|
if ($state.status === 'valid') return true;
|
||||||
|
if ($state.status === 'invalid') return false;
|
||||||
|
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a key against the API. On success the key is persisted and the cache
|
||||||
|
* updated. On an invalid key the stored key is left untouched (so an expired
|
||||||
|
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||||
|
*/
|
||||||
|
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
premiumState.set({ status: 'invalid' });
|
||||||
|
return { valid: false, error: 'Enter your access key.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
premiumState.set({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||||
|
headers: { accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as VerifyResult;
|
||||||
|
|
||||||
|
if (res.ok && data.valid) {
|
||||||
|
storedLicenseKey.set(trimmed);
|
||||||
|
storedPremiumCache.set({
|
||||||
|
valid: true,
|
||||||
|
tier: data.tier,
|
||||||
|
expires: data.expires ?? null,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
});
|
||||||
|
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||||
|
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
||||||
|
premiumState.set({ status: 'invalid' });
|
||||||
|
return { valid: false };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
premiumState.set({ status: 'error', error: message });
|
||||||
|
return { valid: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||||
|
export async function refreshPremium(): Promise<void> {
|
||||||
|
const key = get(storedLicenseKey);
|
||||||
|
if (!key) {
|
||||||
|
premiumState.set({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await verifyKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the key and premium state ("sign out"). */
|
||||||
|
export function clearLicense(): void {
|
||||||
|
storedLicenseKey.set('');
|
||||||
|
storedPremiumCache.set(null);
|
||||||
|
premiumState.set({ status: 'idle' });
|
||||||
|
}
|
||||||
+306
-25
@@ -22,6 +22,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
|||||||
|
|
||||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||||
|
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||||
|
|
||||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -303,6 +304,40 @@ const WEEK_DAILY_VARS = [
|
|||||||
'wind_direction_10m_dominant'
|
'wind_direction_10m_dominant'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles a WeekHourlyData structure from a name→values map, so variables that
|
||||||
|
* were not requested resolve to empty arrays. Shared by the week and historical
|
||||||
|
* fetchers (both return the same hourly shape, which the meteograms and hourly
|
||||||
|
* table consume).
|
||||||
|
*/
|
||||||
|
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
|
||||||
|
const g = (name: string): number[] => byName[name] ?? [];
|
||||||
|
return {
|
||||||
|
temperature_2m: g('temperature_2m'),
|
||||||
|
precipitation: g('precipitation'),
|
||||||
|
precipitation_probability: g('precipitation_probability'),
|
||||||
|
weather_code: g('weather_code'),
|
||||||
|
windspeed_10m: g('wind_speed_10m'),
|
||||||
|
winddirection_10m: g('wind_direction_10m'),
|
||||||
|
cloud_cover: g('cloud_cover'),
|
||||||
|
relative_humidity_2m: g('relative_humidity_2m'),
|
||||||
|
apparent_temperature: g('apparent_temperature'),
|
||||||
|
dew_point_2m: g('dew_point_2m'),
|
||||||
|
wind_gusts_10m: g('wind_gusts_10m'),
|
||||||
|
pressure_msl: g('pressure_msl'),
|
||||||
|
surface_pressure: g('surface_pressure'),
|
||||||
|
rain: g('rain'),
|
||||||
|
showers: g('showers'),
|
||||||
|
snowfall: g('snowfall'),
|
||||||
|
cloud_cover_low: g('cloud_cover_low'),
|
||||||
|
cloud_cover_mid: g('cloud_cover_mid'),
|
||||||
|
cloud_cover_high: g('cloud_cover_high'),
|
||||||
|
uv_index: g('uv_index'),
|
||||||
|
visibility: g('visibility'),
|
||||||
|
cape: g('cape')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the 7-day (week) weather forecast for a single location and model.
|
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||||
* Returns typed hourly and daily data structures.
|
* Returns typed hourly and daily data structures.
|
||||||
@@ -359,32 +394,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const variable = hourlyBlock.variables(i);
|
const variable = hourlyBlock.variables(i);
|
||||||
byName[name] = variable ? getValues(variable) : [];
|
byName[name] = variable ? getValues(variable) : [];
|
||||||
});
|
});
|
||||||
const g = (name: string): number[] => byName[name] ?? [];
|
|
||||||
|
|
||||||
const hourly: WeekHourlyData = {
|
const hourly = weekHourlyFromByName(byName);
|
||||||
temperature_2m: g('temperature_2m'),
|
|
||||||
precipitation: g('precipitation'),
|
|
||||||
precipitation_probability: g('precipitation_probability'),
|
|
||||||
weather_code: g('weather_code'),
|
|
||||||
windspeed_10m: g('wind_speed_10m'),
|
|
||||||
winddirection_10m: g('wind_direction_10m'),
|
|
||||||
cloud_cover: g('cloud_cover'),
|
|
||||||
relative_humidity_2m: g('relative_humidity_2m'),
|
|
||||||
apparent_temperature: g('apparent_temperature'),
|
|
||||||
dew_point_2m: g('dew_point_2m'),
|
|
||||||
wind_gusts_10m: g('wind_gusts_10m'),
|
|
||||||
pressure_msl: g('pressure_msl'),
|
|
||||||
surface_pressure: g('surface_pressure'),
|
|
||||||
rain: g('rain'),
|
|
||||||
showers: g('showers'),
|
|
||||||
snowfall: g('snowfall'),
|
|
||||||
cloud_cover_low: g('cloud_cover_low'),
|
|
||||||
cloud_cover_mid: g('cloud_cover_mid'),
|
|
||||||
cloud_cover_high: g('cloud_cover_high'),
|
|
||||||
uv_index: g('uv_index'),
|
|
||||||
visibility: g('visibility'),
|
|
||||||
cape: g('cape')
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||||
const dailyDates = getDates(dailyBlock);
|
const dailyDates = getDates(dailyBlock);
|
||||||
@@ -803,3 +814,273 @@ function modelEnumToString(modelEnum: number): string {
|
|||||||
};
|
};
|
||||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HistoricalDailyData {
|
||||||
|
weather_code: number[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
temperature_2m_mean: number[];
|
||||||
|
apparent_temperature_max: number[];
|
||||||
|
apparent_temperature_min: number[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
|
sunshine_duration: number[];
|
||||||
|
precipitation_sum: number[];
|
||||||
|
rain_sum: number[];
|
||||||
|
snowfall_sum: number[];
|
||||||
|
precipitation_hours: number[];
|
||||||
|
windspeed_10m_max: number[];
|
||||||
|
windgusts_10m_max: number[];
|
||||||
|
winddirection_10m_dominant: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
/** Inclusive range, YYYY-MM-DD (location-local dates). */
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
/** Hourly API variables to request; defaults to the core week set. */
|
||||||
|
hourlyVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalForecastResult {
|
||||||
|
hourly: WeekHourlyData;
|
||||||
|
daily: HistoricalDailyData;
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
timezone: string;
|
||||||
|
hourlyTimestamps: number[];
|
||||||
|
hourlyDates: Date[];
|
||||||
|
dailyDates: Date[];
|
||||||
|
daylightBands: DaylightBand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requested in this exact order; the daily block returns variables positionally.
|
||||||
|
const HISTORICAL_DAILY_VARS = [
|
||||||
|
'weather_code',
|
||||||
|
'temperature_2m_max',
|
||||||
|
'temperature_2m_min',
|
||||||
|
'temperature_2m_mean',
|
||||||
|
'apparent_temperature_max',
|
||||||
|
'apparent_temperature_min',
|
||||||
|
'sunrise',
|
||||||
|
'sunset',
|
||||||
|
'sunshine_duration',
|
||||||
|
'precipitation_sum',
|
||||||
|
'rain_sum',
|
||||||
|
'snowfall_sum',
|
||||||
|
'precipitation_hours',
|
||||||
|
'wind_speed_10m_max',
|
||||||
|
'wind_gusts_10m_max',
|
||||||
|
'wind_direction_10m_dominant'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
|
||||||
|
* archive API. Returns the same hourly shape as the week forecast (so the
|
||||||
|
* existing meteograms and hourly table render it unchanged) plus a richer daily
|
||||||
|
* block for the climate/statistics view.
|
||||||
|
*/
|
||||||
|
export async function fetchHistoricalWeather(
|
||||||
|
params: HistoricalForecastParams
|
||||||
|
): Promise<HistoricalForecastResult> {
|
||||||
|
const hourlyVars =
|
||||||
|
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||||
|
? [...new Set(params.hourlyVariables)]
|
||||||
|
: [...WEEK_HOURLY_VARS];
|
||||||
|
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {
|
||||||
|
latitude: params.latitude,
|
||||||
|
longitude: params.longitude,
|
||||||
|
start_date: params.start_date,
|
||||||
|
end_date: params.end_date,
|
||||||
|
hourly: hourlyVars.join(','),
|
||||||
|
daily: HISTORICAL_DAILY_VARS.join(','),
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: params.timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(apiParams)) {
|
||||||
|
if (value !== undefined) cleanParams[key] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
|
||||||
|
const response = responses[0];
|
||||||
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||||
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||||
|
|
||||||
|
const hourlyBlock = response.hourly()!;
|
||||||
|
const dailyBlock = response.daily()!;
|
||||||
|
|
||||||
|
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||||
|
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||||
|
|
||||||
|
const byName: Record<string, number[]> = {};
|
||||||
|
hourlyVars.forEach((name, i) => {
|
||||||
|
const variable = hourlyBlock.variables(i);
|
||||||
|
byName[name] = variable ? getValues(variable) : [];
|
||||||
|
});
|
||||||
|
const hourly = weekHourlyFromByName(byName);
|
||||||
|
|
||||||
|
// Daily variables come back in HISTORICAL_DAILY_VARS order.
|
||||||
|
const dailyDates = getDates(dailyBlock);
|
||||||
|
const d = (i: number): number[] => {
|
||||||
|
const v = dailyBlock.variables(i);
|
||||||
|
return v ? getValues(v) : [];
|
||||||
|
};
|
||||||
|
const sunrise = getInt64Values(dailyBlock.variables(6)!);
|
||||||
|
const sunset = getInt64Values(dailyBlock.variables(7)!);
|
||||||
|
|
||||||
|
const daily: HistoricalDailyData = {
|
||||||
|
weather_code: d(0),
|
||||||
|
temperature_2m_max: d(1),
|
||||||
|
temperature_2m_min: d(2),
|
||||||
|
temperature_2m_mean: d(3),
|
||||||
|
apparent_temperature_max: d(4),
|
||||||
|
apparent_temperature_min: d(5),
|
||||||
|
sunrise,
|
||||||
|
sunset,
|
||||||
|
sunshine_duration: d(8),
|
||||||
|
precipitation_sum: d(9),
|
||||||
|
rain_sum: d(10),
|
||||||
|
snowfall_sum: d(11),
|
||||||
|
precipitation_hours: d(12),
|
||||||
|
windspeed_10m_max: d(13),
|
||||||
|
windgusts_10m_max: d(14),
|
||||||
|
winddirection_10m_dominant: d(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
const daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hourly,
|
||||||
|
daily,
|
||||||
|
utcOffsetSeconds,
|
||||||
|
timezone,
|
||||||
|
hourlyTimestamps,
|
||||||
|
hourlyDates,
|
||||||
|
dailyDates,
|
||||||
|
daylightBands
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Climate Normals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClimateNormals {
|
||||||
|
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
|
||||||
|
tmax: number[];
|
||||||
|
tmin: number[];
|
||||||
|
tmean: number[];
|
||||||
|
/** Mean daily precipitation (per calendar day). */
|
||||||
|
precip: number[];
|
||||||
|
baseStart: string;
|
||||||
|
baseEnd: string;
|
||||||
|
temperature_unit: string;
|
||||||
|
precipitation_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
|
||||||
|
baseStart?: string;
|
||||||
|
baseEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days before the first of each month in a leap reference year, so that a
|
||||||
|
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
|
||||||
|
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||||
|
|
||||||
|
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
|
||||||
|
export function monthDayToOrdinal(month: number, day: number): number {
|
||||||
|
const m = Math.min(12, Math.max(1, Math.round(month)));
|
||||||
|
return CUM_DAYS_LEAP[m - 1] + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes daily climate normals for a location by averaging a multi-decade
|
||||||
|
* archive across years, per day-of-year, with a ±7-day smoothing window so the
|
||||||
|
* curve is stable. One archive request; used for the "vs normal" comparison.
|
||||||
|
*/
|
||||||
|
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
|
||||||
|
const baseStart = params.baseStart ?? '1991-01-01';
|
||||||
|
const baseEnd = params.baseEnd ?? '2020-12-31';
|
||||||
|
|
||||||
|
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
|
||||||
|
// timezone is irrelevant to a per-calendar-day normal.
|
||||||
|
const apiParams: Record<string, string> = {
|
||||||
|
latitude: String(params.latitude),
|
||||||
|
longitude: String(params.longitude),
|
||||||
|
start_date: baseStart,
|
||||||
|
end_date: baseEnd,
|
||||||
|
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: 'UTC'
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
|
||||||
|
const response = responses[0];
|
||||||
|
const dailyBlock = response.daily()!;
|
||||||
|
const dates = getDates(dailyBlock);
|
||||||
|
const tmaxV = getValues(dailyBlock.variables(0)!);
|
||||||
|
const tminV = getValues(dailyBlock.variables(1)!);
|
||||||
|
const tmeanV = getValues(dailyBlock.variables(2)!);
|
||||||
|
const precipV = getValues(dailyBlock.variables(3)!);
|
||||||
|
|
||||||
|
const N = 367; // ordinals 1..366
|
||||||
|
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
|
||||||
|
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
|
||||||
|
|
||||||
|
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
|
||||||
|
if (Number.isFinite(val)) {
|
||||||
|
bucket.sum[ord] += val;
|
||||||
|
bucket.cnt[ord] += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const dt = dates[i];
|
||||||
|
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
|
||||||
|
add(acc.tmax, ord, tmaxV[i]);
|
||||||
|
add(acc.tmin, ord, tminV[i]);
|
||||||
|
add(acc.tmean, ord, tmeanV[i]);
|
||||||
|
add(acc.precip, ord, precipV[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
|
||||||
|
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
|
||||||
|
|
||||||
|
// Circular ±window smoothing across the 366 ordinals (skips empty days).
|
||||||
|
const smooth = (arr: number[], window = 7): number[] => {
|
||||||
|
const out = new Array<number>(N).fill(NaN);
|
||||||
|
for (let o = 1; o <= 366; o++) {
|
||||||
|
let s = 0;
|
||||||
|
let c = 0;
|
||||||
|
for (let k = -window; k <= window; k++) {
|
||||||
|
const idx = ((o - 1 + k + 366) % 366) + 1;
|
||||||
|
const v = arr[idx];
|
||||||
|
if (Number.isFinite(v)) {
|
||||||
|
s += v;
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[o] = c > 0 ? s / c : NaN;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tmax: smooth(mean(acc.tmax)),
|
||||||
|
tmin: smooth(mean(acc.tmin)),
|
||||||
|
tmean: smooth(mean(acc.tmean)),
|
||||||
|
precip: smooth(mean(acc.precip)),
|
||||||
|
baseStart,
|
||||||
|
baseEnd,
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<Header onMenuToggle={toggleMobileMenu} />
|
<Header onMenuToggle={toggleMobileMenu} />
|
||||||
|
|
||||||
<main
|
<main
|
||||||
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 md:px-8 md:py-6'}
|
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
||||||
>
|
>
|
||||||
{#if fullBleed}
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
|
|||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const title = document.querySelector('title');
|
const title = document.querySelector('title');
|
||||||
expect(title?.textContent).toBe('Drizzli');
|
expect(title?.textContent).toBe('Drizz.li');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -46,14 +46,7 @@
|
|||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: [
|
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
|
||||||
'temperature_2m',
|
|
||||||
'precipitation',
|
|
||||||
'wind_speed_10m',
|
|
||||||
'relative_humidity_2m',
|
|
||||||
'cloud_cover',
|
|
||||||
'pressure_msl'
|
|
||||||
],
|
|
||||||
models: ['ncep_gefs_seamless']
|
models: ['ncep_gefs_seamless']
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,7 +327,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead — the spread is
|
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
|
||||||
trimmed to its available range.
|
trimmed to its available range.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,29 +341,42 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300} bleed={false}>
|
{#if fetchedData}
|
||||||
{#if fetchedData}
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
{#each chartDefs as def, i (i)}
|
{#each chartDefs as def, i (i)}
|
||||||
<CanvasChart
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
bind:this={chartComponents[i]}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
timestamps={timestampsSec}
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
timezone={fetchedData.timezone}
|
{#if def.subtitle}
|
||||||
series={def.series}
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
bands={fetchedData.daylightBands}
|
{/if}
|
||||||
unit={def.unit}
|
</div>
|
||||||
title={def.title}
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
subtitle={def.subtitle}
|
<CanvasChart
|
||||||
showCredit={def.showCredit}
|
bind:this={chartComponents[i]}
|
||||||
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
timestamps={timestampsSec}
|
||||||
yMin={def.yMin}
|
timezone={fetchedData.timezone}
|
||||||
yMax={def.yMax}
|
series={def.series}
|
||||||
{showLegend}
|
bands={fetchedData.daylightBands}
|
||||||
height={300}
|
unit={def.unit}
|
||||||
group={CHART_GROUP}
|
showCredit={def.showCredit}
|
||||||
/>
|
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</ChartContainer>
|
{:else}
|
||||||
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
|
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m', 'wind_speed_10m'],
|
hourly: ['temperature_2m', 'rain', 'wind_speed_10m'],
|
||||||
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
|
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -359,31 +359,44 @@
|
|||||||
|
|
||||||
<!-- chart count derives from the selected variables (not the fetched data),
|
<!-- chart count derives from the selected variables (not the fetched data),
|
||||||
so the reserved height is right even before the response arrives -->
|
so the reserved height is right even before the response arrives -->
|
||||||
<ChartContainer
|
{#if fetchedData}
|
||||||
{loading}
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
chartHeight={300}
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
bleed={false}
|
|
||||||
>
|
|
||||||
{#if fetchedData}
|
|
||||||
{#each chartDefs as def, i (i)}
|
{#each chartDefs as def, i (i)}
|
||||||
<CanvasChart
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
bind:this={chartComponents[i]}
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
timestamps={timestampsSec}
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
timezone={fetchedData.timezone}
|
{#if def.subtitle}
|
||||||
series={def.series}
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
bands={fetchedData.daylightBands}
|
{/if}
|
||||||
unit={def.unit}
|
</div>
|
||||||
title={def.title}
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
subtitle={def.subtitle}
|
<CanvasChart
|
||||||
showCredit={def.showCredit}
|
bind:this={chartComponents[i]}
|
||||||
{showLegend}
|
timestamps={timestampsSec}
|
||||||
height={300}
|
timezone={fetchedData.timezone}
|
||||||
group={CHART_GROUP}
|
series={def.series}
|
||||||
/>
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</ChartContainer>
|
{:else}
|
||||||
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
|
<ChartContainer
|
||||||
|
loading
|
||||||
|
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||||
|
chartHeight={340}
|
||||||
|
bleed={false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if fetchedData && !loading}
|
{#if fetchedData && !loading}
|
||||||
<ModelPictogramTimeline
|
<ModelPictogramTimeline
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
// at build time this page knows nothing about the visitor, so the redirect
|
||||||
|
// target (the persisted location) is resolved in the browser instead of
|
||||||
|
// being baked to the default city during prerender
|
||||||
|
onMount(() => {
|
||||||
|
goto(
|
||||||
|
resolve('/weather/historical/[location]', {
|
||||||
|
location: buildLocationRoute(get(storedLocation))
|
||||||
|
}),
|
||||||
|
{ replaceState: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import {
|
||||||
|
storedChartLayout,
|
||||||
|
storedLocation,
|
||||||
|
storedUnits,
|
||||||
|
storedVariablePrefs
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||||
|
import { isPremium } from '$lib/paywall/premium';
|
||||||
|
import {
|
||||||
|
type ClimateNormals,
|
||||||
|
type HistoricalForecastResult,
|
||||||
|
fetchClimateNormals,
|
||||||
|
fetchHistoricalWeather
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { defaultParameters } from '../../options';
|
||||||
|
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
|
||||||
|
import { neededHourlyApiVars } from '../../week/[location]/variables';
|
||||||
|
import DateRangeControls from './DateRangeControls.svelte';
|
||||||
|
import HistoricalDaily from './HistoricalDaily.svelte';
|
||||||
|
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
|
||||||
|
|
||||||
|
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let location = $derived(data.location);
|
||||||
|
$effect(() => {
|
||||||
|
storedLocation.set(data.location);
|
||||||
|
});
|
||||||
|
|
||||||
|
let params = $state({ ...defaultParameters });
|
||||||
|
$effect(() => {
|
||||||
|
params.temperature_unit = $storedUnits.temperature_unit;
|
||||||
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||||
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request only what the table rows + meteogram layout actually show.
|
||||||
|
let hourlyVars = $derived(
|
||||||
|
neededHourlyApiVars(
|
||||||
|
$storedVariablePrefs.table,
|
||||||
|
$storedChartLayout.flatMap((p) => p.variables)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Date range ───────────────────────────────────────────────────────────
|
||||||
|
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
||||||
|
const addDays = (d: Date, n: number): Date => {
|
||||||
|
const c = new Date(d);
|
||||||
|
c.setUTCDate(c.getUTCDate() + n);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_DATE = '1940-01-01'; // ERA5 archive start
|
||||||
|
// The reanalysis archive lags real time by a few days.
|
||||||
|
let maxDate = $state(iso(addDays(new Date(), -5)));
|
||||||
|
let startDate = $state(iso(addDays(new Date(), -34)));
|
||||||
|
let endDate = $state(iso(addDays(new Date(), -5)));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const today = new Date();
|
||||||
|
maxDate = iso(addDays(today, -5));
|
||||||
|
endDate = maxDate;
|
||||||
|
startDate = iso(addDays(today, -34));
|
||||||
|
mounted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRangeChange(s: string, e: string) {
|
||||||
|
startDate = s;
|
||||||
|
endDate = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||||
|
let mounted = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let requestVersion = 0;
|
||||||
|
|
||||||
|
let result = $state<HistoricalForecastResult | null>(null);
|
||||||
|
let normals = $state<ClimateNormals | null>(null);
|
||||||
|
|
||||||
|
const selectedDay = new SvelteDate();
|
||||||
|
|
||||||
|
// Historical data: refetch on location / range / units / requested-vars change.
|
||||||
|
$effect(() => {
|
||||||
|
const loc = location;
|
||||||
|
const s = startDate;
|
||||||
|
const e = endDate;
|
||||||
|
const vars = hourlyVars;
|
||||||
|
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
||||||
|
|
||||||
|
const version = ++requestVersion;
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
|
||||||
|
fetchHistoricalWeather({
|
||||||
|
latitude: loc.latitude!,
|
||||||
|
longitude: loc.longitude!,
|
||||||
|
start_date: s,
|
||||||
|
end_date: e,
|
||||||
|
hourlyVariables: vars,
|
||||||
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
|
timezone: loc.timezone
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
result = r;
|
||||||
|
// default the hourly drill-down to the last day in range
|
||||||
|
if (r.dailyDates.length > 0) {
|
||||||
|
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
loadError = err instanceof Error ? err.message : String(err);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Climate normals: independent of the range, so fetch once per location/units.
|
||||||
|
let normalsKey = $derived(
|
||||||
|
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
|
||||||
|
);
|
||||||
|
let normalsVersion = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const key = normalsKey;
|
||||||
|
const loc = location;
|
||||||
|
if (!mounted || !$isPremium || !loc) return;
|
||||||
|
|
||||||
|
const version = ++normalsVersion;
|
||||||
|
normals = null;
|
||||||
|
fetchClimateNormals({
|
||||||
|
latitude: loc.latitude!,
|
||||||
|
longitude: loc.longitude!,
|
||||||
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||||
|
})
|
||||||
|
.then((n) => {
|
||||||
|
if (version === normalsVersion) normals = n;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// normals are a nice-to-have; a failure just hides the comparison
|
||||||
|
if (version === normalsVersion) normals = null;
|
||||||
|
});
|
||||||
|
// re-read key so the effect tracks it
|
||||||
|
void key;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Adapters so the reused week components accept historical data ──────────
|
||||||
|
let fetchedHourly = $derived<FetchedHourly | null>(
|
||||||
|
result
|
||||||
|
? {
|
||||||
|
hourly: result.hourly,
|
||||||
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
|
timezone: result.timezone,
|
||||||
|
timestamps: result.hourlyTimestamps,
|
||||||
|
hourlyDates: result.hourlyDates,
|
||||||
|
daylightBands: result.daylightBands
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetchedDaily = $derived<FetchedDaily | null>(
|
||||||
|
result
|
||||||
|
? {
|
||||||
|
daily: {
|
||||||
|
weather_code: result.daily.weather_code,
|
||||||
|
temperature_2m_max: result.daily.temperature_2m_max,
|
||||||
|
temperature_2m_min: result.daily.temperature_2m_min,
|
||||||
|
sunrise: result.daily.sunrise,
|
||||||
|
sunset: result.daily.sunset,
|
||||||
|
sunshine_duration: result.daily.sunshine_duration,
|
||||||
|
precipitation_sum: result.daily.precipitation_sum,
|
||||||
|
windspeed_10m_max: result.daily.windspeed_10m_max,
|
||||||
|
windgusts_10m_max: result.daily.windgusts_10m_max,
|
||||||
|
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
|
||||||
|
},
|
||||||
|
timezone: result.timezone,
|
||||||
|
dailyDates: result.dailyDates
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function switchDay(date: Date) {
|
||||||
|
selectedDay.setTime(date.getTime());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Drizz.li | Historical weather</title>
|
||||||
|
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Page hero -->
|
||||||
|
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country ?? ''}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
|
{location.name}
|
||||||
|
</h1>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
|
<span class="lg:hidden"
|
||||||
|
>{#if location.admin1}{location.admin1},
|
||||||
|
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||||
|
>Historical weather
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaywallGate feature="Historical weather">
|
||||||
|
<DateRangeControls
|
||||||
|
start={startDate}
|
||||||
|
end={endDate}
|
||||||
|
minDate={MIN_DATE}
|
||||||
|
{maxDate}
|
||||||
|
onChange={onRangeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
Failed to load historical data: {loadError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if result && fetchedHourly && fetchedDaily}
|
||||||
|
<HistoricalDaily
|
||||||
|
daily={result.daily}
|
||||||
|
dailyDates={result.dailyDates}
|
||||||
|
timezone={result.timezone}
|
||||||
|
units={params}
|
||||||
|
{normals}
|
||||||
|
{selectedDay}
|
||||||
|
onSelectDay={switchDay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<HourlyTable
|
||||||
|
data={fetchedHourly}
|
||||||
|
daily={fetchedDaily}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
locationName={location.name ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
||||||
|
{:else}
|
||||||
|
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||||
|
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||||
|
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PaywallGate>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const location = await resolveLocationFromRoute({
|
||||||
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/historical/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
/** Latest selectable date (archive lags real time by a few days). */
|
||||||
|
maxDate: string;
|
||||||
|
minDate: string;
|
||||||
|
onChange: (start: string, end: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { start, end, maxDate, minDate, onChange }: Props = $props();
|
||||||
|
|
||||||
|
// ─── Presets ────────────────────────────────────────────────────────────────
|
||||||
|
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
||||||
|
const addDays = (d: Date, n: number): Date => {
|
||||||
|
const c = new Date(d);
|
||||||
|
c.setUTCDate(c.getUTCDate() + n);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyLastDays(days: number) {
|
||||||
|
const endD = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const startD = addDays(endD, -(days - 1));
|
||||||
|
onChange(iso(startD), iso(endD));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThisMonthLastYear() {
|
||||||
|
const end = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const y = end.getUTCFullYear() - 1;
|
||||||
|
const m = end.getUTCMonth();
|
||||||
|
const first = new Date(Date.UTC(y, m, 1));
|
||||||
|
const last = new Date(Date.UTC(y, m + 1, 0));
|
||||||
|
onChange(iso(first), iso(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ label: '7 days', apply: () => applyLastDays(7) },
|
||||||
|
{ label: '30 days', apply: () => applyLastDays(30) },
|
||||||
|
{ label: '90 days', apply: () => applyLastDays(90) },
|
||||||
|
{ label: 'Month, last year', apply: applyThisMonthLastYear }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Manual inputs ────────────────────────────────────────────────────────────
|
||||||
|
// Editable mirrors of the props, re-seeded whenever a preset changes the range.
|
||||||
|
let localStart = $state('');
|
||||||
|
let localEnd = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
localStart = start;
|
||||||
|
localEnd = end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep start <= end and inside the allowed window before emitting.
|
||||||
|
function commit() {
|
||||||
|
let s = localStart;
|
||||||
|
let e = localEnd;
|
||||||
|
if (s > e) [s, e] = [e, s];
|
||||||
|
if (s < minDate) s = minDate;
|
||||||
|
if (e > maxDate) e = maxDate;
|
||||||
|
onChange(s, e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
|
||||||
|
<input
|
||||||
|
id="hist-start"
|
||||||
|
type="date"
|
||||||
|
bind:value={localStart}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
|
||||||
|
<input
|
||||||
|
id="hist-end"
|
||||||
|
type="date"
|
||||||
|
bind:value={localEnd}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Quick ranges"
|
||||||
|
>
|
||||||
|
{#each presets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ClimateNormals,
|
||||||
|
type HistoricalDailyData,
|
||||||
|
monthDayToOrdinal
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { getColor, getTempStyle } from '../../utils/colors';
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: HistoricalDailyData;
|
||||||
|
dailyDates: Date[];
|
||||||
|
timezone: string;
|
||||||
|
units: WeatherUnits;
|
||||||
|
normals: ClimateNormals | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
onSelectDay: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, dailyDates, timezone, units, normals, selectedDay, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
|
const precipUnit = $derived(getPrecipUnit(units));
|
||||||
|
|
||||||
|
// Day-of-year ordinal for each day, so we can look up its climate normal.
|
||||||
|
let ordinals = $derived(
|
||||||
|
dailyDates.map((d) =>
|
||||||
|
monthDayToOrdinal(
|
||||||
|
Number(formatZoned(d, timezone, 'M')),
|
||||||
|
Number(formatZoned(d, timezone, 'd'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finite = (v: number | undefined): v is number => v != null && Number.isFinite(v);
|
||||||
|
const mean = (xs: number[]): number =>
|
||||||
|
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
|
||||||
|
|
||||||
|
// ─── Range scaling for the min/max bars ─────────────────────────────────────
|
||||||
|
const BAR_H = 78;
|
||||||
|
let scale = $derived.by(() => {
|
||||||
|
const lo = Math.min(...daily.temperature_2m_min.filter(finite));
|
||||||
|
const hi = Math.max(...daily.temperature_2m_max.filter(finite));
|
||||||
|
const span = hi - lo || 1;
|
||||||
|
return { lo, hi, span };
|
||||||
|
});
|
||||||
|
const yOf = (v: number): number => (1 - (v - scale.lo) / scale.span) * BAR_H;
|
||||||
|
|
||||||
|
// ─── Summary statistics ─────────────────────────────────────────────────────
|
||||||
|
let stats = $derived.by(() => {
|
||||||
|
const means = daily.temperature_2m_mean.filter(finite);
|
||||||
|
const avg = mean(means);
|
||||||
|
|
||||||
|
// warmest / coldest day
|
||||||
|
let warm = { t: -Infinity, i: -1 };
|
||||||
|
let cold = { t: Infinity, i: -1 };
|
||||||
|
for (let i = 0; i < dailyDates.length; i++) {
|
||||||
|
const mx = daily.temperature_2m_max[i];
|
||||||
|
const mn = daily.temperature_2m_min[i];
|
||||||
|
if (finite(mx) && mx > warm.t) warm = { t: mx, i };
|
||||||
|
if (finite(mn) && mn < cold.t) cold = { t: mn, i };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrecip = daily.precipitation_sum.filter(finite).reduce((a, b) => a + b, 0);
|
||||||
|
const wetDays = daily.precipitation_sum.filter((p) => finite(p) && p >= 1).length;
|
||||||
|
|
||||||
|
// climate comparison (only when normals are available)
|
||||||
|
let tempAnomaly: number | null = null;
|
||||||
|
let normalPrecip: number | null = null;
|
||||||
|
if (normals) {
|
||||||
|
const normMeans: number[] = [];
|
||||||
|
let np = 0;
|
||||||
|
let npCount = 0;
|
||||||
|
for (let i = 0; i < ordinals.length; i++) {
|
||||||
|
const nm = normals.tmean[ordinals[i]];
|
||||||
|
if (finite(nm)) normMeans.push(nm);
|
||||||
|
const npv = normals.precip[ordinals[i]];
|
||||||
|
if (finite(npv)) {
|
||||||
|
np += npv;
|
||||||
|
npCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normMeans.length && finite(avg)) tempAnomaly = avg - mean(normMeans);
|
||||||
|
if (npCount) normalPrecip = np;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avg, warm, cold, totalPrecip, wetDays, tempAnomaly, normalPrecip };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '–');
|
||||||
|
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
|
||||||
|
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)} ${precipUnit}`;
|
||||||
|
|
||||||
|
function anomalyColor(delta: number): string {
|
||||||
|
const a = Math.min(0.9, 0.25 + Math.abs(delta) / 12);
|
||||||
|
return delta >= 0 ? `rgba(220, 70, 60, ${a})` : `rgba(50, 110, 210, ${a})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">
|
||||||
|
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
|
||||||
|
>{tempUnit.replace('°', '')}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{#if stats.tempAnomaly != null}
|
||||||
|
<p
|
||||||
|
class="mt-0.5 text-xs font-semibold"
|
||||||
|
class:text-red-600={stats.tempAnomaly >= 0}
|
||||||
|
class:text-blue-600={stats.tempAnomaly < 0}
|
||||||
|
class:dark:text-red-400={stats.tempAnomaly >= 0}
|
||||||
|
class:dark:text-blue-400={stats.tempAnomaly < 0}
|
||||||
|
>
|
||||||
|
{fmtSigned(stats.tempAnomaly)} vs normal
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">1991–2020 normal loading…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
|
||||||
|
{#if stats.normalPrecip != null}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1
|
||||||
|
? 'day'
|
||||||
|
: 'days'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">{stats.wetDays} wet days</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
|
||||||
|
{#if stats.warm.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
|
||||||
|
{#if stats.cold.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Daily strip: min/max range bars + anomaly + precip ─────────────────────── -->
|
||||||
|
<section
|
||||||
|
class="mt-4 -mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||||
|
<h3 class="text-base font-bold">
|
||||||
|
Daily <span class="font-semibold text-muted-foreground">– select a day for hourly detail</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
{#if normals}
|
||||||
|
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||||
|
normal band = 1991–2020 mean
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="flex min-w-min">
|
||||||
|
{#each dailyDates as date, i (date.getTime())}
|
||||||
|
{@const selected = isSameDayInZone(date, selectedDay, timezone)}
|
||||||
|
{@const tMax = daily.temperature_2m_max[i]}
|
||||||
|
{@const tMin = daily.temperature_2m_min[i]}
|
||||||
|
{@const tMean = daily.temperature_2m_mean[i]}
|
||||||
|
{@const norm = normals ? normals.tmean[ordinals[i]] : NaN}
|
||||||
|
{@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null}
|
||||||
|
{@const precip = daily.precipitation_sum[i]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-[52px] shrink-0 cursor-pointer flex-col items-center gap-1 border-r border-border/40 px-1 py-2 text-center transition-colors hover:bg-muted/60 {selected
|
||||||
|
? 'bg-primary/10'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => onSelectDay(date)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span class="text-[11px] font-semibold {selected ? 'text-primary' : 'text-foreground'}">
|
||||||
|
{formatZoned(date, timezone, 'EEE')}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground"
|
||||||
|
>{formatZoned(date, timezone, 'd MMM')}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
daily.weather_code[i],
|
||||||
|
true
|
||||||
|
)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- min/max range bar -->
|
||||||
|
<div class="relative w-3.5" style="height:{BAR_H}px">
|
||||||
|
{#if finite(norm)}
|
||||||
|
<!-- normal marker -->
|
||||||
|
<div
|
||||||
|
class="absolute -left-0.5 -right-0.5 border-t border-dashed border-muted-foreground/50"
|
||||||
|
style="top:{yOf(norm)}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if finite(tMax) && finite(tMin)}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 w-full rounded-full"
|
||||||
|
style="top:{yOf(tMax)}px;height:{Math.max(
|
||||||
|
3,
|
||||||
|
yOf(tMin) - yOf(tMax)
|
||||||
|
)}px;background:{getColor(tMean ?? (tMax + tMin) / 2, units.temperature_unit)}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-bold tabular-nums"
|
||||||
|
style="color:{getTempStyle(tMax, units.temperature_unit).bg}">{fmtTemp(tMax)}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-medium text-muted-foreground tabular-nums"
|
||||||
|
>{fmtTemp(tMin)}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if anomaly != null}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
|
||||||
|
style="background:{anomalyColor(anomaly)}"
|
||||||
|
title="{fmtSigned(anomaly)} vs normal"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if finite(precip) && precip >= 0.1}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400 tabular-nums"
|
||||||
|
>
|
||||||
|
{precip.toFixed(precip < 10 ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import { CanvasChart, groupRange } from '$lib/charts';
|
||||||
|
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
|
||||||
|
import { VARIABLE_BY_KEY, buildPanelDef } from '../../week/[location]/variables';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
units: WeatherUnits;
|
||||||
|
loading: boolean;
|
||||||
|
selectedDay: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, units, loading, selectedDay }: Props = $props();
|
||||||
|
|
||||||
|
const CHART_GROUP = 'historical-meteogram';
|
||||||
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
|
||||||
|
let downloadingPng = $state(false);
|
||||||
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
|
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
|
||||||
|
|
||||||
|
// Same customizable layout as the 7-day meteograms, so the two pages match.
|
||||||
|
let panels = $derived(
|
||||||
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||||
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
const targetDayStr = formatZoned(day, data.timezone, 'yyyy-MM-dd');
|
||||||
|
const idx = data.hourlyDates.findIndex(
|
||||||
|
(d) => formatZoned(d, data.timezone, 'yyyy-MM-dd') === targetDayStr
|
||||||
|
);
|
||||||
|
return idx === -1 ? null : data.timestamps[idx] / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft band marking the day currently open in the hourly table below.
|
||||||
|
let selectedDayHighlight = $derived.by(() => {
|
||||||
|
const start = dayStartSec(selectedDay);
|
||||||
|
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDaytime(tSec: number): boolean {
|
||||||
|
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pictograms = $derived.by((): { t: number; icon: string }[] => {
|
||||||
|
const codes = data.hourly.weather_code ?? [];
|
||||||
|
const out: { t: number; icon: string }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const code = codes[i];
|
||||||
|
if (code == null || !isFinite(code)) continue;
|
||||||
|
const t = timestampsSec[i];
|
||||||
|
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
|
||||||
|
const dirs = data.hourly.winddirection_10m ?? [];
|
||||||
|
const out: { t: number; deg: number }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const d = dirs[i];
|
||||||
|
if (d == null || !isFinite(d)) continue;
|
||||||
|
out.push({ t: timestampsSec[i], deg: d });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
|
function resetZoom(): void {
|
||||||
|
liveCharts[0]?.resetRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderPanel extends ChartPanel {
|
||||||
|
def: ReturnType<typeof buildPanelDef>;
|
||||||
|
title: string;
|
||||||
|
titleShort: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderPanels = $derived.by((): RenderPanel[] =>
|
||||||
|
panels.map((p) => {
|
||||||
|
const def = buildPanelDef(p.variables, data.hourly, units);
|
||||||
|
const title = def.series.map((s) => s.name).join(' · ');
|
||||||
|
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||||||
|
return { ...p, def, title, titleShort };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
|
||||||
|
let maxTopRows = $derived(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function downloadPng(): Promise<void> {
|
||||||
|
if (liveCharts.length === 0 || downloadingPng) return;
|
||||||
|
downloadingPng = true;
|
||||||
|
try {
|
||||||
|
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||||||
|
await downloadChartsPng(items, 'historical-weather');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => (downloadingPng = false), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
Meteograms <span class="font-semibold text-muted-foreground">– full range</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||||
|
drag or
|
||||||
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||||
|
>Ctrl</kbd
|
||||||
|
>
|
||||||
|
+ scroll to zoom
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={liveCharts.length === 0 || downloadingPng}
|
||||||
|
onclick={downloadPng}
|
||||||
|
title="Download meteogram as PNG image"
|
||||||
|
>
|
||||||
|
{#if downloadingPng}Rendering…{:else}PNG{/if}
|
||||||
|
</button>
|
||||||
|
<!-- Kept last so toggling it wraps onto a new line instead of shifting
|
||||||
|
the other controls (no layout shift on mobile). -->
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||||
|
onclick={resetZoom}
|
||||||
|
>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if renderPanels.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No meteograms configured. Add variables from the 7-day forecast page.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
|
<div
|
||||||
|
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
|
||||||
|
? 'border-t border-border/50'
|
||||||
|
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||||||
|
>
|
||||||
|
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
||||||
|
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||||
|
<span class="hidden lg:inline">{panel.title}</span>
|
||||||
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ChartContainer
|
||||||
|
{loading}
|
||||||
|
chartCount={1}
|
||||||
|
chartHeight={CHART_HEIGHT}
|
||||||
|
minWidth={520}
|
||||||
|
bleed={false}
|
||||||
|
>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={data.timezone}
|
||||||
|
series={panel.def.series}
|
||||||
|
bands={data.daylightBands}
|
||||||
|
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||||
|
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
|
||||||
|
reserveRightAxis={anyRightAxis}
|
||||||
|
reserveTopRows={maxTopRows}
|
||||||
|
highlight={selectedDayHighlight}
|
||||||
|
unit={panel.def.unit}
|
||||||
|
unitRight={panel.def.unitRight}
|
||||||
|
yMin={panel.def.yMin}
|
||||||
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
|
yMinRight={panel.def.yMinRight}
|
||||||
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
showCredit={i === renderPanels.length - 1}
|
||||||
|
showLegend
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -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';
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import DailyCards from './DailyCards.svelte';
|
import DailyCards from './DailyCards.svelte';
|
||||||
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
@@ -70,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
|
||||||
@@ -90,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(() => {
|
||||||
@@ -145,7 +173,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Drizzli | Weather</title>
|
<title>Drizz.li | Weather</title>
|
||||||
<link rel="canonical" href="https://drizz.li/weather/week" />
|
<link rel="canonical" href="https://drizz.li/weather/week" />
|
||||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -153,7 +181,7 @@
|
|||||||
<div class="week-page">
|
<div class="week-page">
|
||||||
<div class="weather-content" style="min-height: 50vh">
|
<div class="weather-content" style="min-height: 50vh">
|
||||||
<!-- Page hero: prominent location + weather model selection -->
|
<!-- Page hero: prominent location + weather model selection -->
|
||||||
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
<div class="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<img
|
<img
|
||||||
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
@@ -199,33 +227,55 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<DailyCards
|
<!-- Desktop: the full day cards -->
|
||||||
daily={fetchedDaily}
|
<div class="hidden md:block">
|
||||||
{selectedDay}
|
<DailyCards
|
||||||
units={params}
|
|
||||||
onSelectDay={switchDay}
|
|
||||||
canExtend={forecastDays < 15}
|
|
||||||
onExtend={() => (forecastDays = 15)}
|
|
||||||
canExtendPast={pastDays < 3}
|
|
||||||
onExtendPast={() => (pastDays = 3)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if fetchedHourly && fetchedDaily}
|
|
||||||
<HourlyTable
|
|
||||||
data={fetchedHourly}
|
|
||||||
daily={fetchedDaily}
|
daily={fetchedDaily}
|
||||||
{selectedDay}
|
{selectedDay}
|
||||||
units={params}
|
units={params}
|
||||||
locationName={location.name ?? ''}
|
onSelectDay={switchDay}
|
||||||
onCustomize={() => (variableSidebarOpen = true)}
|
canExtend={forecastDays < 15}
|
||||||
|
onExtend={() => (forecastDays = 15)}
|
||||||
|
canExtendPast={pastDays < 3}
|
||||||
|
onExtendPast={() => (pastDays = 3)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
</div>
|
||||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
|
||||||
<div
|
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
|
||||||
transition:fade={{ duration: 200 }}
|
the strip stays stuck (collapsing as it goes) while scrolling the table
|
||||||
class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"
|
and then releases exactly at the table's bottom, freeing the meteograms.
|
||||||
></div>
|
The strip itself is hidden on md+ (the desktop cards above take over). -->
|
||||||
{/if}
|
<div>
|
||||||
|
{#if fetchedDaily}
|
||||||
|
<DailyStripSticky
|
||||||
|
daily={fetchedDaily}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
onSelectDay={switchDay}
|
||||||
|
canExtend={forecastDays < 15}
|
||||||
|
onExtend={() => (forecastDays = 15)}
|
||||||
|
canExtendPast={pastDays < 3}
|
||||||
|
onExtendPast={() => (pastDays = 3)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fetchedHourly && fetchedDaily}
|
||||||
|
<HourlyTable
|
||||||
|
data={fetchedHourly}
|
||||||
|
daily={fetchedDaily}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
locationName={location.name ?? ''}
|
||||||
|
onCustomize={() => (variableSidebarOpen = true)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||||
@@ -235,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>
|
||||||
|
|||||||
@@ -57,7 +57,9 @@
|
|||||||
// defer to after layout so the measured positions and scroll width are final
|
// defer to after layout so the measured positions and scroll width are final
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// scroll so the first day card sits exactly at the content edge (aligned
|
// scroll so the first day card sits exactly at the content edge (aligned
|
||||||
// with the page hero), leaving the "past days" button off to the left
|
// with the page hero), leaving the "past days" button fully off to the
|
||||||
|
// left. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
|
||||||
|
// so subtract it here to land the card on the content edge.
|
||||||
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
|
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -119,13 +121,17 @@
|
|||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div transition:fade={{ duration: 200 }} class="mb-1 min-h-47.5 md:mb-6 md:min-h-65">
|
<div
|
||||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
transition:fade={{ duration: 200 }}
|
||||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
class="-mx-3 mb-1 flex min-h-47.5 items-stretch gap-2 px-3 md:mb-6 md:min-h-65"
|
||||||
lines up with the page content edge -->
|
>
|
||||||
|
<!-- A horizontally-scrolling rail (past button + day cards) plus a "load more"
|
||||||
|
card pinned to the right so it's always visible on every screen. The
|
||||||
|
matching top/bottom padding gives a lifted/scaled card room so it's never
|
||||||
|
clipped, while the first card still lines up with the page content edge. -->
|
||||||
<div
|
<div
|
||||||
bind:this={scrollEl}
|
bind:this={scrollEl}
|
||||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-2 pb-3 md:pt-5 md:pb-11"
|
class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
|
||||||
class:scrolling
|
class:scrolling
|
||||||
onscroll={onScroll}
|
onscroll={onScroll}
|
||||||
>
|
>
|
||||||
@@ -133,7 +139,7 @@
|
|||||||
{#if canExtendPast && onExtendPast}
|
{#if canExtendPast && onExtendPast}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||||
onclick={onExtendPast}
|
onclick={onExtendPast}
|
||||||
aria-label="Load recent past days"
|
aria-label="Load recent past days"
|
||||||
>
|
>
|
||||||
@@ -157,14 +163,9 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<!-- the cards fill at least the viewport so the row overflows past the
|
<!-- the cards fill at least the viewport so the row overflows past the
|
||||||
"past days" button (letting it scroll out of view even on wide
|
"past days" button, letting it scroll out of view even on wide
|
||||||
screens). On md+ we also reserve room so the "load more" button stays
|
screens -->
|
||||||
visible; on mobile it's simply reached by scrolling (never clipped). -->
|
<div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
|
||||||
<div
|
|
||||||
bind:this={cardsWrapEl}
|
|
||||||
class="cards-fill flex gap-2"
|
|
||||||
style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
|
|
||||||
>
|
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
@@ -324,36 +325,67 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if canExtend && onExtend}
|
<!-- Mobile: the "load more" card is the last item in the SAME flex row
|
||||||
<button
|
as the cards (reached by scrolling); keeping it in this container
|
||||||
type="button"
|
avoids the zoom/flex mis-position seen when it was a sibling. On
|
||||||
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
md+ it's hidden here and the pinned sibling below is shown. -->
|
||||||
onclick={onExtend}
|
{#if canExtend && onExtend}
|
||||||
aria-label="Load the longer-range forecast"
|
<button
|
||||||
>
|
type="button"
|
||||||
<svg
|
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
|
||||||
class="h-6 w-6"
|
onclick={onExtend}
|
||||||
fill="none"
|
aria-label="Load the longer-range forecast"
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.75"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
stroke-linecap="round"
|
class="h-6 w-6"
|
||||||
stroke-linejoin="round"
|
fill="none"
|
||||||
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
stroke="currentColor"
|
||||||
/>
|
viewBox="0 0 24 24"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
stroke-width="1.75"
|
||||||
</svg>
|
>
|
||||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
<path
|
||||||
Load<br />15 days
|
stroke-linecap="round"
|
||||||
</span>
|
stroke-linejoin="round"
|
||||||
</button>
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
{/if}
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||||
|
Load<br />15 days
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: "load more" card pinned to the right of the scroll rail (a
|
||||||
|
sibling, not inside the scroll) so it's always visible and full-height. -->
|
||||||
|
{#if daily && canExtend && onExtend}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||||
|
onclick={onExtend}
|
||||||
|
aria-label="Load the longer-range forecast"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -362,6 +394,10 @@
|
|||||||
.day-scroll {
|
.day-scroll {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: transparent transparent;
|
scrollbar-color: transparent transparent;
|
||||||
|
/* Soft fade at the right edge (only) so cards dissolve into the page
|
||||||
|
margin instead of being hard-cut as they scroll off. */
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
|
||||||
}
|
}
|
||||||
.day-scroll.scrolling {
|
.day-scroll.scrolling {
|
||||||
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
|
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { getTempStyle } from '../../utils/colors';
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type FetchedDaily, type WeatherUnits } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
onSelectDay: (date: Date, index: number) => void;
|
||||||
|
canExtend?: boolean;
|
||||||
|
onExtend?: () => void;
|
||||||
|
canExtendPast?: boolean;
|
||||||
|
onExtendPast?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
daily,
|
||||||
|
selectedDay,
|
||||||
|
units,
|
||||||
|
onSelectDay,
|
||||||
|
canExtend = false,
|
||||||
|
onExtend,
|
||||||
|
canExtendPast = false,
|
||||||
|
onExtendPast
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||||
|
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
|
||||||
|
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
|
||||||
|
// (which reflows the table BELOW it) never feeds back into the measurement.
|
||||||
|
let progress = $state(0);
|
||||||
|
let sentinelEl = $state<HTMLDivElement>();
|
||||||
|
let stripScrollEl = $state<HTMLDivElement>();
|
||||||
|
let daysWrapEl = $state<HTMLDivElement>();
|
||||||
|
// Resolved in onMount (client only) so this never touches `window` during the
|
||||||
|
// prerender of city pages.
|
||||||
|
let scrollParent: HTMLElement | Window | null = $state(null);
|
||||||
|
|
||||||
|
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
||||||
|
|
||||||
|
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
|
||||||
|
let node = el?.parentElement ?? null;
|
||||||
|
while (node) {
|
||||||
|
const oy = getComputedStyle(node).overflowY;
|
||||||
|
if (oy === 'auto' || oy === 'scroll') return node;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance (px) over which the collapse plays out once the strip sticks.
|
||||||
|
const RANGE = 120;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
ticking = false;
|
||||||
|
if (!sentinelEl || !scrollParent) return;
|
||||||
|
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
|
||||||
|
const top = sentinelEl.getBoundingClientRect().top - topRef;
|
||||||
|
progress = clamp(-top / RANGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
scrollParent = findScrollParent(sentinelEl ?? null);
|
||||||
|
const target: EventTarget = scrollParent;
|
||||||
|
target.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll, { passive: true });
|
||||||
|
measure();
|
||||||
|
return () => {
|
||||||
|
target.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure once data (and therefore the strip height) changes.
|
||||||
|
$effect(() => {
|
||||||
|
void daily;
|
||||||
|
tick().then(measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
||||||
|
// scrolling left), with the first day flush to the content edge — same as the
|
||||||
|
// desktop cards. Runs once per dataset.
|
||||||
|
let scrolledForRef: FetchedDaily | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const d = daily;
|
||||||
|
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||||
|
scrolledForRef = d;
|
||||||
|
const scroll = stripScrollEl;
|
||||||
|
const wrap = daysWrapEl;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// 12px = the strip's px-3 left padding, so the first day lands on the edge
|
||||||
|
scroll.scrollLeft +=
|
||||||
|
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// All progress-driven sizing lives in CSS (via the single `--p` custom property
|
||||||
|
// set on the strip), so a scroll frame writes ONE value instead of re-patching
|
||||||
|
// height/padding/opacity inline styles on every cell.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- progress sentinel: 0-height marker just above the sticky strip -->
|
||||||
|
<div bind:this={sentinelEl} aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
|
||||||
|
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
||||||
|
{#if daily}
|
||||||
|
{#if canExtendPast && onExtendPast}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||||
|
onclick={onExtendPast}
|
||||||
|
aria-label="Load recent past days"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">Past</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-1.5" bind:this={daysWrapEl}>
|
||||||
|
{#each daily.dailyDates as time, index (index)}
|
||||||
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
|
{@const wCode = daily.daily.weather_code[index]}
|
||||||
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
|
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
||||||
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
|
||||||
|
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'border-border/60 bg-card'}"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onclick={() => onSelectDay(time, index)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
|
||||||
|
>
|
||||||
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
|
||||||
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="day-icon fill-foreground">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
<!-- night companion icon, present in the full view, fades as it collapses -->
|
||||||
|
<svg
|
||||||
|
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
|
||||||
|
>
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
wCode,
|
||||||
|
false
|
||||||
|
)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-1 leading-none">
|
||||||
|
<span
|
||||||
|
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
|
||||||
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||||
|
>
|
||||||
|
{tempMax.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||||
|
{tempMin.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<svg class="fill-sky-500" width="13" height="13">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<svg class="fill-muted-foreground" width="13" height="13">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{windMax?.toFixed(0) ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canExtend && onExtend}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||||
|
onclick={onExtend}
|
||||||
|
aria-label="Load the longer-range forecast"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">15d</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Everything below is derived from a single custom property `--p` (0 = full
|
||||||
|
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
|
||||||
|
browser resolves the calc()s natively, so a scroll frame is one property
|
||||||
|
write instead of dozens of inline-style patches across every cell. */
|
||||||
|
.daystrip {
|
||||||
|
/* tunables */
|
||||||
|
--cell-w: 64px;
|
||||||
|
--cell-h-full: 134px;
|
||||||
|
--cell-h-min: 64px;
|
||||||
|
--icon-full: 40px;
|
||||||
|
--icon-min: 20px;
|
||||||
|
--pt-full: 6px;
|
||||||
|
--pt-min: 1px;
|
||||||
|
|
||||||
|
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
||||||
|
--k: calc(1 - var(--p));
|
||||||
|
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
|
||||||
|
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
|
||||||
|
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
||||||
|
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
|
||||||
|
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
|
||||||
|
/* bar background/divider fade in once it starts collapsing */
|
||||||
|
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
|
||||||
|
|
||||||
|
/* Opaque-background fade only — no backdrop-filter blur or animated
|
||||||
|
box-shadow (both are very expensive to repaint every scroll frame on
|
||||||
|
mobile). Promote to its own compositor layer so repaints stay isolated. */
|
||||||
|
background: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--color-background) calc(var(--chrome) * 100%),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid
|
||||||
|
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-cell,
|
||||||
|
.strip-side {
|
||||||
|
width: var(--cell-w);
|
||||||
|
height: var(--cell-h);
|
||||||
|
/* size tracks scroll exactly (no transition); only the tap highlight eases */
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
|
}
|
||||||
|
.strip-cell {
|
||||||
|
padding-top: var(--pt);
|
||||||
|
}
|
||||||
|
.day-icon {
|
||||||
|
width: var(--icon);
|
||||||
|
height: var(--icon);
|
||||||
|
}
|
||||||
|
.night-badge {
|
||||||
|
width: calc(var(--icon) * 0.44);
|
||||||
|
height: calc(var(--icon) * 0.44);
|
||||||
|
opacity: var(--rel);
|
||||||
|
}
|
||||||
|
.rel-label {
|
||||||
|
opacity: var(--rel);
|
||||||
|
max-height: calc(14px * var(--rel));
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
opacity: var(--detail);
|
||||||
|
max-height: calc(42px * var(--detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
.daystrip :global(.overflow-x-auto) {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -281,6 +281,68 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Auto-scroll the (overflowing) table on day / interval change ────────────
|
||||||
|
// • Day change → today lands on the current-hour cell, other days on 06:00.
|
||||||
|
// • Interval change (3h ↔ 1h) → keep whatever cell is currently on the left.
|
||||||
|
let tableScrollEl = $state<HTMLDivElement>();
|
||||||
|
let autoScrolledDay = -1;
|
||||||
|
let lastInterval = 0;
|
||||||
|
let viewLeftHour = 6; // zoned hour at the left edge (plain, non-reactive)
|
||||||
|
|
||||||
|
function colWidthPx(): number {
|
||||||
|
return (tableWidth - headerColWidth) / cellData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIdx(idx: number) {
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollWidth <= el.clientWidth + 4) {
|
||||||
|
el.scrollLeft = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollLeft = idx <= 0 ? 0 : Math.max(0, idx * colWidthPx());
|
||||||
|
}
|
||||||
|
|
||||||
|
// track the left-most visible cell's hour so an interval switch can restore it
|
||||||
|
function onTableScroll() {
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el || cellData.length === 0 || tableWidth === 0) return;
|
||||||
|
const idx = Math.min(
|
||||||
|
cellData.length - 1,
|
||||||
|
Math.max(0, Math.round(el.scrollLeft / colWidthPx()))
|
||||||
|
);
|
||||||
|
viewLeftHour = getZonedHour(cellData[idx].date, data.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const day = selectedDay.getTime();
|
||||||
|
const interval = is3h ? 3 : 1;
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return;
|
||||||
|
const dayChanged = autoScrolledDay !== day;
|
||||||
|
const intervalChanged = lastInterval !== interval;
|
||||||
|
if (!dayChanged && !intervalChanged) return;
|
||||||
|
autoScrolledDay = day;
|
||||||
|
lastInterval = interval;
|
||||||
|
|
||||||
|
let targetIdx: number;
|
||||||
|
if (dayChanged) {
|
||||||
|
// today → the cell whose block contains "now"; otherwise → 06:00
|
||||||
|
const nowMs = today.getTime();
|
||||||
|
const stepMs = interval * 3600 * 1000;
|
||||||
|
const nowIdx = cellData.findIndex(
|
||||||
|
(c) => nowMs >= c.date.getTime() && nowMs < c.date.getTime() + stepMs
|
||||||
|
);
|
||||||
|
targetIdx =
|
||||||
|
nowIdx >= 0 ? nowIdx : cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6);
|
||||||
|
viewLeftHour = targetIdx >= 0 ? getZonedHour(cellData[targetIdx].date, data.timezone) : 6;
|
||||||
|
} else {
|
||||||
|
// interval change only: keep the same cell on the left
|
||||||
|
targetIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= viewLeftHour);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => scrollToIdx(targetIdx));
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
|
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
|
||||||
// When the shared meteogram is hovered, highlight the matching table column
|
// When the shared meteogram is hovered, highlight the matching table column
|
||||||
// (only if the hovered time falls on the day the table is currently showing).
|
// (only if the hovered time falls on the day the table is currently showing).
|
||||||
@@ -387,7 +449,7 @@
|
|||||||
|
|
||||||
<!-- Below the min-width the table scrolls sideways instead of squeezing;
|
<!-- Below the min-width the table scrolls sideways instead of squeezing;
|
||||||
1h needs far more room than 3h (24 vs 8 columns) -->
|
1h needs far more room than 3h (24 vs 8 columns) -->
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
|
||||||
<div
|
<div
|
||||||
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
|
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
|
||||||
bind:clientWidth={tableWidth}
|
bind:clientWidth={tableWidth}
|
||||||
@@ -408,7 +470,7 @@
|
|||||||
</th>
|
</th>
|
||||||
<td
|
<td
|
||||||
colspan={cellData.length}
|
colspan={cellData.length}
|
||||||
class="relative h-12 overflow-visible p-0"
|
class="relative h-12 cursor-default overflow-visible p-0"
|
||||||
onmousemove={hoverTimeRow}
|
onmousemove={hoverTimeRow}
|
||||||
onmouseleave={clearTimeRowHover}
|
onmouseleave={clearTimeRowHover}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||||
|
|
||||||
@@ -35,7 +35,9 @@
|
|||||||
if (liveCharts.length === 0 || downloadingPng) return;
|
if (liveCharts.length === 0 || downloadingPng) return;
|
||||||
downloadingPng = true;
|
downloadingPng = true;
|
||||||
try {
|
try {
|
||||||
await downloadChartsPng(liveCharts, 'week-forecast');
|
// pair each chart with its panel title so the export is labelled
|
||||||
|
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||||||
|
await downloadChartsPng(items, 'week-forecast');
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => (downloadingPng = false), 500);
|
setTimeout(() => (downloadingPng = false), 500);
|
||||||
}
|
}
|
||||||
@@ -89,13 +91,18 @@
|
|||||||
onResetZoom?.();
|
onResetZoom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
const rangePresets = [
|
const now = new Date();
|
||||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
// "Selected day" is redundant while today is the selected day, so hide it then
|
||||||
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
let rangePresets = $derived.by(() => {
|
||||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
|
||||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
return [
|
||||||
{ label: 'All', apply: () => resetZoom() }
|
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||||
];
|
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
|
||||||
|
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||||
|
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||||
|
{ label: 'All', apply: () => resetZoom() }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
// Soft band drawn on every chart marking the currently selected day
|
// Soft band drawn on every chart marking the currently selected day
|
||||||
let selectedDayHighlight = $derived.by(() => {
|
let selectedDayHighlight = $derived.by(() => {
|
||||||
@@ -185,25 +192,6 @@
|
|||||||
>
|
>
|
||||||
+ scroll to zoom
|
+ scroll to zoom
|
||||||
</span>
|
</span>
|
||||||
{#if zoomActive}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
|
||||||
onclick={resetZoom}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
|
||||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
|
||||||
</svg>
|
|
||||||
Reset zoom
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<div
|
<div
|
||||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
role="group"
|
role="group"
|
||||||
@@ -269,6 +257,27 @@
|
|||||||
{/if}
|
{/if}
|
||||||
PNG
|
PNG
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Kept last so toggling it wraps onto a new line instead of shifting
|
||||||
|
the other controls (no layout shift on mobile). -->
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||||
|
onclick={resetZoom}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||||
|
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||||
|
</svg>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,26 +285,25 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No meteograms configured — <button
|
No meteograms configured, <button
|
||||||
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
onclick={() => (customizerOpen = true)}>add some variables</button
|
onclick={() => (customizerOpen = true)}>add some variables</button
|
||||||
>.
|
>.
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- one full-bleed card on mobile / contained card on md+, graphs stacked
|
<!-- one full-bleed card until lg / contained card on lg+, graphs stacked
|
||||||
tightly so they read as one fluent meteogram -->
|
tightly so they read as one fluent meteogram -->
|
||||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border">
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
{#each renderPanels as panel, i (panel.id)}
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
<div
|
<div
|
||||||
class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i ===
|
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
|
||||||
renderPanels.length - 1
|
? 'border-t border-border/50'
|
||||||
? 'pb-3 md:pb-4'
|
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
<div class="mb-0.5 flex items-center justify-between px-3 md:px-0">
|
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
||||||
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||||
<span class="hidden md:inline">{panel.title}</span>
|
<span class="hidden lg:inline">{panel.title}</span>
|
||||||
<span class="md:hidden">{panel.titleShort}</span>
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
|
|||||||
@@ -42,13 +42,19 @@
|
|||||||
>
|
>
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
aria-label="{label} selection"
|
aria-label="{label} selection"
|
||||||
class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
|
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
|
||||||
>
|
>
|
||||||
<!-- layered-globe icon: weather model -->
|
<!-- layered-globe icon: weather model -->
|
||||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.75">
|
<svg
|
||||||
|
class="size-4.5 sm:size-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="9" />
|
<circle cx="12" cy="12" r="9" />
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
@@ -60,9 +66,14 @@
|
|||||||
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span class="max-w-full truncate text-sm font-bold text-foreground">{modelLabel}</span>
|
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm"
|
||||||
|
>{modelLabel}</span
|
||||||
|
>
|
||||||
{#if modelMeta}
|
{#if modelMeta}
|
||||||
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
<!-- the meta line is dropped on mobile to keep the trigger compact -->
|
||||||
|
<span
|
||||||
|
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
|
||||||
|
>
|
||||||
{modelMeta}
|
{modelMeta}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -496,8 +496,9 @@ export function buildPanelDef(
|
|||||||
unit: leftKind ? unitForKind(leftKind, units) : '',
|
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||||
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||||
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||||
// pressure sits far from zero, so its axis is derived from the data
|
// temperature and pressure sit far from zero, so their axis is derived from
|
||||||
zeroBaseLeft: leftKind !== 'pressure',
|
// the data range (a forced 0 baseline just wastes vertical space)
|
||||||
|
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
|
||||||
yMinRight: rightKind && rightZero ? 0 : undefined,
|
yMinRight: rightKind && rightZero ? 0 : undefined,
|
||||||
yMaxRight: rightKind === 'percent' ? 100 : undefined,
|
yMaxRight: rightKind === 'percent' ? 100 : undefined,
|
||||||
hasPictograms: allDefs.some((d) => d.pictograms),
|
hasPictograms: allDefs.some((d) => d.pictograms),
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user