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 5225 additions and 502 deletions
+3
View File
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
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/).
+282
View File
@@ -7,6 +7,10 @@
"": {
"name": "drizzli",
"version": "0.0.1",
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
@@ -46,6 +50,43 @@
"vitest-browser-svelte": "^3.0.0"
}
},
"../../open-meteo/weather-map-layer": {
"name": "@openmeteo/weather-map-layer",
"version": "0.0.19",
"dependencies": {
"@mapbox/tilebelt": "^2.0.3",
"@mapbox/vector-tile": "^3.0.0",
"@openmeteo/file-reader": "^0.0.16",
"maplibre-gl": "^5.20.1",
"pbf": "^5.1.0",
"point-in-polygon-hao": "^1.2.4"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/d3-interpolate": "^3.0.4",
"@types/geojson": "^7946.0.16",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.46.2",
"@typescript-eslint/parser": "^8.46.2",
"@vitest/coverage-v8": "^4.0.15",
"concurrently": "^10.0.1",
"d3-color": "^3.1.0",
"d3-interpolate": "^3.0.1",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.0.0",
"jiti": "^2.6.1",
"live-server": "^1.2.2",
"npm-watch": "^0.13.0",
"prettier": "^3.6.2",
"tsx": "^4.20.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.1",
"unplugin-dts": "^1.0.0-beta.6",
"vite": "^8.0.12",
"vitest": "^4.0.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -565,6 +606,119 @@
"svelte": "^5"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": {
"node": ">= 22"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.2"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz",
"integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.1.0"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.10.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause"
},
"node_modules/@maplibre/mlt": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^5.1.0"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -597,6 +751,10 @@
"node": ">=12.0"
}
},
"node_modules/@openmeteo/weather-map-layer": {
"resolved": "../../open-meteo/weather-map-layer",
"link": true
},
"node_modules/@oxc-project/types": {
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
@@ -1393,6 +1551,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2122,6 +2286,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/earcut": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.24.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
@@ -2570,6 +2740,12 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2728,6 +2904,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/kdbush": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3102,6 +3290,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "5.24.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.1.0",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.1.0",
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
@@ -3118,6 +3340,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3145,6 +3376,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -3293,6 +3530,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3492,6 +3741,12 @@
"node": ">=4"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -3608,6 +3863,12 @@
}
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3618,6 +3879,12 @@
"node": ">=6"
}
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3632,6 +3899,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/rolldown": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -4062,6 +4338,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+4
View File
@@ -53,5 +53,9 @@
"vite": "^8.1.5",
"vitest": "^4.1.10",
"vitest-browser-svelte": "^3.0.0"
},
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
}
}
+401 -42
View File
@@ -31,6 +31,9 @@
width?: number;
/** Draw a low-alpha area fill below (or above, on inverted axes) the line */
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
* instead of the baseline (e.g. an ensemble min-max band) */
bandTo?: (number | null)[];
@@ -50,6 +53,9 @@
shortName?: string;
/** Colour each line segment by value (e.g. a temperature colour scale) */
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 */
outline?: boolean;
/** Annotate local minima / maxima with their value */
@@ -101,6 +107,14 @@
export function groupRange(name: string): { start: number; end: number } | 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 lang="ts">
@@ -202,12 +216,40 @@
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
const HOUR = 3600;
// Top icon rows (weather pictograms / wind arrows)
const ICON_ROW_H = 30; // reserved height per icon row
const ICON_BAND_H = 28; // visible band height
const ICON_PX = 26; // pictogram size
const ICON_ROW_H = 40; // reserved height per icon row
const ICON_BAND_H = 38; // visible band height
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
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 ──────────────────────────────────────────────────────────────────
let containerEl: HTMLDivElement | undefined = $state();
@@ -250,18 +292,25 @@
let cloudBandSeries = $derived(visibleSeries.filter((s) => s.cloudBand));
let hasRightAxis = $derived(plottedSeries.some((s) => s.axis === 'right'));
// Tighter left gutter on narrow screens so axis labels sit near the edge
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
// Minimal gutters on narrow screens so the plot uses nearly the full width
// (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)
// has a right axis, so a stacked row of charts share the same plot width.
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
// has a right axis, so a stacked row of charts share the same plot width. On
// 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
// 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 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 plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
let plotH = $derived(Math.max(1, height - padTop - padBottom));
interface Scale {
min: number;
@@ -380,6 +429,153 @@
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). */
export function setRange(startEpoch: number, endEpoch: number): void {
applyRange(startEpoch, endEpoch);
@@ -451,6 +647,8 @@
for (const p of pictograms) {
if (p.t < viewStart || p.t > viewEnd) continue;
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;
out.push({ x, icon: p.icon });
lastX = x;
@@ -466,6 +664,7 @@
for (const a of windArrows) {
if (a.t < viewStart || a.t > viewEnd) continue;
const x = iconBandX(a.t);
if (x < ICON_EDGE || x > iconBandWidth - ICON_EDGE) continue;
if (x - lastX < 40) continue;
out.push({ x, deg: a.deg });
lastX = x;
@@ -476,8 +675,8 @@
// 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
// wind arrows (which stay closest to the plot).
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * iconRowH + 2);
let windRowTop = $derived(padTop - iconRowH + 2);
// ─── Local minima / maxima (for value labels) ────────────────────────────────
@@ -533,13 +732,25 @@
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 first = Math.ceil(viewStart / HOUR) * HOUR;
let dayCount = 0;
for (let t = first; t <= viewEnd; t += HOUR) {
const date = new Date(t * 1000);
const hour = getZonedHour(date, timezone);
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) {
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 bgColor = cssColor('--card', '#ffffff');
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 plotBottom = padTop + plotH;
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.globalAlpha = 0.65;
for (const band of bands) {
if (band.end < viewStart || band.start > viewEnd) continue;
const x1 = Math.max(padLeft, xPix(band.start));
const x2 = Math.min(plotRight, xPix(band.end));
if (x2 > x1) ctx.fillRect(x1, padTop, x2 - x1, plotH);
}
ctx.globalAlpha = 1;
// Selected-day highlight: soft tint + dashed edge lines
if (highlight && highlight.end > viewStart && highlight.start < viewEnd) {
@@ -636,8 +851,10 @@
ctx.fillText(v.toFixed(tickDecimals(leftScale.step)), padLeft - 8, y);
}
// Right axis labels (only when a unit is provided)
if (hasRightAxis && unitRight !== undefined) {
// Right axis labels in the gutter (desktop only). On mobile there is no
// 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.fillStyle = textColor;
for (
@@ -749,6 +966,63 @@
for (const points of runs) {
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) {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
@@ -771,7 +1045,7 @@
if (lineWidth > 0) {
ctx.lineJoin = '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
// stays legible over any background.
@@ -785,7 +1059,7 @@
}
ctx.lineWidth = lineWidth;
if (s.segmentColor) {
if (s.segmentColor && !s.foregroundLine) {
// Colour each segment by its value (temperature colour scale).
// `idx[i]` maps a run point back to its source data index.
for (let i = 1; i < points.length; i++) {
@@ -796,6 +1070,36 @@
ctx.lineTo(points[i][0], points[i][1]);
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 {
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
@@ -825,7 +1129,12 @@
const x = xPix(t);
const y = yPix(v, axis);
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
const halfW = ctx.measureText(label).width / 2 + 2;
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
if (showNow) {
const now = Date.now() / 1000;
@@ -876,9 +1207,20 @@
ctx.fillText(unit, padLeft - 4, padTop - 8);
}
if (hasRightAxis && unitRight) {
ctx.textAlign = 'left';
ctx.fillStyle = textColor;
ctx.fillText(unitRight, plotRight + 4, padTop - 8);
if (isNarrow) {
// overlaid inside the plot's top-right corner (no right gutter) with a halo
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
@@ -894,15 +1236,8 @@
}
}
// Credit watermark
if (showCredit) {
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;
}
// Credit is a DOM overlay (below) so its two sources can be links; the
// export path redraws it onto the exported canvas in getExportImage().
}
$effect(() => {
@@ -1131,18 +1466,19 @@
<!-- Weather pictograms: a bordered band across the top of the plot -->
{#if visiblePictograms.length > 0}
<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:top="{pictoRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
>
{#each visiblePictograms as p (p.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, p.x))}
<svg
class="absolute top-px fill-foreground"
class="absolute top-1/2 -translate-y-1/2 fill-foreground"
width={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>
</svg>
@@ -1153,21 +1489,22 @@
<!-- Wind-direction arrows: a matching band -->
{#if visibleWindArrows.length > 0}
<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:top="{windRowTop}px"
style:width="{iconBandWidth}px"
style:height="{ICON_BAND_H}px"
>
{#each visibleWindArrows as a (a.x)}
{@const cx = Math.max(ICON_EDGE, Math.min(iconBandWidth - ICON_EDGE, a.x))}
<span
class="absolute top-px inline-flex items-center justify-center"
style:width="{ICON_PX}px"
style:height="{ICON_PX}px"
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
style:transform="rotate({a.deg}deg)"
class="absolute top-1/2 inline-flex items-center justify-center"
style:width="{ARROW_PX}px"
style:height="{ARROW_PX}px"
style:left="{cx - ARROW_PX / 2}px"
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>
</svg>
</span>
@@ -1201,7 +1538,9 @@
<!-- Legend sits below the graph -->
{#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)}
<button
type="button"
@@ -1224,4 +1563,24 @@
{/each}
</div>
{/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>
+6 -1
View File
@@ -5,7 +5,12 @@
* 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 { buildDaylightBands } from './bands';
@@ -101,26 +101,32 @@
<style>
.chart-bleed {
/* Bleed exactly into the page padding on mobile (main has p-5 =
1.25rem) for edge-to-edge charts, and a bit past the content
/* Bleed exactly into the page padding on mobile (main has p-3 =
0.75rem) for edge-to-edge charts, and a bit past the content
column on md+ (main has 2rem padding) for extra readability. */
margin-left: -1.25rem;
margin-right: -1.25rem;
margin-left: -0.75rem;
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-y: hidden;
}
.chart-bleed.no-bleed {
margin-left: 0;
margin-right: 0;
/* content fits the column, so no horizontal scroller is needed */
overflow-x: hidden;
}
.chart-container {
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. */
@media (max-width: 767px) {
@media (max-width: 1023px) {
.chart-container {
min-width: 0;
}
@@ -129,7 +135,7 @@
}
}
@media (min-width: 768px) {
@media (min-width: 1024px) {
.chart-bleed {
margin-left: -1.5rem;
margin-right: -1.5rem;
+5 -65
View File
@@ -19,20 +19,19 @@
</ChartToolbar>
-->
<script module lang="ts">
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
export interface DownloadableChart {
getPngDataUrl(): string | null;
}
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** 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) */
fileName?: string;
/** Optional CSS class for the outer container */
@@ -58,70 +57,11 @@
// ─── 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> {
if (!hasCharts || downloading) return;
downloading = true;
try {
const dataUrls = charts
.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`);
await downloadChartsPng(charts, fileName);
} finally {
setTimeout(() => {
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 ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
+8 -1
View File
@@ -12,6 +12,8 @@
import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte';
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
interface Props {
onMenuToggle?: () => void;
}
@@ -46,6 +48,8 @@
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) {
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) {
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
} else {
goto(resolve('/weather/week/[location]', { location: locationRoute }));
}
@@ -57,7 +61,7 @@
>
<!-- Mobile menu toggle -->
<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}
aria-label="Toggle menu"
>
@@ -105,6 +109,9 @@
<!-- Measurement units -->
<UnitSelector />
<!-- Premium status / unlock -->
<PremiumBadge />
<!-- Theme toggle: system → light → dark -->
<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"
@@ -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'
]
},
{
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',
url: '/weather/maps' as const,
@@ -87,7 +93,7 @@
</div>
{#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
Drizzli
Drizz.li
</span>
{/if}
</a>
@@ -129,7 +135,7 @@
{#if onToggle}
<div class="border-t border-sidebar-border px-2 py-3">
<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}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
+1 -1
View File
@@ -6,7 +6,7 @@
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
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: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
@@ -22,6 +22,26 @@
children: Snippet;
showCloseButton?: boolean;
} = $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>
<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',
className
)}
style="margin-top:{kbShift}px;transition:margin-top 0.18s ease"
{...restProps}
>
{@render children?.()}
+9 -2
View File
@@ -34,6 +34,14 @@
{ value: 'mm', label: 'mm' },
{ value: 'inch', label: 'inch' }
]
},
{
key: 'time_format',
label: 'Time',
options: [
{ value: '24h', label: '24h' },
{ value: '12h', label: 'AM/PM' }
]
}
];
@@ -44,7 +52,7 @@
<Popover.Root>
<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"
title="Units"
>
@@ -63,7 +71,6 @@
/>
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
</svg>
<span class="hidden text-xs font-semibold sm:inline">Units</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-64 border-border">
<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 ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
// ─── Core Helpers ───────────────────────────────────────────────────────────────
@@ -303,6 +304,40 @@ const WEEK_DAILY_VARS = [
'wind_direction_10m_dominant'
] 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.
* Returns typed hourly and daily data structures.
@@ -359,32 +394,8 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const variable = hourlyBlock.variables(i);
byName[name] = variable ? getValues(variable) : [];
});
const g = (name: string): number[] => byName[name] ?? [];
const hourly: WeekHourlyData = {
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')
};
const hourly = weekHourlyFromByName(byName);
// Daily: variables are in the same order as WEEK_DAILY_VARS
const dailyDates = getDates(dailyBlock);
@@ -803,3 +814,273 @@ function modelEnumToString(modelEnum: number): string {
};
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,
humidity: 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: {
temperature: true,
@@ -79,6 +86,9 @@ export const defaultVariablePrefs: VariablePrefs = {
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
* list of variable keys (see the chart variable registry). Users drag
@@ -90,9 +100,9 @@ export interface ChartPanel {
}
export const defaultChartLayout: ChartPanel[] = [
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
{ id: 'panel-3', variables: ['wind', 'humidity'] }
{ id: 'panel-1', variables: ['temperature', 'weather_icons'] },
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
{ id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
];
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
@@ -105,12 +115,15 @@ export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch';
/** Clock format for displayed times: 24-hour or 12-hour AM/PM. */
time_format: '24h' | '12h';
}
export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius',
wind_speed_unit: 'kmh',
precipitation_unit: 'mm'
precipitation_unit: 'mm',
time_format: '24h'
};
export const storedUnits = persisted<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)
* resolve to null.
*/
const modelDomainCandidates: Record<string, string[]> = {
export const modelDomainCandidates: Record<string, string[]> = {
// DWD Germany
icon_seamless: ['dwd_icon_seamless', 'dwd_icon'],
icon_global: ['dwd_icon'],
+4 -3
View File
@@ -83,13 +83,14 @@
<Header onMenuToggle={toggleMobileMenu} />
<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}
{@render children()}
{:else}
<!-- cap the content width on very large screens -->
<div class="mx-auto w-full max-w-[1536px]">
<!-- cap the content width on very large screens; generous bottom room
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()}
</div>
{/if}
+1 -1
View File
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
render(Page);
const title = document.querySelector('title');
expect(title?.textContent).toBe('Drizzli');
expect(title?.textContent).toBe('Drizz.li');
});
});
@@ -46,14 +46,7 @@
let params = $state({
...defaultParameters,
hourly: [
'temperature_2m',
'precipitation',
'wind_speed_10m',
'relative_humidity_2m',
'cloud_cover',
'pressure_msl'
],
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
models: ['ncep_gefs_seamless']
});
@@ -155,9 +148,9 @@
);
// Surface a note when the chosen model's ensemble stops short of the request.
let fullHours = $derived(fetchedData?.timestamps.length ?? 0);
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
let isTrimmed = $derived(!!fetchedData && validLength > 0 && validLength < fullHours - 1);
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
interface ChartDef {
title?: string;
@@ -165,6 +158,21 @@
unit: string;
showCredit: boolean;
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)';
@@ -202,34 +210,41 @@
const isColumn = isColumnUnit(unit);
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
const series: ChartSeries[] = [
{
name: 'Max',
type: 'line',
color: BAND_COLOR,
data: varData.max,
data: vMax,
width: 1,
fill: true,
fillOpacity: 0.25,
bandTo: varData.min,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Min',
type: 'line',
color: BAND_COLOR,
data: varData.min,
width: 1,
bandTo: vMin,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
name: 'Mean',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: varData.average,
width: 3,
data: vAvg,
width: 3.5,
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}`
}
];
@@ -237,6 +252,7 @@
const isFirst = vi === 0;
const isLast = vi === variables.length - 1;
const axis = axisForVar(variable);
defs.push({
// each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
@@ -245,7 +261,10 @@
: `min · mean · max (${unit})`,
unit,
showCredit: isLast,
series
series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
});
}
@@ -255,7 +274,7 @@
<!-- ─── 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">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -275,7 +294,7 @@
</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
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
groups={ensembleModelGroups}
@@ -308,8 +327,8 @@
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead the spread
is trimmed to its available range.
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range.
</span>
</div>
{/if}
@@ -322,26 +341,42 @@
</div>
{/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)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
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}
{/if}
</ChartContainer>
</div>
{: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 ───────────────────────────────────────── -->
@@ -5,6 +5,8 @@
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
@@ -17,6 +19,7 @@
SERIES_COLORS,
calculateAverage,
findUnit,
groupRange,
isColumnUnit
} from '$lib/charts';
import {
@@ -59,7 +62,7 @@
let params = $state({
...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']
});
@@ -99,6 +102,40 @@
// components persist across refetches; entries are null while unmounted
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) ───────
$effect(() => {
@@ -161,6 +198,24 @@
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[] => {
if (!fetchedData) return [];
@@ -202,17 +257,19 @@
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn
dashed: !isColumn,
outline: !isColumn
});
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
defs.push({
title: isFirst ? 'Model Compare' : undefined,
// label every chart with its variable so each is identifiable
title: varLabel(variable),
subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
: undefined,
? `${params.models?.length ?? 0} models · dashed = average`
: `across ${params.models?.length ?? 0} models`,
unit,
showCredit: isLast,
series
@@ -223,6 +280,73 @@
});
</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 ─────────────────────────────────────────────────────────── -->
{#if loadError}
@@ -235,30 +359,44 @@
<!-- chart count derives from the selected variables (not the fetched data),
so the reserved height is right even before the response arrives -->
<ChartContainer
{loading}
chartCount={params.hourly?.filter((v) => v !== 'weather_code').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)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
<div class="mb-1 px-3 lg:px-0">
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
{#if def.subtitle}
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
{/if}
</div>
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
</ChartContainer>
</div>
{/each}
{/if}
</ChartContainer>
</div>
{: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}
<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">
import { onMount } from 'svelte';
import { type Component, onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import {
storedChartLayout,
@@ -17,6 +18,7 @@
import { defaultParameters } from '../../options';
import DailyCards from './DailyCards.svelte';
import DailyStripSticky from './DailyStripSticky.svelte';
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
@@ -69,6 +71,19 @@
let loadError = $state<string | null>(null);
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)
let forecastDays = $state(7);
// 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)
params.models = [get(storedModel)];
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(() => {
@@ -144,7 +173,7 @@
</script>
<svelte:head>
<title>Drizzli | Weather</title>
<title>Drizz.li | Weather</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head>
@@ -152,7 +181,7 @@
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- 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">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -174,7 +203,7 @@
</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
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
@@ -198,38 +227,95 @@
</div>
{/if}
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{#if fetchedHourly && fetchedDaily}
<HourlyTable
data={fetchedHourly}
<!-- Desktop: the full day cards -->
<div class="hidden md:block">
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{:else}
<!-- 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>
{/if}
</div>
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
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}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- 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} />
</section>
{/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>
@@ -35,22 +35,32 @@
// of view — the user reveals it by scrolling left. Re-hide only when the
// dataset (location) changes, not on every re-render.
let scrollEl = $state<HTMLDivElement>();
let pastBtnEl = $state<HTMLButtonElement>();
let cardsWrapEl = $state<HTMLDivElement>();
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(() => {
const d = daily;
if (!d || !canExtendPast || !scrollEl || !pastBtnEl || hiddenForRef === d) return;
if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
hiddenForRef = d;
const el = scrollEl;
const btn = pastBtnEl;
const wrap = cardsWrapEl;
// defer to after layout so the measured positions and scroll width are final
requestAnimationFrame(() => {
const b = btn.getBoundingClientRect();
const c = el.getBoundingClientRect();
// scroll so the button's right edge sits just past the left edge (a small
// gap of extra margin keeps the first day card from hugging the edge)
el.scrollLeft += b.right - c.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;
});
});
@@ -111,21 +121,25 @@
</defs>
</svg>
<div in:fade out:fade class="mb-6 min-h-[260px]">
<!-- negative margin + matching padding: the scroll box gains room so a
lifted/scaled/shadowed card is never clipped, while the first card still
lines up with the page content edge -->
<div
transition:fade={{ duration: 200 }}
class="-mx-3 mb-1 flex min-h-47.5 items-stretch gap-2 px-3 md:mb-6 md:min-h-65"
>
<!-- 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
bind:this={scrollEl}
class="-mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
style="scrollbar-width: thin"
class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
class:scrolling
onscroll={onScroll}
>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
bind:this={pastBtnEl}
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}
aria-label="Load recent past days"
>
@@ -148,189 +162,271 @@
</span>
</button>
{/if}
{#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 sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = getDaylightSeconds(index)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@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
<!-- the cards fill at least the viewport so the row overflows past the
"past days" button, letting it scroll out of view even on wide
screens -->
<div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
{#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 sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = getDaylightSeconds(index)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@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
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
: '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}
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'}"
? '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'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
{getRelativeDayLabel(time, daily.timezone)}
</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">
<!-- Day label -->
<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}"
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
>
{tempMax.toFixed(0)}°
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
</div>
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
<!-- 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>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
<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
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>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<span
class="inline-flex items-center gap-0.5 {lowPrecip
? '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>
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.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'
: ''}"
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<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
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
<span
class="inline-flex items-center gap-0.5 {lowPrecip
? '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>
{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
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
class="shrink-0 -mr-2 {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>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
</span>
{:else}
<svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</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>
{/if}
{/each}
{#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}
</div>
{/if}
</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>
<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. */
@media (max-width: 768px) {
.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">
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 { setGroupHover } from '$lib/charts';
import { groupHover, setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
@@ -51,11 +57,15 @@
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
// 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 tempUnit = $derived(getTempUnit(units));
@@ -150,6 +160,67 @@
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 {
const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
@@ -209,6 +280,83 @@
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
: 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>
{#snippet weatherIcon(name: string, size: number = 16)}
@@ -236,10 +384,11 @@
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@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+ -->
<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 -->
<div
@@ -289,7 +438,7 @@
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
>
{interval}h
</button>
@@ -300,7 +449,7 @@
<!-- Below the min-width the table scrolls sideways instead of squeezing;
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
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
bind:clientWidth={tableWidth}
@@ -321,7 +470,7 @@
</th>
<td
colspan={cellData.length}
class="relative h-11 overflow-visible p-0"
class="relative h-12 cursor-default overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
@@ -437,7 +586,7 @@
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell leading-0 {is3h ? 'h-14' : 'h-11'}"
class="cell h-12 leading-0"
class:icon-day={cell.isDaytime}
class:icon-night={!cell.isDaytime}
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
@@ -459,7 +608,7 @@
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<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}"
>
{formatTemp(temp)}
@@ -468,13 +617,30 @@
</tr>
{/if}
<!-- Feels Like -->
<!-- Feels Like (short row) -->
{#if showRow('feels')}
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (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)}
</td>
{/each}
@@ -488,16 +654,16 @@
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_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)}
<span
class="inline-block leading-0"
style="transform:{getWindArrowRotation(windDir)}"
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 22)}
{@render weatherIcon('wi-direction-down', 40)}
</span>
{/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)}
</span>
</td>
@@ -505,13 +671,30 @@
</tr>
{/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')}
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
{#each cellData as cell (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)}
</td>
{/each}
@@ -525,7 +708,7 @@
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-8"
class="cell h-12"
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
@@ -534,6 +717,51 @@
</tr>
{/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 -->
{#if showRow('precipitation')}
<tr class="row">
@@ -542,7 +770,7 @@
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell {is3h ? 'h-14' : 'h-11'}"
class="cell precip-cell h-12"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
@@ -556,9 +784,31 @@
{/each}
</tr>
{/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>
</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 -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
@@ -3,9 +3,9 @@
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';
@@ -29,6 +29,19 @@
const CHART_HEIGHT = 300;
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.
let chartComponents: (CanvasChart | null)[] = $state([]);
@@ -78,13 +91,18 @@
onResetZoom?.();
}
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ 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() }
];
const now = new Date();
// "Selected day" is redundant while today is the selected day, so hide it then
let rangePresets = $derived.by(() => {
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
return [
{ 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
let selectedDayHighlight = $derived.by(() => {
@@ -153,7 +171,7 @@
);
</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">
<h3 class="text-lg font-bold">
Meteograms
@@ -174,25 +192,6 @@
>
+ 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"
@@ -224,6 +223,61 @@
</svg>
Customize
</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>
@@ -231,22 +285,25 @@
<div
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"
onclick={() => (customizerOpen = true)}>add some variables</button
>.
</div>
{: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)}
<!-- full-bleed to the screen edges on mobile; a contained card on md+ -->
<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">
<h4 class="truncate text-sm font-bold text-muted-foreground">
<span class="hidden md:inline">{panel.title}</span>
<span class="md:hidden">{panel.titleShort}</span>
<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
@@ -282,10 +339,6 @@
</div>
{/each}
</div>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
{/if}
</section>
@@ -42,13 +42,19 @@
>
<Select.Trigger
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
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 -->
<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" />
<path
stroke-linecap="round"
@@ -60,9 +66,14 @@
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{label}
</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}
<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}
</span>
{/if}
@@ -17,10 +17,16 @@
{ key: 'icons', label: 'Weather icons' },
{ key: 'temperature', label: 'Temperature' },
{ key: 'feels', label: 'Feels like' },
{ key: 'dew_point', label: 'Dew point' },
{ key: 'wind', label: 'Wind' },
{ key: 'gusts', label: 'Wind gusts' },
{ key: 'humidity', label: 'Humidity' },
{ 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) {
@@ -4,6 +4,8 @@
* (data field, render style, colour, unit family) so the panels can be
* assembled dynamically from a user-defined layout.
*/
import { defaultVariablePrefs } from '$lib/stores/settings';
import { getColor } from '../../utils/colors';
import {
type WeatherUnits,
@@ -36,9 +38,14 @@ export interface ChartVariableDef {
dashed?: boolean;
fill?: boolean;
fillOpacity?: number;
/** Area fill coloured by the value scale, fading out below the minimum */
gradientFill?: boolean;
width?: number;
/** Stroke the line coloured by the temperature scale */
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 */
outline?: boolean;
/** Annotate local minima / maxima with their value */
@@ -66,9 +73,10 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
type: 'line',
kind: 'temp',
color: '#ef6c00',
width: 9,
width: 5.6,
colorScale: true,
outline: true,
foregroundLine: true,
gradientFill: true,
extrema: true
},
{
@@ -201,8 +209,19 @@ export const CHART_VARIABLES: ChartVariableDef[] = [
color: '#26a69a',
width: 2,
fill: true,
fillOpacity: 0.15,
windArrows: true
fillOpacity: 0.15
},
{
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',
@@ -301,23 +320,29 @@ export function neededHourlyApiVars(
tablePrefs: Record<string, boolean> | undefined,
layoutKeys: 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>();
// Hourly table rows
if (on('icons')) s.add('weather_code');
if (on('temperature')) s.add('temperature_2m');
if (on('feels')) s.add('apparent_temperature');
if (on('dew_point')) s.add('dew_point_2m');
if (on('wind')) {
s.add('wind_speed_10m');
s.add('wind_direction_10m');
}
if (on('gusts')) s.add('wind_gusts_10m');
if (on('humidity')) s.add('relative_humidity_2m');
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')) {
s.add('precipitation');
s.add('precipitation_probability');
}
if (on('snowfall')) s.add('snowfall');
// Meteogram variables
for (const key of layoutKeys) {
@@ -325,7 +350,7 @@ export function neededHourlyApiVars(
if (!def) continue;
s.add(apiNameOf(def));
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];
@@ -443,10 +468,12 @@ export function buildPanelDef(
width: d.width,
fill: d.fill,
fillOpacity: d.fillOpacity,
gradientFill: d.gradientFill,
dashed: d.dashed,
axis,
cloudBand: d.cloudBand,
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
foregroundLine: d.foregroundLine,
outline: d.outline,
labelExtrema: d.extrema,
labelFormat: d.extrema
@@ -469,8 +496,9 @@ export function buildPanelDef(
unit: leftKind ? unitForKind(leftKind, units) : '',
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
// pressure sits far from zero, so its axis is derived from the data
zeroBaseLeft: leftKind !== 'pressure',
// temperature and pressure sit far from zero, so their axis is derived from
// the data range (a forced 0 baseline just wastes vertical space)
zeroBaseLeft: leftKind ? isZeroBased(leftKind) : true,
yMinRight: rightKind && rightZero ? 0 : undefined,
yMaxRight: rightKind === 'percent' ? 100 : undefined,
hasPictograms: allDefs.some((d) => d.pictograms),
+14
View File
@@ -32,6 +32,20 @@ const viteServerConfig = (): Plugin => ({
export default defineConfig({
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: {
expect: { requireAssertions: true },