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
45 changed files with 3870 additions and 121 deletions
View File
View File
View File
View File
View File
View File
View File
View File
View File
+3
View File
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
AGENTS.md
# Local agent/editor config
.claude/
+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"
}
}
@@ -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 }));
}
@@ -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,
+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?.()}
+8
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' }
]
}
];
+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'
};
}
+4 -1
View File
@@ -115,12 +115,15 @@ export interface UnitPrefs {
temperature_unit: 'celsius' | 'fahrenheit';
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
precipitation_unit: 'mm' | 'inch';
/** Clock format for displayed times: 24-hour or 12-hour AM/PM. */
time_format: '24h' | '12h';
}
export const defaultUnits: UnitPrefs = {
temperature_unit: 'celsius',
wind_speed_unit: 'kmh',
precipitation_unit: 'mm'
precipitation_unit: 'mm',
time_format: '24h'
};
export const storedUnits = persisted<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'],
@@ -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']
});
@@ -62,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']
});
@@ -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>
+107 -25
View File
@@ -1,5 +1,5 @@
<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';
@@ -18,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';
@@ -70,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
@@ -90,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(() => {
@@ -153,7 +181,7 @@
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- Page hero: prominent location + weather model selection -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="relative flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
@@ -199,33 +227,55 @@
</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
transition:fade={{ duration: 200 }}
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} />
@@ -235,5 +285,37 @@
<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>
@@ -57,8 +57,10 @@
// defer to after layout so the measured positions and scroll width are final
requestAnimationFrame(() => {
// 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
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left;
// with the page hero), leaving the "past days" button fully off to the
// left. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
// so subtract it here to land the card on the content edge.
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
});
});
@@ -129,7 +131,7 @@
clipped, while the first card still lines up with the page content edge. -->
<div
bind:this={scrollEl}
class="day-scroll flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 md:pt-5 md:pb-11"
class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
class:scrolling
onscroll={onScroll}
>
@@ -0,0 +1,314 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits } from './types';
interface Props {
daily: FetchedDaily | null;
selectedDay: Date;
units: WeatherUnits;
onSelectDay: (date: Date, index: number) => void;
canExtend?: boolean;
onExtend?: () => void;
canExtendPast?: boolean;
onExtendPast?: () => void;
}
let {
daily,
selectedDay,
units,
onSelectDay,
canExtend = false,
onExtend,
canExtendPast = false,
onExtendPast
}: Props = $props();
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
// (which reflows the table BELOW it) never feeds back into the measurement.
let progress = $state(0);
let sentinelEl = $state<HTMLDivElement>();
let stripScrollEl = $state<HTMLDivElement>();
let daysWrapEl = $state<HTMLDivElement>();
// Resolved in onMount (client only) so this never touches `window` during the
// prerender of city pages.
let scrollParent: HTMLElement | Window | null = $state(null);
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
let node = el?.parentElement ?? null;
while (node) {
const oy = getComputedStyle(node).overflowY;
if (oy === 'auto' || oy === 'scroll') return node;
node = node.parentElement;
}
return window;
}
// Distance (px) over which the collapse plays out once the strip sticks.
const RANGE = 120;
let ticking = false;
function measure() {
ticking = false;
if (!sentinelEl || !scrollParent) return;
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
const top = sentinelEl.getBoundingClientRect().top - topRef;
progress = clamp(-top / RANGE);
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(measure);
}
onMount(() => {
scrollParent = findScrollParent(sentinelEl ?? null);
const target: EventTarget = scrollParent;
target.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
measure();
return () => {
target.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
});
// Re-measure once data (and therefore the strip height) changes.
$effect(() => {
void daily;
tick().then(measure);
});
// Start scrolled so the "Past" button sits just off the left edge (revealed by
// scrolling left), with the first day flush to the content edge — same as the
// desktop cards. Runs once per dataset.
let scrolledForRef: FetchedDaily | null = null;
$effect(() => {
const d = daily;
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
scrolledForRef = d;
const scroll = stripScrollEl;
const wrap = daysWrapEl;
requestAnimationFrame(() => {
// 12px = the strip's px-3 left padding, so the first day lands on the edge
scroll.scrollLeft +=
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
});
});
// All progress-driven sizing lives in CSS (via the single `--p` custom property
// set on the strip), so a scroll frame writes ONE value instead of re-patching
// height/padding/opacity inline styles on every cell.
</script>
<!-- progress sentinel: 0-height marker just above the sticky strip -->
<div bind:this={sentinelEl} aria-hidden="true"></div>
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
{#if daily}
{#if canExtendPast && onExtendPast}
<button
type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
onclick={onExtendPast}
aria-label="Load recent past days"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
</svg>
<span class="text-[9px] leading-tight font-semibold">Past</span>
</button>
{/if}
<div class="flex gap-1.5" bind:this={daysWrapEl}>
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
type="button"
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
: 'border-border/60 bg-card'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<span
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
{getRelativeDayLabel(time, daily.timezone)}
</span>
<div class="relative">
<svg class="day-icon fill-foreground">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<!-- night companion icon, present in the full view, fades as it collapses -->
<svg
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<div class="flex items-baseline gap-1 leading-none">
<span
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
<div
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
>
<span class="inline-flex items-center gap-1">
<svg class="fill-sky-500" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
</span>
<span class="inline-flex items-center gap-1">
<svg class="fill-muted-foreground" width="13" height="13">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{windMax?.toFixed(0) ?? '-'}
</span>
</div>
</button>
{/if}
{/each}
{#if canExtend && onExtend}
<button
type="button"
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
</svg>
<span class="text-[9px] leading-tight font-semibold">15d</span>
</button>
{/if}
</div>
{/if}
</div>
</div>
<style>
/* Everything below is derived from a single custom property `--p` (0 = full
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
browser resolves the calc()s natively, so a scroll frame is one property
write instead of dozens of inline-style patches across every cell. */
.daystrip {
/* tunables */
--cell-w: 64px;
--cell-h-full: 134px;
--cell-h-min: 64px;
--icon-full: 40px;
--icon-min: 20px;
--pt-full: 6px;
--pt-min: 1px;
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
--k: calc(1 - var(--p));
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
/* bar background/divider fade in once it starts collapsing */
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
/* Opaque-background fade only — no backdrop-filter blur or animated
box-shadow (both are very expensive to repaint every scroll frame on
mobile). Promote to its own compositor layer so repaints stay isolated. */
background: color-mix(
in oklab,
var(--color-background) calc(var(--chrome) * 100%),
transparent
);
border-bottom: 1px solid
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
transform: translateZ(0);
}
.strip-cell,
.strip-side {
width: var(--cell-w);
height: var(--cell-h);
/* size tracks scroll exactly (no transition); only the tap highlight eases */
transition:
border-color 0.15s,
background-color 0.15s;
}
.strip-cell {
padding-top: var(--pt);
}
.day-icon {
width: var(--icon);
height: var(--icon);
}
.night-badge {
width: calc(var(--icon) * 0.44);
height: calc(var(--icon) * 0.44);
opacity: var(--rel);
}
.rel-label {
opacity: var(--rel);
max-height: calc(14px * var(--rel));
}
.detail-row {
opacity: var(--detail);
max-height: calc(42px * var(--detail));
}
.daystrip :global(.overflow-x-auto) {
scrollbar-width: none;
}
</style>
@@ -281,32 +281,66 @@
: null
);
// ─── Start the scroll at 06:00 on day change (mobile) ───────────────────────
// When the table overflows sideways, scroll so the 06:00 column is the leftmost
// visible one — the early hours are rarely of interest and this keeps the
// daytime in view on every day switch.
// ─── 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(); // re-run on day switch
const day = selectedDay.getTime();
const interval = is3h ? 3 : 1;
const el = tableScrollEl;
if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return;
if (autoScrolledDay === day) return;
const dayChanged = autoScrolledDay !== day;
const intervalChanged = lastInterval !== interval;
if (!dayChanged && !intervalChanged) return;
autoScrolledDay = day;
requestAnimationFrame(() => {
// only when it actually overflows (i.e. mobile / narrow)
if (el.scrollWidth <= el.clientWidth + 4) {
el.scrollLeft = 0;
return;
}
const sixIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6);
if (sixIdx <= 0) {
el.scrollLeft = 0;
return;
}
const colWidth = (tableWidth - headerColWidth) / cellData.length;
el.scrollLeft = Math.max(0, sixIdx * colWidth);
});
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 ─────────────────────────────────────────────────────
@@ -415,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" bind:this={tableScrollEl}>
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
<div
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
bind:clientWidth={tableWidth}
@@ -436,7 +470,7 @@
</th>
<td
colspan={cellData.length}
class="relative h-12 overflow-visible p-0"
class="relative h-12 cursor-default overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
@@ -3,7 +3,7 @@
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
@@ -91,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(() => {
@@ -187,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"
@@ -271,6 +257,27 @@
{/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>
@@ -42,13 +42,19 @@
>
<Select.Trigger
aria-label="{label} selection"
class="group h-auto min-h-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
>
<div
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}
@@ -496,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 },