Author SHA1 Message Date
Vincent van der Wal ced7fff8e5 maps components 2026-07-25 18:42:03 +02:00
Vincent van der Wal 6b2db975b3 more perf impr 2026-07-25 16:45:34 +02:00
Vincent van der Wal d37c95a46d perf enhancements 2026-07-25 16:30:58 +02:00
Vincent van der Wal cc1589b017 daycards strip 2026-07-25 16:14:13 +02:00
Vincent van der Wal 61d84e4450 historical weather 2026-07-25 15:24:03 +02:00
Vincent van der Wal f8d7c7699f defaults 2026-07-25 14:21:46 +02:00
Vincent van der Wal 1d7d9d3a53 scroll behaviour 2026-07-25 14:14:49 +02:00
Vincent van der Wal c7924bb0ae visual updates 2026-07-25 13:56:16 +02:00
Vincent van der Wal cd98b7a5cc temperature chart and table alignments 2026-07-25 12:43:41 +02:00
Vincent van der Wal d78ac84a12 more fluent design 2026-07-25 11:10:14 +02:00
Vincent van der Wal 562d545c7a past weather 2026-07-25 10:51:21 +02:00
47 changed files with 5255 additions and 498 deletions
+3
View File
@@ -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/
View File
+1 -1
View File
@@ -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/).
+282
View File
@@ -7,6 +7,10 @@
"": { "": {
"name": "drizzli", "name": "drizzli",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
},
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -46,6 +50,43 @@
"vitest-browser-svelte": "^3.0.0" "vitest-browser-svelte": "^3.0.0"
} }
}, },
"../../open-meteo/weather-map-layer": {
"name": "@openmeteo/weather-map-layer",
"version": "0.0.19",
"dependencies": {
"@mapbox/tilebelt": "^2.0.3",
"@mapbox/vector-tile": "^3.0.0",
"@openmeteo/file-reader": "^0.0.16",
"maplibre-gl": "^5.20.1",
"pbf": "^5.1.0",
"point-in-polygon-hao": "^1.2.4"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/d3-interpolate": "^3.0.4",
"@types/geojson": "^7946.0.16",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@vitest/coverage-v8": "^4.0.15",
"concurrently": "^10.0.1",
"d3-color": "^3.1.0",
"d3-interpolate": "^3.0.1",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0",
"jiti": "^2.6.1",
"live-server": "^1.2.2",
"npm-watch": "^0.13.0",
"prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.1",
"unplugin-dts": "^1.0.0-beta.6",
"vite": "^8.0.12",
"vitest": "^4.0.4"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -565,6 +606,119 @@
"svelte": "^5" "svelte": "^5"
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -597,6 +751,10 @@
"node": ">=12.0" "node": ">=12.0"
} }
}, },
"node_modules/@openmeteo/weather-map-layer": {
"resolved": "../../open-meteo/weather-map-layer",
"link": true
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.139.0", "version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -1393,6 +1551,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2122,6 +2286,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.24.2", "version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
@@ -2570,6 +2740,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2728,6 +2904,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3102,6 +3290,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -3118,6 +3340,15 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3145,6 +3376,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -3293,6 +3530,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3492,6 +3741,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3608,6 +3863,12 @@
} }
} }
}, },
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3618,6 +3879,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3632,6 +3899,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -4062,6 +4338,12 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": { "node_modules/tinyrainbow": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+4
View File
@@ -53,5 +53,9 @@
"vite": "^8.1.5", "vite": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0" "vitest-browser-svelte": "^3.0.0"
},
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
} }
} }
+401 -42
View File
@@ -31,6 +31,9 @@
width?: number; width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */ /** Draw a low-alpha area fill below (or above, on inverted axes) the line */
fill?: boolean; fill?: boolean;
/** Area fill coloured by the value scale (segmentColor), fading to
* transparent ~20px below the series minimum. */
gradientFill?: boolean;
/** With `fill`, fill the area between this line and another data array /** With `fill`, fill the area between this line and another data array
* instead of the baseline (e.g. an ensemble min-max band) */ * instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[]; bandTo?: (number | null)[];
@@ -50,6 +53,9 @@
shortName?: string; shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */ /** Colour each line segment by value (e.g. a temperature colour scale) */
segmentColor?: (value: number, index: number) => string; segmentColor?: (value: number, index: number) => string;
/** Draw the line itself in the theme foreground (black/white), ignoring
* segmentColor for the stroke (segmentColor still colours any fill) */
foregroundLine?: boolean;
/** Draw a contrasting halo (black in light mode, white in dark) under the line */ /** Draw a contrasting halo (black in light mode, white in dark) under the line */
outline?: boolean; outline?: boolean;
/** Annotate local minima / maxima with their value */ /** Annotate local minima / maxima with their value */
@@ -101,6 +107,14 @@
export function groupRange(name: string): { start: number; end: number } | null { export function groupRange(name: string): { start: number; end: number } | null {
return groups[name]?.range ?? null; return groups[name]?.range ?? null;
} }
/**
* Current shared crosshair time of a group (epoch seconds, or null), reactive.
* Lets outside UI (e.g. the hourly table) mirror the chart's hovered timestep.
*/
export function groupHover(name: string): number | null {
return groups[name]?.hover ?? null;
}
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -202,12 +216,40 @@
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600; const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows) // Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row const ICON_ROW_H = 40; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height const ICON_BAND_H = 38; // visible band height
const ICON_PX = 26; // pictogram size const ICON_PX = 25; // pictogram size (a touch smaller than the arrows)
const ARROW_PX = 36; // wind-direction arrow size
// edge inset used for BOTH rows so pictograms and arrows clamp to the same
// centre and stay aligned with each other
const ICON_EDGE = ARROW_PX / 2;
// Puffy cloud band: 100% cover hangs 40px from the top of the plot // Puffy cloud band: 100% cover hangs 40px from the top of the plot
const CLOUD_BAND_MAX = 40; const CLOUD_BAND_MAX = 40;
/** Return a colour string with the given alpha (handles rgb/rgba/#hex). */
function withAlpha(color: string, alpha: number): string {
const m = color.match(/rgba?\(([^)]+)\)/);
if (m) {
const [r, g, b] = m[1].split(',').map((p) => parseFloat(p));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (color[0] === '#') {
const h = color.slice(1);
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
const r = parseInt(n.slice(0, 2), 16);
const g = parseInt(n.slice(2, 4), 16);
const b = parseInt(n.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
return color;
}
// ─── State ────────────────────────────────────────────────────────────────── // ─── State ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state(); let containerEl: HTMLDivElement | undefined = $state();
@@ -250,18 +292,25 @@
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand)); let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right')); let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
// Tighter left gutter on narrow screens so axis labels sit near the edge // Minimal gutters on narrow screens so the plot uses nearly the full width
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60); // (just enough to keep the axis tick labels legible).
let isNarrow = $derived(width > 0 && width < 520);
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(hasRightAxis || reserveRightAxis ? 56 : 20); // narrow (mobile) screens we don't reserve it — the graph uses the full width
// 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;
@@ -380,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);
@@ -451,6 +647,8 @@
for (const p of pictograms) { for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue; if (p.t < viewStart || p.t > viewEnd) continue;
const x = iconBandX(p.t); const x = iconBandX(p.t);
// keep clear of the band edges so the first/last never bunch or clip
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue; if (x - lastX < 40) continue;
out.push({ x, icon: p.icon }); out.push({ x, icon: p.icon });
lastX = x; lastX = x;
@@ -466,6 +664,7 @@
for (const a of windArrows) { for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue; if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t); const x = iconBandX(a.t);
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue; if (x - lastX < 40) continue;
out.push({ x, deg: a.deg }); out.push({ x, deg: a.deg });
lastX = x; lastX = x;
@@ -476,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) ────────────────────────────────
@@ -533,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 });
} }
@@ -578,20 +789,24 @@
const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)'); const gridColor = cssColor('--border', 'rgba(0, 0, 0, 0.1)');
const bgColor = cssColor('--card', '#ffffff'); const bgColor = cssColor('--card', '#ffffff');
const dark = document.documentElement.classList.contains('dark'); const dark = document.documentElement.classList.contains('dark');
const outlineColor = dark ? '#ffffff' : '#000000'; // halo matches the background (white in light mode, dark in dark mode) so a
// line reads clearly where it crosses others
const outlineColor = bgColor;
const plotRight = padLeft + plotW; const plotRight = padLeft + plotW;
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) {
@@ -636,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 (
@@ -749,6 +966,63 @@
for (const points of runs) { for (const points of runs) {
if (points.length === 0) continue; if (points.length === 0) continue;
if (s.gradientFill && points.length > 1) {
// Coloured fill anchored at the curve, fading out towards zero: down
// for positive values, up for all-negative values. Full opacity near
// the far-from-zero extreme, fading ~30px past the near-zero extreme.
let minV = Infinity;
let maxV = -Infinity;
for (const p of points) {
const val = s.data[p[3]] as number;
if (val < minV) minV = val;
if (val > maxV) maxV = val;
}
const goUp = maxV <= 0; // all non-positive → fill toward zero (upward)
const maxYp = yPix(maxV, axis);
const minYp = yPix(minV, axis);
// anchor = opaque end (far-from-zero extreme); fade = transparent end
const anchorY = goUp ? minYp : maxYp;
const nearY = goUp ? maxYp : minYp;
// fade runs a good stretch past the near extreme, flowing most of the
// way to the plot edge (~78% of the way there)
const fadeY = goUp
? Math.max(padTop, maxYp - (maxYp - padTop) * 0.78)
: Math.min(plotBottom, minYp + (plotBottom - minYp) * 0.78);
const anchorV = goUp ? minV : maxV;
const nearV = goUp ? maxV : minV;
const span = fadeY - anchorY;
// begin the fade a touch (~10px) BEFORE the near-zero extreme
const fadeStartY = nearY - Math.sign(nearY - anchorY) * 10;
const fadeStart =
span !== 0 ? Math.max(0, Math.min(0.95, (fadeStartY - anchorY) / span)) : 0.6;
const colorFn = s.segmentColor ?? (() => s.color);
const FULL = 0.8;
const grad = ctx.createLinearGradient(0, anchorY, 0, fadeY);
// full colour from the curve down to just before the near extreme…
const STOPS = 6;
for (let k = 0; k <= STOPS; k++) {
const t = k / STOPS;
const val = anchorV + t * (nearV - anchorV);
grad.addColorStop(t * fadeStart, withAlpha(colorFn(val, 0), FULL));
}
// …then a long, gentle fade out to the plot edge
grad.addColorStop(fadeStart, withAlpha(colorFn(nearV, 0), FULL));
grad.addColorStop(1, withAlpha(colorFn(nearV, 0), 0));
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
ctx.lineTo(points[points.length - 1][0], fadeY);
ctx.lineTo(points[0][0], fadeY);
ctx.closePath();
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.globalAlpha = 1;
}
if (s.fill && points.length > 1) { if (s.fill && points.length > 1) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
@@ -771,7 +1045,7 @@
if (lineWidth > 0) { if (lineWidth > 0) {
ctx.lineJoin = 'round'; ctx.lineJoin = 'round';
ctx.lineCap = 'round'; ctx.lineCap = 'round';
ctx.setLineDash(s.dashed ? [6, 4] : []); ctx.setLineDash(s.dashed ? [8, 8] : []);
// Contrasting halo drawn under the line so a multi-colour line // Contrasting halo drawn under the line so a multi-colour line
// stays legible over any background. // stays legible over any background.
@@ -785,7 +1059,7 @@
} }
ctx.lineWidth = lineWidth; ctx.lineWidth = lineWidth;
if (s.segmentColor) { if (s.segmentColor && !s.foregroundLine) {
// Colour each segment by its value (temperature colour scale). // Colour each segment by its value (temperature colour scale).
// `idx[i]` maps a run point back to its source data index. // `idx[i]` maps a run point back to its source data index.
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
@@ -796,6 +1070,36 @@
ctx.lineTo(points[i][0], points[i][1]); ctx.lineTo(points[i][0], points[i][1]);
ctx.stroke(); ctx.stroke();
} }
} else if (s.foregroundLine) {
// Foreground line drawn on top of its own fill. Stroke the whole
// path once with a horizontal gradient sampled from the value
// colour scale, so the colour flows smoothly along the line
// instead of stepping at each data point.
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i][0], points[i][1]);
if (s.segmentColor && points.length > 1) {
const x0 = points[0][0];
const x1 = points[points.length - 1][0];
const span = x1 - x0 || 1;
const grad = ctx.createLinearGradient(x0, 0, x1, 0);
let prevT = -1;
for (let i = 0; i < points.length; i++) {
let t = (points[i][0] - x0) / span;
t = t < 0 ? 0 : t > 1 ? 1 : t;
if (t <= prevT) t = prevT + 1e-6; // keep stops strictly increasing
if (t > 1) t = 1;
prevT = t;
// exact same colour as the fill so the line and gradient match
grad.addColorStop(t, s.segmentColor(s.data[points[i][3]] as number, points[i][3]));
}
ctx.strokeStyle = grad;
} else {
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();
} else { } else {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]); ctx.moveTo(points[0][0], points[0][1]);
@@ -825,7 +1129,12 @@
const x = xPix(t); const x = xPix(t);
const y = yPix(v, axis); const y = yPix(v, axis);
const label = fmt(v); const label = fmt(v);
const ly = ext.type === 'max' ? y - off : y + off + 8; // keep the label inside the plot vertically (never under the icon
// band above or clipped at the bottom)
const ly = Math.max(
padTop + 11,
Math.min(plotBottom - 3, ext.type === 'max' ? y - off : y + off + 8)
);
// keep the centred label fully inside the plot so it never clips // keep the centred label fully inside the plot so it never clips
const halfW = ctx.measureText(label).width / 2 + 2; const halfW = ctx.measureText(label).width / 2 + 2;
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x)); const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
@@ -835,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;
@@ -876,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
@@ -894,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(() => {
@@ -1131,18 +1466,19 @@
<!-- Weather pictograms: a bordered band across the top of the plot --> <!-- Weather pictograms: a bordered band across the top of the plot -->
{#if visiblePictograms.length > 0} {#if visiblePictograms.length > 0}
<div <div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30" class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px" style:left="{iconBandLeft}px"
style:top="{pictoRowTop}px" style:top="{pictoRowTop}px"
style:width="{iconBandWidth}px" style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px" style:height="{ICON_BAND_H}px"
> >
{#each visiblePictograms as p (p.x)} {#each visiblePictograms as p (p.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
<svg <svg
class="absolute top-px fill-foreground" class="absolute top-1/2 -translate-y-1/2 fill-foreground"
width={ICON_PX} width={ICON_PX}
height={ICON_PX} height={ICON_PX}
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px" style:left="{cx - ICON_PX / 2}px"
> >
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
</svg> </svg>
@@ -1153,21 +1489,22 @@
<!-- Wind-direction arrows: a matching band --> <!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0} {#if visibleWindArrows.length > 0}
<div <div
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30" class="pointer-events-none absolute z-10 overflow-hidden rounded-t-lg border border-border/60 bg-muted/30"
style:left="{iconBandLeft}px" style:left="{iconBandLeft}px"
style:top="{windRowTop}px" style:top="{windRowTop}px"
style:width="{iconBandWidth}px" style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px" style:height="{ICON_BAND_H}px"
> >
{#each visibleWindArrows as a (a.x)} {#each visibleWindArrows as a (a.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
<span <span
class="absolute top-px inline-flex items-center justify-center" class="absolute top-1/2 inline-flex items-center justify-center"
style:width="{ICON_PX}px" style:width="{ARROW_PX}px"
style:height="{ICON_PX}px" style:height="{ARROW_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px" style:left="{cx - ARROW_PX / 2}px"
style:transform="rotate({a.deg}deg)" style:transform="translateY(-50%) rotate({a.deg}deg)"
> >
<svg class="fill-foreground/80" width="22" height="22"> <svg class="fill-foreground/80" width={ARROW_PX} height={ARROW_PX}>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg> </svg>
</span> </span>
@@ -1201,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"
@@ -1224,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>
+6 -1
View File
@@ -5,7 +5,12 @@
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts'; * import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
*/ */
export { default as CanvasChart, setGroupHover, groupRange } from './CanvasChart.svelte'; export {
default as CanvasChart,
setGroupHover,
groupRange,
groupHover
} from './CanvasChart.svelte';
export type { ChartSeries } from './CanvasChart.svelte'; export type { ChartSeries } from './CanvasChart.svelte';
export { buildDaylightBands } from './bands'; export { buildDaylightBands } from './bands';
@@ -101,26 +101,32 @@
<style> <style>
.chart-bleed { .chart-bleed {
/* Bleed exactly into the page padding on mobile (main has p-5 = /* Bleed exactly into the page padding on mobile (main has p-3 =
1.25rem) for edge-to-edge charts, and a bit past the content 0.75rem) for edge-to-edge charts, and a bit past the content
column on md+ (main has 2rem padding) for extra readability. */ column on md+ (main has 2rem padding) for extra readability. */
margin-left: -1.25rem; margin-left: -0.75rem;
margin-right: -1.25rem; margin-right: -0.75rem;
/* overflow-y is pinned (never `visible`): a bare `overflow-x: auto`
makes the browser compute overflow-y as `auto` too, which turns the
chart into a 1-2px vertical micro-scroller that swallows page scroll. */
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
} }
.chart-bleed.no-bleed { .chart-bleed.no-bleed {
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
/* content fits the column, so no horizontal scroller is needed */
overflow-x: hidden;
} }
.chart-container { .chart-container {
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;
} }
@@ -129,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;
+5 -65
View File
@@ -19,20 +19,19 @@
</ChartToolbar> </ChartToolbar>
--> -->
<script module lang="ts"> <script module lang="ts">
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */ export type { ExportableChart } from './downloadChartsPng';
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
</script> </script>
<script lang="ts"> <script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
// ─── Props ────────────────────────────────────────────────────────────────── // ─── Props ──────────────────────────────────────────────────────────────────
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 */
@@ -58,70 +57,11 @@
// ─── Download ─────────────────────────────────────────────────────────────── // ─── Download ───────────────────────────────────────────────────────────────
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(img);
img.src = src;
});
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
return bg || '#ffffff';
}
function triggerDownload(url: string, name: string): void {
const link = document.createElement('a');
link.href = url;
link.download = name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
async function handleDownload(): Promise<void> { async function handleDownload(): Promise<void> {
if (!hasCharts || downloading) return; if (!hasCharts || downloading) return;
downloading = true; downloading = true;
try { try {
const dataUrls = charts await downloadChartsPng(charts, fileName);
.filter((chart): chart is DownloadableChart => chart != null)
.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
);
if (images.length === 0) return;
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
canvas.height = totalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const img of images) {
ctx.drawImage(img, 0, y);
y += img.naturalHeight;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
} finally { } finally {
setTimeout(() => { setTimeout(() => {
downloading = false; downloading = false;
@@ -0,0 +1,80 @@
/**
* Shared PNG export for charts.
*
* Each chart composites itself (plot + icon bands + optional title + legend)
* onto a canvas via `getExportImage`; those are stacked vertically over the
* current theme background and downloaded as one PNG.
*/
/** A chart that can composite itself (plot + icons + legend) for export. */
export interface ExportableChart {
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
}
/** One chart to export, with the title to render above it (e.g. panel name). */
export interface ChartExportItem {
chart: ExportableChart | null | undefined;
title?: string;
}
/** Resolves the page background so exports match the current theme. */
function exportBackground(): string {
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
return bg || '#ffffff';
}
function triggerDownload(url: string, name: string): void {
const link = document.createElement('a');
link.href = url;
link.download = name;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
requestAnimationFrame(() => {
document.body.removeChild(link);
});
}
/** 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
* download has been triggered (or immediately if there is nothing to export).
*/
export async function downloadChartsPng(
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
fileName: string
): Promise<void> {
const items = charts.map(toItem).filter((it) => it.chart != null);
if (items.length === 0) return;
const canvases = (
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(...canvases.map((c) => c.width));
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
const canvas = document.createElement('canvas');
canvas.width = maxWidth;
canvas.height = totalHeight;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = exportBackground();
ctx.fillRect(0, 0, maxWidth, totalHeight);
let y = 0;
for (const c of canvases) {
ctx.drawImage(c, 0, y);
y += c.height;
}
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
}
+1
View File
@@ -9,3 +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 ExportableChart, type ChartExportItem } from './downloadChartsPng';
+9 -2
View File
@@ -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"
> >
@@ -81,7 +85,7 @@
{location.name} {location.name}
{#if location.admin1 || location.country} {#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground"> <span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1}, · {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''} {/if}{location.country ?? ''}
</span> </span>
{/if} {/if}
@@ -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'}
> >
+1 -1
View File
@@ -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?.()}
+9 -2
View File
@@ -34,6 +34,14 @@
{ value: 'mm', label: 'mm' }, { value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' } { value: 'inch', label: 'inch' }
] ]
},
{
key: 'time_format',
label: 'Time',
options: [
{ value: '24h', label: '24h' },
{ value: '12h', label: 'AM/PM' }
]
} }
]; ];
@@ -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">
+723
View File
@@ -0,0 +1,723 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
import PauseIcon from '@lucide/svelte/icons/pause';
import PlayIcon from '@lucide/svelte/icons/play';
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
import {
clockPlaying,
clockScrub,
clockSpan,
clockSpeed,
sharedCamera
} from '$lib/maps/map-sync';
import {
type ResolvedSource,
omSourceUrl,
registerOmProtocol,
resolveSource
} from '$lib/maps/om';
import { modelGroups } from '../../routes/weather/options';
interface Props {
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
variable: string;
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
title: string;
/**
* Accumulation variable (e.g. precipitation): its first timestep is the
* analysis time with no accumulation window, so skip it. Leave off for
* instantaneous variables like wind.
*/
accumulation?: boolean;
/** Overlay animated wind-arrow vectors on top of the raster field. */
arrows?: boolean;
}
let { variable, title, accumulation = false, arrows = false }: Props = $props();
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
const DAY_MS = 86_400_000;
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
// tiles) - the requested preload parallelism.
const PRELOAD_MAX_FRAMES = 8;
// Playback speeds: ms of minimum wall-time per forecast frame.
const SPEEDS = [
{ label: '0.5×', ms: 900 },
{ label: '1×', ms: 450 },
{ label: '2×', ms: 220 }
];
// Loop / preload span presets (Infinity = the complete model run).
const SPANS = [
{ label: '2½ d', days: 2.5 },
{ label: '5 d', days: 5 },
{ label: 'Full run', days: Infinity }
];
const modelLabel = (id: string): string => {
for (const g of modelGroups) {
for (const m of g.models) if (m.value === id) return m.label;
}
return id;
};
let mapEl: HTMLDivElement;
let map: maplibregl.Map | undefined;
let animator: FrameAnimator | undefined;
let marker: maplibregl.Marker | undefined;
let syncingCamera = false;
// resolved source (full run) + windowed timeline
let sourceBase: ResolvedSource | null = null;
let fullTimes: Date[] = [];
let source = $state<ResolvedSource | null>(null);
let index = $state(0);
let loading = $state(true);
let firstFrameLoading = $state(true);
let error = $state<string | null>(null);
/** Per-frame load state, aligned with `times`. */
let loaded = $state<boolean[]>([]);
// dark mode mirrors the root layout's `.dark` class on <html>
let dark = $state(false);
// preload covers the whole windowed timeline (the loop == the window)
let loopStart = 0;
let loopEnd = 0;
let loadController: AbortController | undefined;
let lastKey = '';
let appliedSpan = get(clockSpan);
let appliedScrubEpoch = get(clockScrub).epoch;
const times = $derived(source?.times ?? []);
const currentTime = $derived(times[index]);
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
// ---- helpers -------------------------------------------------------------
const closestIndexToNow = (list: Date[]): number => {
const now = Date.now();
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < list.length; i++) {
const delta = Math.abs(list[i].getTime() - now);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
};
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
const windowTimes = (all: Date[], spanDays: number): Date[] => {
if (!isFinite(spanDays) || spanDays <= 0) return all;
const dayStart = new SvelteDate();
dayStart.setHours(0, 0, 0, 0);
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
return windowed.length ? windowed : all;
};
const buildUrl = (i: number): string => {
const s = source;
if (!s) return '';
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
};
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
// ---- animator / channels -------------------------------------------------
const makeChannels = (): FrameChannel[] => {
const raster: FrameChannel = {
key: 'raster',
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': 0,
'raster-opacity-transition': { duration: 0, delay: 0 },
'raster-fade-duration': 0
}
},
before
),
opacityProp: 'raster-opacity',
peakOpacity: dark ? 0.85 : 0.9
};
if (!arrows) return [raster];
const arrowLayer: FrameChannel = {
key: 'arrows',
sourceSpec: (url) => ({ type: 'vector', url }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'line',
source: sourceId,
'source-layer': 'wind-arrows',
layout: { 'line-cap': 'round' },
paint: {
'line-opacity': 0,
'line-opacity-transition': { duration: 0, delay: 0 },
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
}
},
before
),
opacityProp: 'line-opacity',
peakOpacity: 1
};
return [raster, arrowLayer];
};
const makeAnimator = (m: maplibregl.Map): void => {
animator?.destroy();
animator = new FrameAnimator({
map: m,
urlForFrame: buildUrl,
channels: makeChannels(),
beforeLayer: firstSymbolLayer(m),
opacity: dark ? 0.85 : 0.9,
onShow: (i) => {
index = i;
if (i < loaded.length) loaded[i] = true;
},
onReady: (i) => {
if (i < loaded.length) loaded[i] = true;
},
onFirstFrame: () => {
firstFrameLoading = false;
}
});
};
/** Chronological preload so the current day fills front-to-back, no gap. */
const preloadOrder = (): number[] => {
const order: number[] = [];
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
return order;
};
const startAnimation = (m: maplibregl.Map): void => {
if (!source) return;
makeAnimator(m);
animator!.setFrameCount(source.times.length);
animator!.setLoopRange(loopStart, loopEnd);
const speed = get(clockSpeed);
animator!.setSpeed(speed);
const initial = closestIndexToNow(source.times);
index = initial;
if (get(clockPlaying)) animator!.play(speed, initial);
else animator!.scrubTo(initial);
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
};
// ---- windowing / (re)load ------------------------------------------------
const applyWindow = (): void => {
if (!sourceBase || !map) return;
const list = windowTimes(fullTimes, get(clockSpan));
source = { ...sourceBase, times: list };
loopStart = 0;
loopEnd = Math.max(0, list.length - 1);
loaded = new Array(list.length).fill(false);
firstFrameLoading = true;
startAnimation(map);
};
const load = async (model: string): Promise<void> => {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
loading = true;
error = null;
const resolved = await resolveSource(model, variable, {
signal: controller.signal,
skipFirst: accumulation
});
if (controller.signal.aborted) return;
if (!resolved) {
error = `No ${title.toLowerCase()} data available for this model right now.`;
loading = false;
return;
}
sourceBase = resolved;
fullTimes = resolved.times;
loading = false;
applyWindow();
};
// ---- theme ---------------------------------------------------------------
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
applyPadding(m);
// setStyle wipes all layers; rebuild the animator for the new theme
if (source) startAnimation(m);
};
// ---- camera / marker -----------------------------------------------------
// 20% bottom padding keeps the focused location above the time scrubber.
const applyPadding = (m: maplibregl.Map): void => {
const h = mapEl?.clientHeight ?? 0;
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
};
const makeMarkerEl = (): HTMLDivElement => {
const el = document.createElement('div');
el.className = 'wx-marker';
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
return el;
};
// ---- lifecycle -----------------------------------------------------------
onMount(() => {
registerOmProtocol();
dark = document.documentElement.classList.contains('dark');
lastKey = `${$storedModel}`;
map = new maplibregl.Map({
container: mapEl,
style: dark ? STYLE_DARK : STYLE_LIGHT,
center: [$storedLocation.longitude, $storedLocation.latitude],
zoom: 7.5,
attributionControl: { compact: true },
maxPitch: 0,
dragRotate: false
});
const m = map;
m.scrollZoom.disable(); // don't hijack page scroll to zoom
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
const pushBounds = () => {
const b = m.getBounds();
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
// mirror this map's camera to siblings (unless the move came from a sync)
m.on('move', () => {
pushBounds();
if (syncingCamera) return;
const c = m.getCenter();
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
});
m.on('resize', () => applyPadding(m));
m.on('load', () => {
pushBounds();
applyPadding(m);
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
.addTo(m);
void load($storedModel);
});
// follow a sibling map's camera
const unsubCamera = sharedCamera.subscribe((cam) => {
if (!cam || !map) return;
const c = map.getCenter();
if (
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
Math.abs(map.getZoom() - cam.zoom) < 1e-6
)
return;
syncingCamera = true;
map.jumpTo({ center: cam.center, zoom: cam.zoom });
syncingCamera = false;
});
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
if (isDark !== dark) {
dark = isDark;
if (map && map.isStyleLoaded()) void applyStyle(map);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
unsubCamera();
animator?.destroy();
loadController?.abort();
marker?.remove();
map?.remove();
map = undefined;
};
});
// reload when the shared model selection changes
$effect(() => {
const key = `${$storedModel}`;
if (map && map.isStyleLoaded() && key !== lastKey) {
lastKey = key;
void load($storedModel);
}
});
// recenter + move the marker when the user picks a new location
$effect(() => {
const loc = $storedLocation;
marker?.setLngLat([loc.longitude, loc.latitude]);
if (map && map.isStyleLoaded()) {
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
}
});
// ---- shared-clock effects ------------------------------------------------
// play / pause together
$effect(() => {
const playing = $clockPlaying;
if (!animator) return;
if (playing) {
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
animator.play(get(clockSpeed), from);
} else {
animator.pause();
}
});
// speed together
$effect(() => {
const ms = $clockSpeed;
animator?.setSpeed(ms);
});
// span together (re-window + restart)
$effect(() => {
const days = $clockSpan;
if (days !== appliedSpan) {
appliedSpan = days;
if (sourceBase) applyWindow();
}
});
// scrub together
$effect(() => {
const s = $clockScrub;
if (s.epoch !== appliedScrubEpoch) {
appliedScrubEpoch = s.epoch;
if (s.index >= 0) {
index = s.index;
animator?.scrubTo(s.index);
}
}
});
// ---- controls (write to the shared clock so every map follows) -----------
const togglePlay = (): void => clockPlaying.update((p) => !p);
const setSpeed = (ms: number): void => clockSpeed.set(ms);
const setSpan = (days: number): void => clockSpan.set(days);
const onScrubInput = (e: Event): void => {
const v = Number((e.currentTarget as HTMLInputElement).value);
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
};
const onScrubStart = (): void => clockPlaying.set(false);
const onScrubEnd = (): void => clockPlaying.set(true);
// ---- formatting / scrubber decorations -----------------------------------
const hour12 = $derived($storedUnits.time_format === '12h');
const fmtTime = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
: '--:--';
const fmtDay = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short'
}).format(d)
: '';
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
const dayMarkers = $derived.by(() => {
const out: { i: number; label: string; left: number }[] = [];
let prevDay = '';
times.forEach((t, i) => {
const day = t.toDateString();
if (day !== prevDay) {
prevDay = day;
out.push({
i,
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
left: pct(i)
});
}
});
return out;
});
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
</script>
<div class="relative h-full w-full">
<div bind:this={mapEl} class="h-full w-full"></div>
<!-- top-left status: variable + model + fallback notice -->
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
<div
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
>
{title} · {modelLabel($storedModel)}
</div>
{#if source?.fellBack}
<div
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
>
No map for this model - showing global {source.domain}
</div>
{/if}
</div>
{#if error}
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
{error}
</div>
</div>
{/if}
<!-- big scrubber -->
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
<div
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
>
<div class="flex items-center gap-3 sm:gap-4">
<button
type="button"
onclick={togglePlay}
disabled={loading || !!error || times.length < 2}
aria-label={$clockPlaying ? 'Pause' : 'Play'}
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
>
{#if loading || firstFrameLoading}
<LoaderIcon class="size-6 animate-spin" />
{:else if $clockPlaying}
<PauseIcon class="size-6" />
{:else}
<PlayIcon class="size-6 translate-x-0.5" />
{/if}
</button>
<div class="flex min-w-0 flex-col">
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
{fmtTime(currentTime)}
</span>
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
{fmtDay(currentTime)}
</span>
</div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
{#if times.length}
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
{loadedCount}/{times.length} cached
</span>
{/if}
<!-- range: how much to loop / preload -->
<div
class="flex items-center gap-1 rounded-lg bg-muted p-1"
title="Loop &amp; preload range"
>
{#each SPANS as s (s.label)}
<button
type="button"
onclick={() => setSpan(s.days)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpan === s.days
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
<!-- playback speed -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
{#each SPEEDS as s (s.ms)}
<button
type="button"
onclick={() => setSpeed(s.ms)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpeed === s.ms
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
</div>
</div>
<!-- track -->
<div class="relative mt-3">
<input
type="range"
class="scrubber relative"
min="0"
max={Math.max(0, times.length - 1)}
step="1"
value={index}
disabled={loading || !!error || !times.length}
oninput={onScrubInput}
onpointerdown={onScrubStart}
onpointerup={onScrubEnd}
onpointercancel={onScrubEnd}
aria-label="Forecast time"
/>
<!-- now marker -->
{#if times.length}
<div
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
style="left: {nowLeft}%"
title="Now"
></div>
{/if}
<!-- buffered-frames strip: shows which steps are cached -->
{#if times.length}
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
{#each times as _t, i (i)}
<div
class="h-full flex-1 transition-colors {loaded[i]
? 'bg-primary'
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
></div>
{/each}
</div>
{/if}
<!-- day labels -->
<div class="relative mt-1 h-4">
{#each dayMarkers as d (d.i)}
<span
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
style="left: {Math.min(96, Math.max(4, d.left))}%"
>
{d.label}
</span>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
/* Big, prominent scrubber that works in light and dark. */
.scrubber {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
cursor: pointer;
outline: none;
}
.scrubber:disabled {
opacity: 0.5;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-webkit-slider-thumb:active {
cursor: grabbing;
}
.scrubber::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-moz-range-progress {
height: 10px;
border-radius: 9999px;
background: var(--color-primary);
}
/* Pulsing "current location" marker (added to the map's own DOM, so styles
must be global). */
:global(.wx-marker) {
position: relative;
width: 20px;
height: 20px;
pointer-events: none;
}
:global(.wx-marker__dot) {
position: absolute;
inset: 6px;
border-radius: 9999px;
background: #ef4444;
box-shadow:
0 0 0 2px #fff,
0 1px 3px rgba(0, 0, 0, 0.5);
}
:global(.wx-marker__ring) {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.55);
animation: wx-pulse 1.5s ease-out infinite;
}
@keyframes -global-wx-pulse {
0% {
transform: scale(0.4);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
</style>
+379
View File
@@ -0,0 +1,379 @@
import * as maplibregl from 'maplibre-gl';
/**
* Smooth cross-fade animator for a stack of MapLibre weather frames.
*
* Instead of tearing down and rebuilding a source per timestep (which flashes
* and stutters), each forecast step gets its own persistent layer(s). Playback
* drives a floating-point playhead and holds each frame crisp for most of its
* dwell, then cross-fades to the next over a short window - a fluid hand-off
* rather than a muddy full-interval dissolve.
*
* A frame can render several **channels** (e.g. a raster field plus a vector
* wind-arrows overlay); all a frame's channels fade together and it is only
* "ready" once every channel's source has loaded.
*
* Frames inside the active loop range stay resident so repeat loops are
* instant; frames visited only by scrubbing are evicted LRU. The playhead only
* advances once both the current and next frame have loaded, so a slow tile
* never produces a gap - it just briefly holds the current frame.
*/
export interface FrameChannel {
/** Unique per channel (part of source/layer ids). */
key: string;
sourceSpec: (url: string) => maplibregl.SourceSpecification;
/** Add the layer at opacity 0 (it is faded in by the animator). */
addLayer: (
map: maplibregl.Map,
sourceId: string,
layerId: string,
beforeLayer: string | undefined
) => void;
/** Paint property used to fade this channel, e.g. `raster-opacity`. */
opacityProp: string;
/** Peak opacity for this channel (defaults to the animator's `opacity`). */
peakOpacity?: number;
}
export interface FrameAnimatorOptions {
map: maplibregl.Map;
/** `om://` source URL for frame `i`. */
urlForFrame: (i: number) => string;
channels: FrameChannel[];
beforeLayer?: string;
/** Default peak opacity for channels that don't set their own. */
opacity?: number;
/** Cross-fade window in ms (0 = instant swaps). Default 130. */
crossFadeMs?: number;
/** LRU cap for frames outside the loop range (scrub-visited). */
outOfRangePool?: number;
/** Fired when the dominant integer frame changes (drives the scrubber). */
onShow?: (i: number) => void;
/** Fired when frame `i` finishes loading (drives the buffer strip). */
onReady?: (i: number) => void;
/** Fired once, when the very first frame becomes visible. */
onFirstFrame?: () => void;
}
interface LayerInfo {
layerId: string;
opacityProp: string;
peak: number;
}
interface Entry {
sources: string[];
layers: LayerInfo[];
loadedSources: Set<string>;
ready: boolean;
onData?: (e: maplibregl.MapSourceDataEvent) => void;
}
export class FrameAnimator {
private map: maplibregl.Map;
private opt: FrameAnimatorOptions;
private target: number;
private crossFadeMs: number;
private frames = new Map<number, Entry>();
private visible = new Set<number>();
private lru: number[] = []; // out-of-loop frames, oldest first
private frameCount = 0;
private loopStart = 0;
private loopEnd = 0;
private mode: 'still' | 'play' = 'still';
private q = 0; // loop-local playhead (float) when playing
private stillIndex = 0;
private speedMs = 450;
private raf = 0;
private lastTs = 0;
private lastShown = -1;
private firstShown = false;
private alive = true;
// bounded-parallel preloading
private preloadQueue: number[] = [];
private preloading = new Set<number>();
private preloadInFlight = 0;
private preloadMax = 8;
constructor(opt: FrameAnimatorOptions) {
this.map = opt.map;
this.opt = opt;
this.target = opt.opacity ?? 0.9;
this.crossFadeMs = opt.crossFadeMs ?? 200;
this.tick = this.tick.bind(this);
}
setFrameCount(n: number): void {
this.frameCount = n;
}
setLoopRange(start: number, end: number): void {
this.loopStart = Math.max(0, start);
this.loopEnd = Math.max(this.loopStart, end);
}
setSpeed(ms: number): void {
this.speedMs = ms;
}
get currentIndex(): number {
return this.mode === 'play'
? this.loopStart + (Math.floor(this.q) % this.loopLen())
: this.stillIndex;
}
private loopLen(): number {
return Math.max(1, this.loopEnd - this.loopStart + 1);
}
private inLoop(i: number): boolean {
return i >= this.loopStart && i <= this.loopEnd;
}
/** Show a single frame and stop animating (used for scrubbing / paused). */
scrubTo(i: number): void {
this.mode = 'still';
this.stillIndex = Math.max(0, Math.min(this.frameCount - 1, i));
this.start();
}
/** Start looping over the loop range, entering at `fromIndex` when given. */
play(speedMs: number, fromIndex?: number): void {
this.speedMs = speedMs;
this.mode = 'play';
const L = this.loopLen();
this.q = fromIndex != null && this.inLoop(fromIndex) ? (fromIndex - this.loopStart) % L : 0;
this.start();
}
pause(): void {
if (this.mode === 'play') this.stillIndex = this.currentIndex;
this.mode = 'still';
}
private start(): void {
if (!this.alive) return;
this.lastTs = 0;
if (!this.raf) this.raf = requestAnimationFrame(this.tick);
}
private stopRaf(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
private tick(ts: number): void {
if (!this.alive) return;
const dt = this.lastTs ? ts - this.lastTs : 16;
this.lastTs = ts;
if (this.mode === 'play') this.tickPlay(dt);
else this.tickStill();
this.raf = requestAnimationFrame(this.tick);
}
private tickStill(): void {
const i = this.stillIndex;
this.ensure(i);
if (!this.frames.get(i)?.ready) return; // hold previous frame until ready
this.markFirst();
this.setOp(i, 1);
this.hideExcept(new Set([i]));
this.emitShown(i);
}
private tickPlay(dt: number): void {
const L = this.loopLen();
const baseLocal = Math.floor(this.q) % L;
const curr = this.loopStart + baseLocal;
const next = this.loopStart + ((baseLocal + 1) % L);
this.ensure(curr);
this.ensure(next);
this.ensure(this.loopStart + ((baseLocal + 2) % L));
const currReady = this.frames.get(curr)?.ready;
const nextReady = this.frames.get(next)?.ready;
if (currReady) this.markFirst();
if (currReady && nextReady) {
this.q += dt / this.speedMs;
if (this.q >= L) this.q -= L;
const bl = Math.floor(this.q) % L;
const cB = this.loopStart + bl;
const cN = this.loopStart + ((bl + 1) % L);
const frac = this.q - Math.floor(this.q);
// Hold the current frame crisp for most of its dwell, then cross-fade
// to the next only over the short crossFadeMs window at the end.
const fadeFrac = Math.min(0.9, this.crossFadeMs / this.speedMs);
const fadeStart = 1 - fadeFrac;
if (fadeFrac <= 0 || frac < fadeStart) {
this.setOp(cB, 1);
this.hideExcept(new Set([cB]));
this.emitShown(cB);
} else {
const blend = (frac - fadeStart) / fadeFrac;
this.setOp(cB, 1 - blend);
this.setOp(cN, blend);
this.hideExcept(new Set([cB, cN]));
this.emitShown(blend < 0.5 ? cB : cN);
}
} else if (currReady) {
// next frame still loading - hold the current frame at full opacity
this.setOp(curr, 1);
this.hideExcept(new Set([curr]));
this.emitShown(curr);
}
}
private markFirst(): void {
if (this.firstShown) return;
this.firstShown = true;
this.opt.onFirstFrame?.();
}
private emitShown(i: number): void {
if (i === this.lastShown) return;
this.lastShown = i;
this.opt.onShow?.(i);
}
private ensure(i: number): void {
if (i < 0 || i >= this.frameCount) return;
if (this.frames.has(i)) {
this.touch(i);
return;
}
if (!this.map.isStyleLoaded()) return;
const url = this.opt.urlForFrame(i);
const sources: string[] = [];
const layers: LayerInfo[] = [];
for (const ch of this.opt.channels) {
const sourceId = `omAnim_${ch.key}_${i}`;
const layerId = `omAnimL_${ch.key}_${i}`;
this.map.addSource(sourceId, ch.sourceSpec(url));
ch.addLayer(this.map, sourceId, layerId, this.opt.beforeLayer);
if (this.map.getLayer(layerId)) {
sources.push(sourceId);
layers.push({ layerId, opacityProp: ch.opacityProp, peak: ch.peakOpacity ?? this.target });
}
}
const entry: Entry = { sources, layers, loadedSources: new Set(), ready: false };
const allLoaded = () => entry.sources.every((s) => this.map.getSource(s)?.loaded());
const markReady = () => {
entry.ready = true;
if (entry.onData) {
this.map.off('sourcedata', entry.onData);
entry.onData = undefined;
}
this.opt.onReady?.(i);
if (this.preloading.has(i)) {
this.preloading.delete(i);
this.preloadInFlight = Math.max(0, this.preloadInFlight - 1);
queueMicrotask(() => this.pumpPreload());
}
};
if (allLoaded()) {
markReady();
} else {
const onData = (e: maplibregl.MapSourceDataEvent) => {
if (!e.sourceId || !entry.sources.includes(e.sourceId)) return;
if (!e.isSourceLoaded) return;
if (allLoaded()) markReady();
};
entry.onData = onData;
this.map.on('sourcedata', onData);
}
this.frames.set(i, entry);
this.touch(i);
this.evict();
}
private touch(i: number): void {
if (this.inLoop(i)) return; // loop frames are never evicted
const at = this.lru.indexOf(i);
if (at >= 0) this.lru.splice(at, 1);
this.lru.push(i);
}
private evict(): void {
const cap = this.opt.outOfRangePool ?? 6;
while (this.lru.length > cap) {
const i = this.lru.shift();
if (i === undefined || i === this.stillIndex) continue;
this.remove(i);
}
}
private remove(i: number): void {
const e = this.frames.get(i);
if (!e) return;
if (e.onData) this.map.off('sourcedata', e.onData);
for (const { layerId } of e.layers)
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
for (const s of e.sources) if (this.map.getSource(s)) this.map.removeSource(s);
this.frames.delete(i);
this.visible.delete(i);
}
/** Set every channel of frame `i` to `mul` × its peak opacity. */
private setOp(i: number, mul: number): void {
const e = this.frames.get(i);
if (!e) return;
for (const { layerId, opacityProp, peak } of e.layers) {
if (this.map.getLayer(layerId)) this.map.setPaintProperty(layerId, opacityProp, peak * mul);
}
if (mul > 0.001) this.visible.add(i);
else this.visible.delete(i);
}
private hideExcept(keep: Set<number>): void {
for (const i of [...this.visible]) if (!keep.has(i)) this.setOp(i, 0);
}
/**
* Eagerly load `indices` with at most `maxInFlight` frames loading at once.
* Each frame is a set of layers, so MapLibre + the tile worker pool fetch and
* render them in parallel; the cap bounds the number of open tile requests.
*/
preload(indices: number[], maxInFlight = 8): void {
this.preloadMax = Math.max(1, maxInFlight);
this.preloadQueue = indices.slice();
this.pumpPreload();
}
private pumpPreload(): void {
while (this.preloadInFlight < this.preloadMax && this.preloadQueue.length) {
const i = this.preloadQueue.shift();
if (i === undefined || i < 0 || i >= this.frameCount) continue;
if (this.frames.get(i)?.ready || this.preloading.has(i)) continue;
this.preloading.add(i);
this.preloadInFlight++;
this.ensure(i); // synchronous cache hits settle via markReady immediately
}
}
/** Remove every layer/source (e.g. before a style reload). */
reset(): void {
this.stopRaf();
for (const i of [...this.frames.keys()]) this.remove(i);
this.lru = [];
this.visible.clear();
this.lastShown = -1;
this.firstShown = false;
this.preloadQueue = [];
this.preloading.clear();
this.preloadInFlight = 0;
}
destroy(): void {
this.alive = false;
this.reset();
}
}
+33
View File
@@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
/**
* Cross-map sync state, shared by every WeatherMap on the page so the maps stay
* matched. Split into one store per concern so a change to one (e.g. speed)
* doesn't wake effects that only care about another (e.g. the playhead).
*/
/** Camera shared across maps; the last map the user moved writes it. */
export interface SharedCamera {
center: [number, number];
zoom: number;
}
export const sharedCamera = writable<SharedCamera | null>(null);
/** Whether playback is running. */
export const clockPlaying = writable(true);
/** Minimum wall-time per frame, in ms. */
export const clockSpeed = writable(450);
/** Loop / preload span in days from today 00:00 (Infinity = the whole run). */
export const clockSpan = writable(2.5);
/**
* The scrub target. `epoch` bumps on every scrub so followers re-apply even if
* the index repeats; `index` is -1 before the first scrub.
*/
export interface ScrubState {
index: number;
epoch: number;
}
export const clockScrub = writable<ScrubState>({ index: -1, epoch: 0 });
+166
View File
@@ -0,0 +1,166 @@
/**
* Thin integration layer over @openmeteo/weather-map-layer for the native
* animated weather maps. Responsibilities:
*
* - register the `om://` MapLibre protocol once (shared settings/cache)
* - resolve drizzli's selected model id to a maps domain that actually serves
* the requested variable, falling back to a global model when the model has
* no map domain (or its run lacks the variable) - "use the global models as
* fallback"
* - build the fully-resolved `.om` tile-source URLs for a given model run and
* forecast time (the timestamp lives in the path, the variable in the query)
*/
import {
type DomainMetaDataJson,
defaultOmProtocolSettings,
omProtocol
} from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import { modelDomainCandidates } from '$lib/utils/maps-domain';
/** Tile host. Always the production CDN (serves CORS/CORP for our headers). */
const BASE = 'https://map-tiles.open-meteo.com';
/**
* Global models (widest coverage) tried after the selected model's own
* candidates. dwd_icon leads: it is truly global, hourly, and carries the
* longest run, which makes for the smoothest animation.
*/
const GLOBAL_FALLBACKS = ['dwd_icon', 'ecmwf_ifs025', 'ncep_gfs013'];
// Register the protocol exactly once with a single shared settings object.
export const omSettings = { ...defaultOmProtocolSettings };
let registered = false;
export const registerOmProtocol = (): void => {
if (registered) return;
registered = true;
maplibregl.addProtocol('om', (params, abortController) =>
omProtocol(params, abortController, omSettings)
);
};
const pad = (n: number): string => String(n).padStart(2, '0');
/** Model-run directory, e.g. `2026/07/25/0600Z` (UTC). */
export const fmtModelRun = (run: Date): string =>
`${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/${pad(
run.getUTCHours()
)}${pad(run.getUTCMinutes())}Z`;
/** Forecast-time filename stem, e.g. `2026-07-25T1400` (UTC). */
export const fmtValidTime = (t: Date): string =>
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}T${pad(
t.getUTCHours()
)}${pad(t.getUTCMinutes())}`;
/** Plain https `.om` URL for a single forecast step. */
export const omFileUrl = (domain: string, run: Date, time: Date): string =>
`${BASE}/data_spatial/${domain}/${fmtModelRun(run)}/${fmtValidTime(time)}.om`;
/**
* `om://` source URL for one forecast step of a given variable. The same URL
* serves both a raster field and (with `arrows`) a vector `wind-arrows` layer;
* MapLibre asks the protocol for image vs vector tiles per source type.
*/
export const omSourceUrl = (
domain: string,
run: Date,
time: Date,
variable: string,
dark: boolean,
arrows = false
): string =>
`om://${omFileUrl(domain, run, time)}?variable=${variable}${arrows ? '&arrows=true' : ''}${
dark ? '&dark=true' : ''
}`;
/** Ordered, de-duplicated domain candidates for a model, then global fallbacks. */
const domainCandidates = (model: string): string[] => {
const seen = new Set<string>();
const out: string[] = [];
for (const d of [...(modelDomainCandidates[model] ?? [model]), ...GLOBAL_FALLBACKS]) {
if (!seen.has(d)) {
seen.add(d);
out.push(d);
}
}
return out;
};
const metaCache = new Map<string, Promise<DomainMetaDataJson | null>>();
const fetchLatestMeta = (
domain: string,
signal?: AbortSignal
): Promise<DomainMetaDataJson | null> => {
let pending = metaCache.get(domain);
if (!pending) {
pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal })
.then((r) => (r.ok ? (r.json() as Promise<DomainMetaDataJson>) : null))
.catch(() => null);
metaCache.set(domain, pending);
// short TTL so a fresh model run is picked up without a reload
setTimeout(() => metaCache.delete(domain), 60_000);
}
return pending;
};
export interface ResolvedSource {
/** The maps domain we actually render (may be a global fallback). */
domain: string;
/** Model run of this domain's latest data. */
modelRun: Date;
/** Forecast steps as Dates, ascending. */
times: Date[];
/** True when we fell back off the user's selected model. */
fellBack: boolean;
}
export interface ResolveOptions {
signal?: AbortSignal;
/**
* Drop the first timestep. Accumulation variables (e.g. precipitation, an
* hourly sum) have no value at the analysis time; instantaneous variables
* (e.g. wind) do, so leave this off for them.
*/
skipFirst?: boolean;
}
/**
* Resolve the selected model to a renderable source for `variable`. Probes each
* candidate domain's `latest.json` in order and returns the first that serves
* the variable. Global fallbacks are appended so there is (almost) always a
* result.
*/
export const resolveSource = async (
model: string,
variable: string,
opts: ResolveOptions = {}
): Promise<ResolvedSource | null> => {
const { signal, skipFirst } = opts;
const candidates = domainCandidates(model);
const preferred = new Set(modelDomainCandidates[model] ?? [model]);
for (const domain of candidates) {
const meta = await fetchLatestMeta(domain, signal);
if (signal?.aborted) return null;
if (!meta || !meta.variables?.includes(variable) || !meta.valid_times?.length) continue;
const sorted = meta.valid_times
.map((s) => new Date(s))
.filter((d) => !isNaN(d.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
const times = skipFirst && sorted.length > 1 ? sorted.slice(1) : sorted;
if (!times.length) continue;
return {
domain,
modelRun: new Date(meta.reference_time),
times,
fellBack: !preferred.has(domain)
};
}
return null;
};
+103
View File
@@ -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} />
+45
View File
@@ -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 />
+140
View File
@@ -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>
+67
View File
@@ -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'
];
+129
View File
@@ -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
View File
@@ -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'
};
}
+18 -5
View File
@@ -65,7 +65,14 @@ export const defaultVariablePrefs: VariablePrefs = {
wind: true, wind: true,
humidity: true, humidity: true,
clouds: true, clouds: true,
precipitation: true precipitation: true,
// extra rows, off by default
dew_point: false,
gusts: false,
pressure: false,
uv: false,
visibility: false,
snowfall: false
}, },
charts: { charts: {
temperature: true, temperature: true,
@@ -79,6 +86,9 @@ export const defaultVariablePrefs: VariablePrefs = {
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs); export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
/** /**
* Meteogram layout: an ordered list of chart panels, each holding an ordered * Meteogram layout: an ordered list of chart panels, each holding an ordered
* list of variable keys (see the chart variable registry). Users drag * list of variable keys (see the chart variable registry). Users drag
@@ -90,9 +100,9 @@ export interface ChartPanel {
} }
export const defaultChartLayout: ChartPanel[] = [ export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] }, { id: 'panel-1', variables: ['temperature', 'weather_icons'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] }, { id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
{ id: 'panel-3', variables: ['wind', 'humidity'] } { id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
]; ];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout); export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
@@ -105,12 +115,15 @@ export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit'; temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn'; wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch'; precipitation_unit: 'mm' | 'inch';
/** Clock format for displayed times: 24-hour or 12-hour AM/PM. */
time_format: '24h' | '12h';
} }
export const defaultUnits: UnitPrefs = { export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius', temperature_unit: 'celsius',
wind_speed_unit: 'kmh', wind_speed_unit: 'kmh',
precipitation_unit: 'mm' precipitation_unit: 'mm',
time_format: '24h'
}; };
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits); export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
+1 -1
View File
@@ -12,7 +12,7 @@
* the map does not serve at all (best_match, bom, google, UKMO ensembles) * the map does not serve at all (best_match, bom, google, UKMO ensembles)
* resolve to null. * resolve to null.
*/ */
const modelDomainCandidates: Record<string, string[]> = { export const modelDomainCandidates: Record<string, string[]> = {
// DWD Germany // DWD Germany
icon_seamless: ['dwd_icon_seamless', 'dwd_icon'], icon_seamless: ['dwd_icon_seamless', 'dwd_icon'],
icon_global: ['dwd_icon'], icon_global: ['dwd_icon'],
+4 -3
View File
@@ -83,13 +83,14 @@
<Header onMenuToggle={toggleMobileMenu} /> <Header onMenuToggle={toggleMobileMenu} />
<main <main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 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()}
{:else} {:else}
<!-- cap the content width on very large screens --> <!-- cap the content width on very large screens; generous bottom room
<div class="mx-auto w-full max-w-[1536px]"> so the last chart/table never sits flush against the viewport edge -->
<div class="mx-auto w-full max-w-[1536px] pb-80">
{@render children()} {@render children()}
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -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');
}); });
}); });
+105 -41
View File
@@ -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']
}); });
@@ -154,12 +147,32 @@
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : [] fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
); );
// Surface a note when the chosen model's ensemble stops short of the request.
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
interface ChartDef { interface ChartDef {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
unit: string; unit: string;
showCredit: boolean; showCredit: boolean;
series: ChartSeries[]; series: ChartSeries[];
zeroBaseLeft?: boolean;
yMin?: number;
yMax?: number;
}
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
// pressure never anchored to zero; percentages pinned to 0-100).
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
return { zeroBaseLeft: false };
}
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
}
return { zeroBaseLeft: true };
} }
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)'; const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
@@ -197,34 +210,41 @@
const isColumn = isColumnUnit(unit); const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length; const memberCount = varData.members.length;
// Trim to the valid horizon so the axis scale ignores the trailing
// zeros the service pads past the model's range.
const vMax = varData.max.slice(0, validLength);
const vMin = varData.min.slice(0, validLength);
const vAvg = varData.average.slice(0, validLength);
// Min/max spread band + mean, instead of every individual member // Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [ const series: ChartSeries[] = [
{ {
name: 'Max', name: 'Max',
type: 'line', type: 'line',
color: BAND_COLOR, color: BAND_COLOR,
data: varData.max, data: vMax,
width: 1, width: 1,
fill: true, fill: true,
fillOpacity: 0.25, fillOpacity: 0.25,
bandTo: varData.min, bandTo: vMin,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
}, },
{ {
name: 'Mean', name: 'Mean',
type: isColumn ? 'bar' : 'line', type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average, color: CHART_COLORS.average,
data: varData.average, data: vAvg,
width: 3, width: 3.5,
dashed: !isColumn, dashed: !isColumn,
outline: !isColumn,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: vMin,
width: 1,
format: (v) => `${v.toFixed(1)} ${unit}` format: (v) => `${v.toFixed(1)} ${unit}`
} }
]; ];
@@ -232,6 +252,7 @@
const isFirst = vi === 0; const isFirst = vi === 0;
const isLast = vi === variables.length - 1; const isLast = vi === variables.length - 1;
const axis = axisForVar(variable);
defs.push({ defs.push({
// each chart is labelled so the variable is obvious at a glance // each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`, title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
@@ -240,7 +261,10 @@
: `min · mean · max (${unit})`, : `min · mean · max (${unit})`,
unit, unit,
showCredit: isLast, showCredit: isLast,
series series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
}); });
} }
@@ -250,7 +274,7 @@
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── --> <!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <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"> <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"
@@ -270,7 +294,7 @@
</div> </div>
</div> </div>
<div class="flex w-full items-center gap-3 sm:w-auto"> <div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'} selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups} groups={ensembleModelGroups}
@@ -285,6 +309,30 @@
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if isTrimmed}
<div
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
>
<svg
class="mt-0.5 h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range.
</span>
</div>
{/if}
{#if loadError} {#if loadError}
<div <div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive" class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
@@ -293,26 +341,42 @@
</div> </div>
{/if} {/if}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}> {#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]}
{showLegend} timestamps={timestampsSec}
height={300} timezone={fetchedData.timezone}
group={CHART_GROUP} series={def.series}
/> bands={fetchedData.daylightBands}
unit={def.unit}
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 ───────────────────────────────────────── -->
@@ -5,6 +5,8 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings'; import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
@@ -17,6 +19,7 @@
SERIES_COLORS, SERIES_COLORS,
calculateAverage, calculateAverage,
findUnit, findUnit,
groupRange,
isColumnUnit isColumnUnit
} from '$lib/charts'; } from '$lib/charts';
import { import {
@@ -59,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']
}); });
@@ -99,6 +102,40 @@
// components persist across refetches; entries are null while unmounted // components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null)); let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
const SECONDS_PER_DAY = 24 * 3600;
function dayStartSec(day: Date): number | null {
if (!fetchedData) return null;
const tz = fetchedData.timezone;
const target = formatZoned(day, tz, 'yyyy-MM-dd');
const idx = fetchedData.timestamps.findIndex(
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
);
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
}
function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
if (start == null || liveCharts.length === 0) return;
// charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
liveCharts[0]?.resetRange();
}
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Data Fetching (only when params.hourly or params.models change) ─────── // ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => { $effect(() => {
@@ -161,6 +198,24 @@
series: ChartSeries[]; series: ChartSeries[];
} }
// Human labels for the compared variables (API names → readable title)
const VAR_LABELS: Record<string, string> = {
temperature_2m: 'Temperature',
apparent_temperature: 'Feels like',
dew_point_2m: 'Dew point',
precipitation: 'Precipitation',
rain: 'Rain',
showers: 'Showers',
snowfall: 'Snowfall',
wind_speed_10m: 'Wind speed',
wind_gusts_10m: 'Wind gusts',
relative_humidity_2m: 'Relative humidity',
cloud_cover: 'Cloud cover',
pressure_msl: 'Pressure (MSL)'
};
const varLabel = (v: string): string =>
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let chartDefs = $derived.by((): ChartDef[] => { let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return []; if (!fetchedData) return [];
@@ -202,17 +257,19 @@
color: CHART_COLORS.average, color: CHART_COLORS.average,
data: average, data: average,
width: 4, width: 4,
dashed: !isColumn dashed: !isColumn,
outline: !isColumn
}); });
const isFirst = vi === 0; const isFirst = vi === 0;
const isLast = vi === variableCount - 1; const isLast = vi === variableCount - 1;
defs.push({ defs.push({
title: isFirst ? 'Model Compare' : undefined, // label every chart with its variable so each is identifiable
title: varLabel(variable),
subtitle: isFirst subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}` ? `${params.models?.length ?? 0} models · dashed = average`
: undefined, : `across ${params.models?.length ?? 0} models`,
unit, unit,
showCredit: isLast, showCredit: isLast,
series series
@@ -223,6 +280,73 @@
}); });
</script> </script>
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<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
>Model comparison
</p>
</div>
</div>
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg: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>
{#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
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 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>
</div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── --> <!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError} {#if loadError}
@@ -235,30 +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">
>
{#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">19912020 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 = 19912020 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>
+111 -25
View File
@@ -1,7 +1,8 @@
<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 { import {
storedChartLayout, storedChartLayout,
@@ -17,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';
@@ -69,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
@@ -89,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(() => {
@@ -144,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>
@@ -152,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="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3"> <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"
@@ -174,7 +203,7 @@
</div> </div>
</div> </div>
<div class="flex w-full min-w-0 items-center gap-3 sm:w-auto"> <div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
<ModelSelector <ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'} selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => { onModelChange={(model) => {
@@ -198,38 +227,95 @@
</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 class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div> <!-- Mobile: a compact day strip and the hourly table share this wrapper, so
{/if} the strip stays stuck (collapsing as it goes) while scrolling the table
and then releases exactly at the table's bottom, freeing the meteograms.
The strip itself is hidden on md+ (the desktop cards above take over). -->
<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} />
{:else} {:else}
<!-- reserve the exact chart area height before the first fetch resolves --> <!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8"> <section class="mt-8" transition:fade={{ duration: 200 }}>
<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>
@@ -35,18 +35,33 @@
// of view — the user reveals it by scrolling left. Re-hide only when the // of view — the user reveals it by scrolling left. Re-hide only when the
// dataset (location) changes, not on every re-render. // dataset (location) changes, not on every re-render.
let scrollEl = $state<HTMLDivElement>(); let scrollEl = $state<HTMLDivElement>();
let pastBtnEl = $state<HTMLButtonElement>(); let cardsWrapEl = $state<HTMLDivElement>();
let hiddenForRef: FetchedDaily | null = null; let hiddenForRef: FetchedDaily | null = null;
// Show the scrollbar only briefly while actively scrolling (a constant thin
// gutter is reserved so revealing the thumb never shifts layout).
let scrolling = $state(false);
let scrollHideTimer: ReturnType<typeof setTimeout> | undefined;
function onScroll() {
scrolling = true;
clearTimeout(scrollHideTimer);
scrollHideTimer = setTimeout(() => (scrolling = false), 700);
}
$effect(() => { $effect(() => {
const d = daily; const d = daily;
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return; if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
hiddenForRef = d; hiddenForRef = d;
const btn = pastBtnEl.getBoundingClientRect(); const el = scrollEl;
const cont = scrollEl.getBoundingClientRect(); const wrap = cardsWrapEl;
// scroll so the button's right edge sits just past the left edge (a small // defer to after layout so the measured positions and scroll width are final
// gap of extra margin keeps the first day card from hugging the edge) requestAnimationFrame(() => {
scrollEl.scrollLeft += btn.right - cont.left + 6; // scroll so the first day card sits exactly at the content edge (aligned
// 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;
});
}); });
function getDaylightSeconds(index: number): number { function getDaylightSeconds(index: number): number {
@@ -106,21 +121,25 @@
</defs> </defs>
</svg> </svg>
<div in:fade out:fade class="mb-6 min-h-[260px]"> <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="-mx-3 flex gap-2 overflow-x-auto px-3 pt-4 pb-8" 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"
style="scrollbar-width: thin" class:scrolling
onscroll={onScroll}
> >
{#if daily} {#if daily}
{#if canExtendPast && onExtendPast} {#if canExtendPast && onExtendPast}
<button <button
bind:this={pastBtnEl}
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"
> >
@@ -143,189 +162,271 @@
</span> </span>
</button> </button>
{/if} {/if}
{#each daily.dailyDates as time, index (index)} <!-- the cards fill at least the viewport so the row overflows past the
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)} "past days" button, letting it scroll out of view even on wide
{@const tempMax = daily.daily.temperature_2m_max[index]} screens -->
{@const tempMin = daily.daily.temperature_2m_min[index]} <div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
{@const wCode = daily.daily.weather_code[index]} {#each daily.dailyDates as time, index (index)}
{@const sunDuration = daily.daily.sunshine_duration[index]} {@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const daylightSec = getDaylightSeconds(index)} {@const tempMax = daily.daily.temperature_2m_max[index]}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)} {@const tempMin = daily.daily.temperature_2m_min[index]}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)} {@const wCode = daily.daily.weather_code[index]}
{@const precipSum = daily.daily.precipitation_sum[index]} {@const sunDuration = daily.daily.sunshine_duration[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]} {@const daylightSec = getDaylightSeconds(index)}
{@const gustMax = daily.daily.windgusts_10m_max[index]} {@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const windDir = daily.daily.winddirection_10m_dominant[index]} {@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const unit = String(units.temperature_unit)} {@const precipSum = daily.daily.precipitation_sum[index]}
{@const maxStyle = getTempStyle(tempMax, unit)} {@const windMax = daily.daily.windspeed_10m_max[index]}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)} {@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))} {@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))} {@const unit = String(units.temperature_unit)}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)} {@const maxStyle = getTempStyle(tempMax, unit)}
<button {@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none {@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected {selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60' ? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}" : 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected} aria-pressed={selected}
onclick={() => onSelectDay(time, index)} onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
> >
{getRelativeDayLabel(time, daily.timezone)} <!-- Day label -->
</span>
<!-- Weather icon: large day with a night badge in the corner -->
<div class="relative my-1 px-3 -ml-2.5">
<svg
class="day-icon fill-foreground"
width="100px"
height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, false)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span <span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums" class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
> >
{tempMax.toFixed(0)}° {formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span> </span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground"> <span
{tempMin.toFixed(0)}° class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span> </span>
</div>
<!-- Details --> <!-- Weather icon: large day with a night badge in the corner -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5"> <div class="relative my-1 px-3 -ml-2.5">
<!-- Sunshine --> <svg
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}"> class="day-icon fill-foreground"
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}"> width="100px"
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use> height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg> </svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted"> <svg
<div class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
class="h-full rounded-full transition-all" width="42px"
style="width: {sunPct}%; background-color: {sunColor}" height="42px"
></div> >
</div> <use
<span class="text-[10px] font-medium tabular-nums text-muted-foreground"> xlink:href="/images/weather-icons/{getWeatherIconName(
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span> </span>
</div> </div>
<!-- Precipitation + wind --> <!-- Details -->
<div <div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80" <!-- Sunshine -->
> <div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<span <svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
class="inline-flex items-center gap-0.5 {lowPrecip <use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
? 'text-muted-foreground/50'
: ''}"
>
<svg
class="shrink-0 {lowPrecip ? 'fill-muted-foreground/40' : 'fill-foreground/70'}"
width="23px"
height="23px"
>
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg> </svg>
{Number(precipSum ?? 0).toFixed( <div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
precipSum >= 10 ? 0 : 1 <div
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"} class="h-full rounded-full transition-all"
</span> style="width: {sunPct}%; background-color: {sunColor}"
<span ></div>
class="inline-flex items-center gap-0.5 {lowWind </div>
? 'text-muted-foreground/50' <span class="text-[10px] font-medium tabular-nums text-muted-foreground">
: ''}" {Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
> >
{#if windDir != null && !isNaN(windDir)} <span
<span class="inline-flex items-center gap-0.5 {lowPrecip
class="inline-flex shrink-0 -mr-2" ? 'text-muted-foreground/50'
style="transform: {getWindArrowRotation(windDir)}" : ''}"
>
<svg
class="shrink-0 {lowPrecip
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="23px"
height="23px"
> >
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
<span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</span>
{:else}
<svg <svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'} class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px" width="40px"
height="40px" height="40px"
> >
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use> <use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg> </svg>
</span> {/if}
{:else} {windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
<svg >-{gustMax?.toFixed(0) ?? '-'}</span
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
> >
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use> </span>
</svg> </div>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
</div> </div>
</div> </button>
{/if}
{/each}
<!-- Mobile: the "load more" card is the last item in the SAME flex row
as the cards (reached by scrolling); keeping it in this container
avoids the zoom/flex mis-position seen when it was a sibling. On
md+ it's hidden here and the pinned sibling below is shown. -->
{#if canExtend && onExtend}
<button
type="button"
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"
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> </button>
{/if} {/if}
{/each} </div>
{#if canExtend && onExtend}
<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"
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}
{/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>
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
thumb invisible until the user is actively scrolling. */
.day-scroll {
scrollbar-width: thin;
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 {
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
}
.day-scroll::-webkit-scrollbar {
height: 8px;
}
.day-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: transparent;
transition: background 0.2s;
}
.day-scroll.scrolling::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-border) 85%, transparent);
}
/* The cards group fills the viewport so the row overflows past the side
buttons. On md+ we also reserve room so the "load more" button stays in
view; on mobile it keeps its full width after the cards (reached by
scrolling) and is never clipped. */
.cards-fill {
min-width: 100%;
}
@media (min-width: 768px) {
.cards-fill {
min-width: calc(100% - var(--fill-reserve, 0rem));
}
}
/* Mobile: keep the exact desktop layout, just scale the whole card down. */ /* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) { @media (max-width: 768px) {
.day-card { .day-card {
@@ -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>
@@ -1,9 +1,15 @@
<script lang="ts"> <script lang="ts">
import { storedVariablePrefs } from '$lib/stores/settings'; import { fade } from 'svelte/transition';
import {
defaultVariablePrefs,
storedHourlyInterval,
storedVariablePrefs
} from '$lib/stores/settings';
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date'; import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
import { setGroupHover } from '$lib/charts'; import { groupHover, setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors'; import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes'; import { getWeatherIconName } from '../../utils/weather-codes';
@@ -51,11 +57,15 @@
setGroupHover(METEOGRAM_GROUP, null); setGroupHover(METEOGRAM_GROUP, null);
} }
let hourlyInterval = $state<1 | 3>(3); // persisted 1h / 3h preference
let hourlyInterval = $derived($storedHourlyInterval);
// Row visibility, controlled from the Variables sidebar (missing keys // Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible) // from older stored prefs default to visible)
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true); let showRow = $derived(
(key: string): boolean =>
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
);
const today = new Date(); const today = new Date();
const tempUnit = $derived(getTempUnit(units)); const tempUnit = $derived(getTempUnit(units));
@@ -150,6 +160,67 @@
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`; return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
} }
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
function hexRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function mixRgb(
a: [number, number, number],
b: [number, number, number],
f: number
): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f)
];
}
// Pressure: 940 hPa blue → 1010 white → 1060 red.
const PRESSURE_LOW = hexRgb('#4444ff');
const PRESSURE_MID = hexRgb('#ffffff');
const PRESSURE_HIGH = hexRgb('#ff4444');
function getPressureBg(hpa: number | null): string {
if (hpa == null || isNaN(hpa)) return 'transparent';
const v = Math.max(940, Math.min(1060, hpa));
const c =
v <= 1010
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
}
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
// opacity tracks the value so low/night hours stay faint instead of tinting
// the whole row.
const UV_STOPS = [
'#009392',
'#39b185',
'#9ccb86',
'#e9e29c',
'#eeb479',
'#e88471',
'#cf597e'
].map(hexRgb);
function getUvBg(uv: number | null): string {
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
const v = Math.min(12, uv);
const p = (v / 12) * (UV_STOPS.length - 1);
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
const alpha = 0.18 + (v / 12) * 0.5;
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
}
function formatPrecipTooltip(precip: number | null, prob: number | null): string { function formatPrecipTooltip(precip: number | null, prob: number | null): string {
const parts: string[] = []; const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`); if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
@@ -209,6 +280,83 @@
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth) ? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: 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 ─────────────────────────────────────────────────────
// 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).
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
let hoveredCol = $derived.by((): number => {
if (chartHoverTime == null || cellData.length === 0) return -1;
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
const tMs = chartHoverTime * 1000;
for (let i = 0; i < cellData.length; i++) {
const start = cellData[i].date.getTime();
if (tMs >= start && tMs < start + stepMs) return i;
}
return -1;
});
</script> </script>
{#snippet weatherIcon(name: string, size: number = 16)} {#snippet weatherIcon(name: string, size: number = 16)}
@@ -236,10 +384,11 @@
{#if cellData.length > 0} {#if cellData.length > 0}
{@const hourly = data.hourly} {@const hourly = data.hourly}
{@const iconPx = is3h ? 38 : 33} {@const iconPx = is3h ? 38 : 33}
<!-- Full-bleed to the viewport edges on mobile (main has p-5 = 1.25rem); <!-- Full-bleed to the viewport edges on mobile (main has p-3 = 0.75rem);
a contained rounded card on md+ --> a contained rounded card on md+ -->
<section <section
class="-mx-5 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border" transition:fade={{ duration: 200 }}
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
> >
<!-- Card toolbar --> <!-- Card toolbar -->
<div <div
@@ -289,7 +438,7 @@
? 'bg-background text-foreground shadow-sm' ? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}" : 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval} aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)} onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
> >
{interval}h {interval}h
</button> </button>
@@ -300,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}
@@ -321,7 +470,7 @@
</th> </th>
<td <td
colspan={cellData.length} colspan={cellData.length}
class="relative h-11 overflow-visible p-0" class="relative h-12 cursor-default overflow-visible p-0"
onmousemove={hoverTimeRow} onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover} onmouseleave={clearTimeRowHover}
> >
@@ -437,7 +586,7 @@
{#each cellData as cell, i (cell.idx)} {#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]} {@const wCode = hourly.weather_code[cell.idx]}
<td <td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}" class="cell h-12 leading-0"
class:icon-day={cell.isDaytime} class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime} class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime} class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -459,7 +608,7 @@
{@const temp = hourly.temperature_2m[cell.idx]} {@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))} {@const style = getTempStyle(temp, String(units.temperature_unit))}
<td <td
class="cell h-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}" class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
style="background-color:{style.bg};color:{style.fg}" style="background-color:{style.bg};color:{style.fg}"
> >
{formatTemp(temp)} {formatTemp(temp)}
@@ -468,13 +617,30 @@
</tr> </tr>
{/if} {/if}
<!-- Feels Like --> <!-- Feels Like (short row) -->
{#if showRow('feels')} {#if showRow('feels')}
<tr class="row"> <tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')} {@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]} {@const temp = hourly.apparent_temperature[cell.idx]}
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"> <td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)} {formatTemp(temp)}
</td> </td>
{/each} {/each}
@@ -488,16 +654,16 @@
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]} {@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]} {@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="cell h-13 align-middle leading-tight"> <td class="relative cell h-12 align-middle leading-none">
{#if windDir != null && !isNaN(windDir)} {#if windDir != null && !isNaN(windDir)}
<span <span
class="inline-block leading-0" class="absolute top-0 left-1/2 inline-block origin-center leading-0"
style="transform:{getWindArrowRotation(windDir)}" style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
> >
{@render weatherIcon('wi-direction-down', 22)} {@render weatherIcon('wi-direction-down', 40)}
</span> </span>
{/if} {/if}
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}"> <span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
{formatValue(wind)} {formatValue(wind)}
</span> </span>
</td> </td>
@@ -505,13 +671,30 @@
</tr> </tr>
{/if} {/if}
<!-- Humidity --> <!-- Wind Gusts (off by default) -->
{#if showRow('gusts')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td
class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm'
: 'text-xs'}"
>
{formatValue(v)}
</td>
{/each}
</tr>
{/if}
<!-- Humidity (short row) -->
{#if showRow('humidity')} {#if showRow('humidity')}
<tr class="row"> <tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')} {@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]} {@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}"> <td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)} {formatValue(hum)}
</td> </td>
{/each} {/each}
@@ -525,7 +708,7 @@
{#each cellData as cell (cell.idx)} {#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]} {@const cloud = hourly.cloud_cover[cell.idx]}
<td <td
class="cell h-8" class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})" style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
> >
{formatValue(cloud)} {formatValue(cloud)}
@@ -534,6 +717,51 @@
</tr> </tr>
{/if} {/if}
<!-- Pressure (off by default) -->
{#if showRow('pressure')}
<tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]}
<td
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- UV Index (off by default) -->
{#if showRow('uv')}
<tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]}
<td
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Visibility (off by default) -->
{#if showRow('visibility')}
<tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Precipitation --> <!-- Precipitation -->
{#if showRow('precipitation')} {#if showRow('precipitation')}
<tr class="row"> <tr class="row">
@@ -542,7 +770,7 @@
{@const precip = hourly.precipitation[cell.idx]} {@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]} {@const prob = hourly.precipitation_probability[cell.idx]}
<td <td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}" class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}" style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)} title={formatPrecipTooltip(precip, prob)}
> >
@@ -556,9 +784,31 @@
{/each} {/each}
</tr> </tr>
{/if} {/if}
<!-- Snowfall (off by default) -->
{#if showRow('snowfall')}
<tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td>
{/each}
</tr>
{/if}
</tbody> </tbody>
</table> </table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
></div>
{/if}
<!-- "Now" column highlight + exact-time line --> <!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null} {#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length} {@const colWidth = (tableWidth - headerColWidth) / cellData.length}
@@ -3,9 +3,9 @@
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, ChartToolbar } from '$lib/components/charts'; import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { CanvasChart, groupRange } from '$lib/charts'; import { CanvasChart, groupRange } from '$lib/charts';
@@ -29,6 +29,19 @@
const CHART_HEIGHT = 300; const CHART_HEIGHT = 300;
let customizerOpen = $state(false); let customizerOpen = $state(false);
let downloadingPng = $state(false);
async function downloadPng(): Promise<void> {
if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true;
try {
// 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 {
setTimeout(() => (downloadingPng = false), 500);
}
}
// Charts persist across data refetches; entries are null while unmounted. // Charts persist across data refetches; entries are null while unmounted.
let chartComponents: (CanvasChart | null)[] = $state([]); let chartComponents: (CanvasChart | null)[] = $state([]);
@@ -78,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(() => {
@@ -153,7 +171,7 @@
); );
</script> </script>
<section class="mt-8" in:fade={{ duration: 200 }}> <section class="mt-8" transition:fade={{ duration: 200 }}>
<div class="mb-3 flex flex-wrap items-center justify-between gap-2"> <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-lg font-bold"> <h3 class="text-lg font-bold">
Meteograms Meteograms
@@ -174,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"
@@ -224,6 +223,61 @@
</svg> </svg>
Customize Customize
</button> </button>
<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}
<svg
class="h-3.5 w-3.5 animate-spin"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
{:else}
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{/if}
PNG
</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>
@@ -231,22 +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}
<div class="flex flex-col gap-6"> <!-- one full-bleed card until lg / contained card on lg+, graphs stacked
tightly so they read as one fluent meteogram -->
<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)}
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
<div <div
class="-mx-5 border-y border-border/70 bg-card px-0 py-3 shadow-sm md:mx-0 md:rounded-2xl md:border md:px-4 md:py-4" 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-1 flex items-center justify-between px-3 md:px-1"> <div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
<h4 class="truncate text-sm font-bold text-muted-foreground"> <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
@@ -282,10 +339,6 @@
</div> </div>
{/each} {/each}
</div> </div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{/if} {/if}
</section> </section>
@@ -42,13 +42,19 @@
> >
<Select.Trigger <Select.Trigger
aria-label="{label} selection" aria-label="{label} selection"
class="group h-auto 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}
@@ -17,10 +17,16 @@
{ key: 'icons', label: 'Weather icons' }, { key: 'icons', label: 'Weather icons' },
{ key: 'temperature', label: 'Temperature' }, { key: 'temperature', label: 'Temperature' },
{ key: 'feels', label: 'Feels like' }, { key: 'feels', label: 'Feels like' },
{ key: 'dew_point', label: 'Dew point' },
{ key: 'wind', label: 'Wind' }, { key: 'wind', label: 'Wind' },
{ key: 'gusts', label: 'Wind gusts' },
{ key: 'humidity', label: 'Humidity' }, { key: 'humidity', label: 'Humidity' },
{ key: 'clouds', label: 'Cloud cover' }, { key: 'clouds', label: 'Cloud cover' },
{ key: 'precipitation', label: 'Precipitation' } { key: 'pressure', label: 'Pressure' },
{ key: 'uv', label: 'UV index' },
{ key: 'visibility', label: 'Visibility' },
{ key: 'precipitation', label: 'Precipitation' },
{ key: 'snowfall', label: 'Snowfall' }
]; ];
function toggle(section: 'table' | 'charts', key: string) { function toggle(section: 'table' | 'charts', key: string) {
@@ -4,6 +4,8 @@
* (data field, render style, colour, unit family) so the panels can be * (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout. * assembled dynamically from a user-defined layout.
*/ */
import { defaultVariablePrefs } from '$lib/stores/settings';
import { getColor } from '../../utils/colors'; import { getColor } from '../../utils/colors';
import { import {
type WeatherUnits, type WeatherUnits,
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
dashed?: boolean; dashed?: boolean;
fill?: boolean; fill?: boolean;
fillOpacity?: number; fillOpacity?: number;
/** Area fill coloured by the value scale, fading out below the minimum */
gradientFill?: boolean;
width?: number; width?: number;
/** Stroke the line coloured by the temperature scale */ /** Stroke the line coloured by the temperature scale */
colorScale?: boolean; colorScale?: boolean;
/** Draw the line in the theme foreground (black/white), while colorScale still
* drives the gradient fill */
foregroundLine?: boolean;
/** Draw a contrasting halo under the line */ /** Draw a contrasting halo under the line */
outline?: boolean; outline?: boolean;
/** Annotate local minima / maxima with their value */ /** Annotate local minima / maxima with their value */
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
type: 'line', type: 'line',
kind: 'temp', kind: 'temp',
color: '#ef6c00', color: '#ef6c00',
width: 9, width: 5.6,
colorScale: true, colorScale: true,
outline: true, foregroundLine: true,
gradientFill: true,
extrema: true extrema: true
}, },
{ {
@@ -201,8 +209,19 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: '#26a69a', color: '#26a69a',
width: 2, width: 2,
fill: true, fill: true,
fillOpacity: 0.15, fillOpacity: 0.15
windArrows: true },
{
key: 'wind_direction',
label: 'Wind direction',
short: 'Dir',
field: 'winddirection_10m',
api: 'wind_direction_10m',
type: 'line',
kind: 'wind',
color: '#14b8a6',
windArrows: true,
marker: true
}, },
{ {
key: 'wind_gusts', key: 'wind_gusts',
@@ -301,23 +320,29 @@ export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined, tablePrefs: Record<string, boolean> | undefined,
layoutKeys: string[] layoutKeys: string[]
): string[] { ): string[] {
const on = (key: string): boolean => tablePrefs?.[key] ?? true; const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
const s = new Set<string>(); const s = new Set<string>();
// Hourly table rows // Hourly table rows
if (on('icons')) s.add('weather_code'); if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m'); if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature'); if (on('feels')) s.add('apparent_temperature');
if (on('dew_point')) s.add('dew_point_2m');
if (on('wind')) { if (on('wind')) {
s.add('wind_speed_10m'); s.add('wind_speed_10m');
s.add('wind_direction_10m'); s.add('wind_direction_10m');
} }
if (on('gusts')) s.add('wind_gusts_10m');
if (on('humidity')) s.add('relative_humidity_2m'); if (on('humidity')) s.add('relative_humidity_2m');
if (on('clouds')) s.add('cloud_cover'); if (on('clouds')) s.add('cloud_cover');
if (on('pressure')) s.add('pressure_msl');
if (on('uv')) s.add('uv_index');
if (on('visibility')) s.add('visibility');
if (on('precipitation')) { if (on('precipitation')) {
s.add('precipitation'); s.add('precipitation');
s.add('precipitation_probability'); s.add('precipitation_probability');
} }
if (on('snowfall')) s.add('snowfall');
// Meteogram variables // Meteogram variables
for (const key of layoutKeys) { for (const key of layoutKeys) {
@@ -325,7 +350,7 @@ export function neededHourlyApiVars(
if (!def) continue; if (!def) continue;
s.add(apiNameOf(def)); s.add(apiNameOf(def));
if (def.pictograms) s.add('weather_code'); if (def.pictograms) s.add('weather_code');
if (def.key === 'wind') s.add('wind_direction_10m'); if (def.windArrows || def.key === 'wind') s.add('wind_direction_10m');
} }
return [...s]; return [...s];
@@ -443,10 +468,12 @@ export function buildPanelDef(
width: d.width, width: d.width,
fill: d.fill, fill: d.fill,
fillOpacity: d.fillOpacity, fillOpacity: d.fillOpacity,
gradientFill: d.gradientFill,
dashed: d.dashed, dashed: d.dashed,
axis, axis,
cloudBand: d.cloudBand, cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined, segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
foregroundLine: d.foregroundLine,
outline: d.outline, outline: d.outline,
labelExtrema: d.extrema, labelExtrema: d.extrema,
labelFormat: d.extrema labelFormat: d.extrema
@@ -469,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),
+14
View File
@@ -32,6 +32,20 @@ const viteServerConfig = (): Plugin => ({
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), viteServerConfig()], plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
// @openmeteo/weather-map-layer is linked from ../../open-meteo/weather-map-layer
// (a file: dependency), so allow Vite to serve files from the shared Projects
// root and keep a single maplibre-gl instance across drizzli and the layer.
server: {
fs: { allow: ['../..'] }
},
resolve: {
dedupe: ['maplibre-gl']
},
optimizeDeps: {
// these ship WASM and must not be pre-bundled by esbuild
exclude: ['@openmeteo/file-reader', '@openmeteo/file-format-wasm']
},
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },