Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ced7fff8e5 | ||
|
|
6b2db975b3 | ||
|
|
d37c95a46d | ||
|
|
cc1589b017 | ||
|
|
61d84e4450 | ||
|
|
f8d7c7699f | ||
|
|
1d7d9d3a53 | ||
|
|
c7924bb0ae | ||
|
|
cd98b7a5cc | ||
|
|
d78ac84a12 | ||
|
|
562d545c7a | ||
|
|
b4e509f9cb | ||
|
|
bac4759662 | ||
|
|
af9bf42d05 | ||
|
|
54dbaeaadc | ||
|
|
faee466286 | ||
|
|
f688b7dbb5 | ||
|
|
e258c70670 | ||
|
|
cd1a47b6c3 | ||
|
|
baa4840b38 | ||
|
|
e031716ce6 | ||
|
|
6d81af8df5 | ||
|
|
9e1944396e | ||
|
|
0526d71716 | ||
|
|
d8d220a6a9 | ||
|
|
8c0b80f041 | ||
|
|
0355d586f5 | ||
|
|
65fe6d1601 | ||
|
|
bed6e3aa53 | ||
|
|
84eefd538b | ||
|
|
9a7e66d5d9 | ||
|
|
53e5fb6538 | ||
|
|
4e3ccf1c06 | ||
|
|
6b48dd6764 | ||
|
|
b99750ef77 | ||
|
|
af433c92ff | ||
|
|
c2e90f5919 | ||
|
|
f781ef4fd6 | ||
|
|
2d31df88f0 |
@@ -23,3 +23,6 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
# Local agent/editor config
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# OMbrella
|
# Drizz.li
|
||||||
|
|
||||||
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
|
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Our objective is to provide a comprehensive, user-friendly weather platform for
|
|||||||
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
||||||
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
|
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
|
||||||
- **Visualization**: [Apache Echarts](https://echarts.apache.org)
|
- **Visualization**: custom canvas charts (`src/lib/charts`)
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
@@ -33,3 +33,61 @@ npm run build
|
|||||||
```
|
```
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
## Deployment (static hosting)
|
||||||
|
|
||||||
|
The build output in `build/` is a fully static site. Two pieces of server
|
||||||
|
configuration are needed:
|
||||||
|
|
||||||
|
### 1. SPA fallback
|
||||||
|
|
||||||
|
Pages that are not prerendered (unlisted cities, GPS coordinate routes like
|
||||||
|
`/weather/week/52.09N5.12E/`) are served by `404.html`, which boots the app
|
||||||
|
and resolves the location client-side. Configure the server to serve
|
||||||
|
`404.html` for unknown paths.
|
||||||
|
|
||||||
|
### 2. Cross-origin isolation (SharedArrayBuffer for the embedded map)
|
||||||
|
|
||||||
|
The `/weather/maps/` page embeds `maps.open-meteo.com`, which uses
|
||||||
|
`SharedArrayBuffer` for its decoding worker pool. A cross-origin iframe only
|
||||||
|
gets `SharedArrayBuffer` when the **embedding** page is cross-origin
|
||||||
|
isolated, so this site must be served with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
|
Cross-Origin-Embedder-Policy: require-corp
|
||||||
|
```
|
||||||
|
|
||||||
|
(The map already serves `Cross-Origin-Resource-Policy: cross-origin` and its
|
||||||
|
own COOP/COEP, so it is embeddable under these headers. All other assets are
|
||||||
|
same-origin and the weather APIs are CORS requests, so `require-corp` is safe
|
||||||
|
here.)
|
||||||
|
|
||||||
|
### Example: Caddy
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
drizzli.example.com {
|
||||||
|
root * /srv/drizzli
|
||||||
|
file_server
|
||||||
|
try_files {path} {path}/ /404.html
|
||||||
|
header {
|
||||||
|
Cross-Origin-Opener-Policy "same-origin"
|
||||||
|
Cross-Origin-Embedder-Policy "require-corp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example: nginx
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name drizzli.example.com;
|
||||||
|
root /srv/drizzli;
|
||||||
|
error_page 404 /404.html;
|
||||||
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
|
add_header Cross-Origin-Embedder-Policy "require-corp" always;
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
Generated
+1410
-1750
File diff suppressed because it is too large
Load Diff
+38
-36
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "open-meteo-weather",
|
"name": "drizzli",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -17,43 +17,45 @@
|
|||||||
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label popover select separator switch -y -o && prettier --write src/lib/components/ui"
|
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label popover select separator switch -y -o && prettier --write src/lib/components/ui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^2.1.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@internationalized/date": "^3.10.1",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@lucide/svelte": "^0.561.0",
|
"@lucide/svelte": "^1.25.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
"@sveltejs/kit": "^2.70.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^24",
|
"@types/node": "^26",
|
||||||
"@vitest/browser-playwright": "^4.0.15",
|
"@vitest/browser-playwright": "^4.1.10",
|
||||||
"bits-ui": "^2.15.4",
|
"bits-ui": "^2.18.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.39.1",
|
"date-fns": "^4.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"eslint-plugin-svelte": "^3.13.1",
|
|
||||||
"globals": "^16.5.0",
|
|
||||||
"playwright": "^1.57.0",
|
|
||||||
"prettier": "^3.7.4",
|
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
||||||
"svelte": "^5.45.6",
|
|
||||||
"svelte-check": "^4.3.4",
|
|
||||||
"svelte-persisted-store": "^0.12.0",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"tailwind-variants": "^3.2.2",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"typescript-eslint": "^8.48.1",
|
|
||||||
"vite": "^7.2.6",
|
|
||||||
"vitest": "^4.0.15",
|
|
||||||
"vitest-browser-svelte": "^2.0.1",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
"echarts": "^6.0.0",
|
"eslint": "^10.7.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"openmeteo": "^1.2.3"
|
"eslint-plugin-svelte": "^3.21.0",
|
||||||
|
"globals": "^17.7.0",
|
||||||
|
"openmeteo": "^1.2.3",
|
||||||
|
"playwright": "^1.61.1",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"prettier-plugin-svelte": "^3.5.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||||
|
"svelte": "^5.56.6",
|
||||||
|
"svelte-check": "^4.7.3",
|
||||||
|
"svelte-persisted-store": "^0.12.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"typescript-eslint": "^8.64.0",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
// apply the persisted theme before first paint to avoid a flash
|
||||||
|
try {
|
||||||
|
var theme = JSON.parse(localStorage.getItem('theme') || '"system"');
|
||||||
|
if (
|
||||||
|
theme === 'dark' ||
|
||||||
|
(theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('sum test', () => {
|
|
||||||
it('adds 1 + 2 to equal 3', () => {
|
|
||||||
expect(1 + 2).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1 +1,46 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||||
|
<title></title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#e0f2fe" />
|
||||||
|
<stop offset="1" stop-color="#bae6fd" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="canopy" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#fb923c" />
|
||||||
|
<stop offset="1" stop-color="#ea580c" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="drop" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#38bdf8" />
|
||||||
|
<stop offset="1" stop-color="#2563eb" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#sky)" />
|
||||||
|
<!-- raindrops falling onto the umbrella -->
|
||||||
|
<path d="M12 5c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
|
||||||
|
<path d="M53 4c2 2.9 3 4.6 3 6.2a3 3 0 0 1-6 0c0-1.6 1-3.3 3-6.2Z" fill="url(#drop)" />
|
||||||
|
<path d="M23 2.5c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
|
||||||
|
<path d="M45 12c1.7 2.4 2.5 3.9 2.5 5.2a2.5 2.5 0 0 1-5 0c0-1.3.8-2.8 2.5-5.2Z" fill="url(#drop)" />
|
||||||
|
<!-- pole with curved handle -->
|
||||||
|
<path
|
||||||
|
d="M33 35v17a4.5 4.5 0 0 1-9 0"
|
||||||
|
fill="none"
|
||||||
|
stroke="#475569"
|
||||||
|
stroke-width="3.25"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<!-- canopy tip -->
|
||||||
|
<path d="M33 13.5v4" fill="none" stroke="#475569" stroke-width="3" stroke-linecap="round" />
|
||||||
|
<!-- canopy with scalloped edge -->
|
||||||
|
<path
|
||||||
|
d="M11 37c0-11.6 9.8-21 22-21s22 9.4 22 21c-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.4 0-1.8-2.9-5.5-2.9-7.3 0-1.9-2.9-5.5-2.9-7.3 0Z"
|
||||||
|
fill="url(#canopy)"
|
||||||
|
/>
|
||||||
|
<!-- ribs -->
|
||||||
|
<path
|
||||||
|
d="M33 16.5c-5.2 3-7.4 11-7.3 19M33 16.5c5.2 3 7.4 11 7.3 19"
|
||||||
|
fill="none"
|
||||||
|
stroke="#9a3412"
|
||||||
|
stroke-width="1.5"
|
||||||
|
opacity="0.35"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.8 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Daylight Bands
|
||||||
|
*
|
||||||
|
* Converts sunrise/sunset timestamp arrays into neutral background band
|
||||||
|
* descriptors that CanvasChart renders as shaded daylight areas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A background band on the time axis, expressed in epoch seconds. */
|
||||||
|
export interface DaylightBand {
|
||||||
|
/** Band start (epoch seconds) */
|
||||||
|
start: number;
|
||||||
|
/** Band end (epoch seconds) */
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds daylight bands from sunrise/sunset arrays.
|
||||||
|
*
|
||||||
|
* @param sunrise - Array of sunrise timestamps (unix seconds)
|
||||||
|
* @param sunset - Array of sunset timestamps (unix seconds)
|
||||||
|
*/
|
||||||
|
export function buildDaylightBands(sunrise: number[], sunset: number[]): DaylightBand[] {
|
||||||
|
return sunrise.map((r, i) => ({ start: r, end: sunset[i] }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Chart Data Helpers
|
||||||
|
*
|
||||||
|
* Shared color palette and data-processing helpers used by the chart pages.
|
||||||
|
* Ported from the previous ECharts utilities so the visual identity and
|
||||||
|
* calculations stay identical.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Color Palette ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default series color palette matching the application's design system */
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'#5470c6',
|
||||||
|
'#91cc75',
|
||||||
|
'#fac858',
|
||||||
|
'#ee6666',
|
||||||
|
'#73c0de',
|
||||||
|
'#3ba272',
|
||||||
|
'#fc8452',
|
||||||
|
'#9a60b4',
|
||||||
|
'#ea7ccc',
|
||||||
|
'#4dc9f6'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Semantic colors used for specific chart elements */
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
average: '#5e5e5e',
|
||||||
|
currentTimeLine: '#ef4444',
|
||||||
|
daylight: 'rgba(255, 255, 194, 0.3)',
|
||||||
|
memberLine: 'rgba(115, 192, 222, 0.45)'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Utility: Detect column-type variables ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Units that should be rendered as bar/column charts instead of lines. */
|
||||||
|
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given unit should be rendered as a bar chart.
|
||||||
|
*/
|
||||||
|
export function isColumnUnit(unit: string): boolean {
|
||||||
|
return COLUMN_UNITS.has(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AverageResult {
|
||||||
|
average: number[];
|
||||||
|
averageCount: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates per-timestep average and count from hourly model data.
|
||||||
|
*
|
||||||
|
* @param hourlyData - The `data.hourly` object from the API response
|
||||||
|
* @param variable - The variable prefix to filter on (e.g. 'temperature_2m')
|
||||||
|
* @param timeLength - Number of timesteps
|
||||||
|
* @returns Object containing running average and count arrays
|
||||||
|
*/
|
||||||
|
export function calculateAverage(
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string,
|
||||||
|
timeLength: number
|
||||||
|
): AverageResult {
|
||||||
|
const average = new Array<number>(timeLength).fill(0);
|
||||||
|
const averageCount = new Array<number>(timeLength).fill(0);
|
||||||
|
|
||||||
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
|
for (const [index, val] of (values as number[]).entries()) {
|
||||||
|
if (val !== null && val !== undefined && isFinite(val)) {
|
||||||
|
average[index] += val;
|
||||||
|
averageCount[index]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize average values
|
||||||
|
for (let i = 0; i < timeLength; i++) {
|
||||||
|
if (averageCount[i] > 0) {
|
||||||
|
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { average, averageCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the unit string for a given variable from the hourly_units map.
|
||||||
|
* Returns an empty string if the variable is not found.
|
||||||
|
*/
|
||||||
|
export function findUnit(
|
||||||
|
hourlyUnits: Record<string, string>,
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string
|
||||||
|
): string {
|
||||||
|
for (const model of Object.keys(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (model.startsWith(variable) && hourlyUnits[model]) {
|
||||||
|
return hourlyUnits[model];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Canvas Charts — Barrel Export
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
default as CanvasChart,
|
||||||
|
setGroupHover,
|
||||||
|
groupRange,
|
||||||
|
groupHover
|
||||||
|
} from './CanvasChart.svelte';
|
||||||
|
export type { ChartSeries } from './CanvasChart.svelte';
|
||||||
|
|
||||||
|
export { buildDaylightBands } from './bands';
|
||||||
|
export type { DaylightBand } from './bands';
|
||||||
|
|
||||||
|
export { CHART_COLORS, SERIES_COLORS, calculateAverage, findUnit, isColumnUnit } from './data';
|
||||||
|
export type { AverageResult } from './data';
|
||||||
@@ -11,12 +11,13 @@
|
|||||||
Usage:
|
Usage:
|
||||||
<ChartContainer loading={!chartsReady} chartCount={3}>
|
<ChartContainer loading={!chartsReady} chartCount={3}>
|
||||||
{#each charts as chart}
|
{#each charts as chart}
|
||||||
<EChart option={chart.option} />
|
<CanvasChart {...chart} />
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
@@ -30,6 +31,10 @@
|
|||||||
chartHeight?: number;
|
chartHeight?: number;
|
||||||
/** Extra vertical padding in pixels added to the total min-height (default: 2) */
|
/** Extra vertical padding in pixels added to the total min-height (default: 2) */
|
||||||
extraPadding?: number;
|
extraPadding?: number;
|
||||||
|
/** Minimum chart width in pixels; narrower viewports scroll sideways (default: 560) */
|
||||||
|
minWidth?: number;
|
||||||
|
/** Bleed the chart into the page gutters (edge-to-edge). Off when nested in a card. */
|
||||||
|
bleed?: boolean;
|
||||||
/** Optional CSS class for the outer wrapper */
|
/** Optional CSS class for the outer wrapper */
|
||||||
class?: string;
|
class?: string;
|
||||||
/** Slot content (charts go here) */
|
/** Slot content (charts go here) */
|
||||||
@@ -41,6 +46,8 @@
|
|||||||
chartCount = 1,
|
chartCount = 1,
|
||||||
chartHeight = 300,
|
chartHeight = 300,
|
||||||
extraPadding = 2,
|
extraPadding = 2,
|
||||||
|
minWidth = 560,
|
||||||
|
bleed = true,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -50,16 +57,14 @@
|
|||||||
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="chart-bleed" class:no-bleed={!bleed}>
|
||||||
|
<div
|
||||||
class="chart-container relative {className}"
|
class="chart-container relative {className}"
|
||||||
style:min-height="{minHeight}px"
|
style:min-height="{minHeight}px"
|
||||||
>
|
style="--chart-min-width: {minWidth}px"
|
||||||
<!-- Chart content area -->
|
|
||||||
<div
|
|
||||||
class="chart-content"
|
|
||||||
in:fade={{ duration: 300 }}
|
|
||||||
out:fade={{ duration: 300 }}
|
|
||||||
>
|
>
|
||||||
|
<!-- Chart content area -->
|
||||||
|
<div class="chart-content" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
<div
|
<div
|
||||||
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
|
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-background transition-opacity duration-300"
|
||||||
class:pointer-events-none={!loading}
|
class:pointer-events-none={!loading}
|
||||||
class:opacity-0={!loading}
|
class:opacity-0={!loading}
|
||||||
class:opacity-100={loading}
|
class:opacity-100={loading}
|
||||||
@@ -91,17 +96,51 @@
|
|||||||
<span class="sr-only">Loading charts...</span>
|
<span class="sr-only">Loading charts...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.chart-bleed {
|
||||||
|
/* Bleed exactly into the page padding on mobile (main has p-3 =
|
||||||
|
0.75rem) for edge-to-edge charts, and a bit past the content
|
||||||
|
column on md+ (main has 2rem padding) for extra readability. */
|
||||||
|
margin-left: -0.75rem;
|
||||||
|
margin-right: -0.75rem;
|
||||||
|
/* overflow-y is pinned (never `visible`): a bare `overflow-x: auto`
|
||||||
|
makes the browser compute overflow-y as `auto` too, which turns the
|
||||||
|
chart into a 1-2px vertical micro-scroller that swallows page scroll. */
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bleed.no-bleed {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
/* content fits the column, so no horizontal scroller is needed */
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.chart-container {
|
.chart-container {
|
||||||
/* Negative horizontal margin on mobile to use full width, reset on md+ */
|
min-width: var(--chart-min-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below lg: fit the chart to the viewport instead of forcing a min-width
|
||||||
|
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.chart-container {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chart-bleed {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.chart-bleed {
|
||||||
margin-left: -1.5rem;
|
margin-left: -1.5rem;
|
||||||
margin-right: -1.5rem;
|
margin-right: -1.5rem;
|
||||||
}
|
}
|
||||||
|
.chart-bleed.no-bleed {
|
||||||
@media (min-width: 768px) {
|
|
||||||
.chart-container {
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
Provides a toolbar row with:
|
Provides a toolbar row with:
|
||||||
- Download full meteogram as PNG button
|
- Download full meteogram as PNG button
|
||||||
- Download full meteogram as SVG button
|
|
||||||
- Slot for additional custom controls (e.g. legend toggle)
|
- Slot for additional custom controls (e.g. legend toggle)
|
||||||
|
|
||||||
When multiple charts are provided, they are stitched into a single
|
When multiple charts are provided, they are stitched into a single
|
||||||
@@ -11,7 +10,7 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
charts={chartInstances}
|
charts={chartComponents}
|
||||||
fileName="model-comparison"
|
fileName="model-comparison"
|
||||||
>
|
>
|
||||||
{#snippet controls()}
|
{#snippet controls()}
|
||||||
@@ -19,22 +18,22 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
-->
|
-->
|
||||||
|
<script module lang="ts">
|
||||||
|
export type { ExportableChart } from './downloadChartsPng';
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloadMeteogram } from '$lib/utils/echarts/download';
|
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
|
||||||
|
|
||||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Array of ECharts instances available for download */
|
/** Chart components available for download (undefined entries are skipped) */
|
||||||
charts?: echarts.ECharts[];
|
charts?: Array<ExportableChart | undefined | null>;
|
||||||
/** Base file name for downloaded images (without extension) */
|
/** Base file name for downloaded images (without extension) */
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/** Pixel ratio for PNG exports (default: 2) */
|
|
||||||
pixelRatio?: number;
|
|
||||||
/** Optional CSS class for the outer container */
|
/** Optional CSS class for the outer container */
|
||||||
class?: string;
|
class?: string;
|
||||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||||
@@ -43,33 +42,29 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
charts = [],
|
charts = [],
|
||||||
fileName = 'open-meteo-chart',
|
fileName = 'drizzli-chart',
|
||||||
pixelRatio = 2,
|
|
||||||
class: className = '',
|
class: className = '',
|
||||||
controls
|
controls
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let downloadingFormat: ExportFormat | null = $state(null);
|
let downloading = $state(false);
|
||||||
|
|
||||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let hasCharts = $derived(charts.length > 0);
|
let hasCharts = $derived(charts.some((chart) => chart != null));
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
// ─── Download ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function handleDownload(format: ExportFormat): Promise<void> {
|
|
||||||
if (!hasCharts || downloadingFormat) return;
|
|
||||||
|
|
||||||
downloadingFormat = format;
|
|
||||||
|
|
||||||
|
async function handleDownload(): Promise<void> {
|
||||||
|
if (!hasCharts || downloading) return;
|
||||||
|
downloading = true;
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await downloadChartsPng(charts, fileName);
|
||||||
downloadMeteogram(charts, { fileName, format, pixelRatio });
|
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
downloadingFormat = null;
|
downloading = false;
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,17 +80,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right side: Download buttons -->
|
<!-- Right side: Download button -->
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<!-- Download as PNG -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="toolbar-btn"
|
class="toolbar-btn"
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
disabled={!hasCharts || downloading}
|
||||||
onclick={() => handleDownload('png')}
|
onclick={handleDownload}
|
||||||
title="Download meteogram as PNG image"
|
title="Download meteogram as PNG image"
|
||||||
>
|
>
|
||||||
{#if downloadingFormat === 'png'}
|
{#if downloading}
|
||||||
<svg
|
<svg
|
||||||
class="h-4 w-4 animate-spin"
|
class="h-4 w-4 animate-spin"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -126,46 +120,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span>PNG</span>
|
<span>PNG</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Download as SVG -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toolbar-btn"
|
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
|
||||||
onclick={() => handleDownload('svg')}
|
|
||||||
title="Download meteogram as SVG vector image"
|
|
||||||
>
|
|
||||||
{#if downloadingFormat === 'svg'}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 animate-spin"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
<polyline points="7 10 12 15 17 10" />
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<span>SVG</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
<!--
|
|
||||||
EChart.svelte — Reusable ECharts wrapper component
|
|
||||||
|
|
||||||
Handles chart lifecycle (init, update, dispose), responsive resize via
|
|
||||||
ResizeObserver, and exposes the ECharts instance for programmatic access
|
|
||||||
(e.g. export/download).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
<EChart
|
|
||||||
option={chartOption}
|
|
||||||
height="300px"
|
|
||||||
renderer="canvas"
|
|
||||||
onChartReady={(chart) => { ... }}
|
|
||||||
/>
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The ECharts option object to render */
|
|
||||||
option: Record<string, unknown>;
|
|
||||||
/** CSS height of the chart container (default: '300px') */
|
|
||||||
height?: string;
|
|
||||||
/** CSS width of the chart container (default: '100%') */
|
|
||||||
width?: string;
|
|
||||||
/** Renderer type: 'canvas' or 'svg' (default: 'canvas') */
|
|
||||||
renderer?: 'canvas' | 'svg';
|
|
||||||
/** Whether to merge options on update (true) or replace them (false) */
|
|
||||||
notMerge?: boolean;
|
|
||||||
/** Whether to delay update until next animation frame */
|
|
||||||
lazyUpdate?: boolean;
|
|
||||||
/** Optional CSS class for the outer container */
|
|
||||||
class?: string;
|
|
||||||
/** Callback fired once the chart instance is initialized */
|
|
||||||
onChartReady?: (chart: echarts.ECharts) => void;
|
|
||||||
/** Callback fired when the chart is disposed */
|
|
||||||
onChartDisposed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
option,
|
|
||||||
height = '300px',
|
|
||||||
width = '100%',
|
|
||||||
renderer = 'canvas',
|
|
||||||
notMerge = false,
|
|
||||||
lazyUpdate = false,
|
|
||||||
class: className = '',
|
|
||||||
onChartReady,
|
|
||||||
onChartDisposed
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// ─── Internal State ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let chartInstance: echarts.ECharts | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the underlying ECharts instance, or null if not yet initialized.
|
|
||||||
*/
|
|
||||||
export function getChart(): echarts.ECharts | null {
|
|
||||||
return chartInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the chart has been initialized and is not disposed.
|
|
||||||
*/
|
|
||||||
export function isReady(): boolean {
|
|
||||||
return chartInstance !== null && !chartInstance.isDisposed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a manual resize of the chart.
|
|
||||||
* Useful after layout changes that the ResizeObserver might miss.
|
|
||||||
*/
|
|
||||||
export function resize(): void {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes the chart instance and cleans up observers.
|
|
||||||
* Called automatically on component destroy, but can be invoked manually.
|
|
||||||
*/
|
|
||||||
export function dispose(): void {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
initChart();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Reactivity: Update option when it changes ──────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed() && option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Init & Cleanup ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function initChart(): void {
|
|
||||||
if (!containerEl) return;
|
|
||||||
|
|
||||||
// Dispose any existing instance (e.g. from HMR)
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance = echarts.init(containerEl, null, { renderer });
|
|
||||||
|
|
||||||
if (option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up responsive resize
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
resizeObserver.observe(containerEl);
|
|
||||||
|
|
||||||
onChartReady?.(chartInstance);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
resizeObserver = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartInstance) {
|
|
||||||
if (!chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChartDisposed?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={containerEl} class="echart-wrapper {className}" style:width style:height></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.echart-wrapper {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Shared PNG export for charts.
|
||||||
|
*
|
||||||
|
* Each chart composites itself (plot + icon bands + optional title + legend)
|
||||||
|
* onto a canvas via `getExportImage`; those are stacked vertically over the
|
||||||
|
* current theme background and downloaded as one PNG.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A chart that can composite itself (plot + icons + legend) for export. */
|
||||||
|
export interface ExportableChart {
|
||||||
|
getExportImage(opts?: { title?: string }): Promise<HTMLCanvasElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One chart to export, with the title to render above it (e.g. panel name). */
|
||||||
|
export interface ChartExportItem {
|
||||||
|
chart: ExportableChart | null | undefined;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the page background so exports match the current theme. */
|
||||||
|
function exportBackground(): string {
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||||
|
return bg || '#ffffff';
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerDownload(url: string, name: string): void {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = name;
|
||||||
|
link.style.display = 'none';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.removeChild(link);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise either a bare chart or a {chart, title} item. */
|
||||||
|
function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartExportItem {
|
||||||
|
if (x && 'getExportImage' in x) return { chart: x };
|
||||||
|
return (x as ChartExportItem) ?? { chart: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stitch the given charts into one PNG and download it. Resolves once the
|
||||||
|
* download has been triggered (or immediately if there is nothing to export).
|
||||||
|
*/
|
||||||
|
export async function downloadChartsPng(
|
||||||
|
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
|
||||||
|
fileName: string
|
||||||
|
): Promise<void> {
|
||||||
|
const items = charts.map(toItem).filter((it) => it.chart != null);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
|
||||||
|
const canvases = (
|
||||||
|
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
|
||||||
|
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
|
||||||
|
if (canvases.length === 0) return;
|
||||||
|
|
||||||
|
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
||||||
|
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = maxWidth;
|
||||||
|
canvas.height = totalHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
ctx.fillStyle = exportBackground();
|
||||||
|
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||||
|
|
||||||
|
let y = 0;
|
||||||
|
for (const c of canvases) {
|
||||||
|
ctx.drawImage(c, 0, y);
|
||||||
|
y += c.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||||
|
}
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
ECharts Global Styles — Open-Meteo Weather
|
|
||||||
|
|
||||||
Shared CSS for all ECharts chart instances across the application.
|
|
||||||
Provides consistent theming, tooltip styling, and responsive behavior
|
|
||||||
that integrates with the application's design system (Tailwind + shadcn).
|
|
||||||
═══════════════════════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.echart-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Chart Container (legacy class support) ───────────────────────────────── */
|
|
||||||
|
|
||||||
.echarts-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure canvas background is always transparent so our page bg shows through */
|
|
||||||
.echart-wrapper canvas,
|
|
||||||
.echarts-container canvas {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Override ECharts' default tooltip to match the application's popover design */
|
|
||||||
.echarts-tooltip {
|
|
||||||
background: hsl(var(--popover)) !important;
|
|
||||||
border: 1px solid hsl(var(--border)) !important;
|
|
||||||
border-radius: var(--radius, 0.5rem) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
|
||||||
0 2px 4px -2px rgb(0 0 0 / 0.05) !important;
|
|
||||||
padding: 0.625rem 0.75rem !important;
|
|
||||||
font-size: 0.8125rem !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
max-width: min(90vw, 480px) !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.echarts-tooltip-content {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip marker dots — make them slightly larger and rounded */
|
|
||||||
.echarts-tooltip .echarts-tooltip-marker,
|
|
||||||
.echarts-tooltip span[style*="border-radius"] {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Loading Mask ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.echarts-loading-mask {
|
|
||||||
background: hsl(var(--background) / 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
[data-theme='light'] .echart-wrapper,
|
|
||||||
[data-theme='light'] .echarts-container,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echart-wrapper,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echarts-container {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='light'] .echarts-tooltip,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echarts-tooltip {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.dark .echart-wrapper,
|
|
||||||
[data-theme='dark'] .echart-wrapper,
|
|
||||||
.dark .echarts-container,
|
|
||||||
[data-theme='dark'] .echarts-container {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .echarts-tooltip,
|
|
||||||
[data-theme='dark'] .echarts-tooltip {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 0.3),
|
|
||||||
0 2px 4px -2px rgb(0 0 0 / 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Make sure the toolbox icons are visually subtle until hovered */
|
|
||||||
.echart-wrapper [class*="toolbox"],
|
|
||||||
.echarts-container [class*="toolbox"] {
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.echart-wrapper:hover [class*="toolbox"],
|
|
||||||
.echarts-container:hover [class*="toolbox"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Chart Spacing ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Add consistent vertical spacing between stacked chart instances */
|
|
||||||
.chart-content .echart-wrapper + .echart-wrapper {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Responsive Sizing ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.echarts-container {
|
|
||||||
min-height: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly smaller tooltips on mobile */
|
|
||||||
.echarts-tooltip {
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
padding: 0.5rem 0.625rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) and (max-width: 1024px) {
|
|
||||||
.echarts-container {
|
|
||||||
min-height: 280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Print Styles ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.echart-wrapper,
|
|
||||||
.echarts-container {
|
|
||||||
break-inside: avoid;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide interactive elements when printing */
|
|
||||||
.echart-wrapper [class*="toolbox"],
|
|
||||||
.echarts-container [class*="toolbox"],
|
|
||||||
.chart-toolbar {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Accessibility ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Respect reduced motion preferences */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.echart-wrapper *,
|
|
||||||
.echarts-container * {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
* Re-exports all chart-related Svelte components from a single entry point.
|
* Re-exports all chart-related Svelte components from a single entry point.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
|
* import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as EChart } from './EChart.svelte';
|
|
||||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||||
|
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
||||||
|
|
||||||
import { type GeoLocation } from '$lib/stores/settings';
|
import {
|
||||||
|
type GeoLocation,
|
||||||
|
locationKey,
|
||||||
|
storedFavoriteLocations,
|
||||||
|
storedRecentLocations
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -30,11 +35,31 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectLocation = (location: GeoLocation) => {
|
const selectLocation = (location: GeoLocation) => {
|
||||||
|
addRecent(location);
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
closePopover();
|
closePopover();
|
||||||
dispatch('location', location);
|
dispatch('location', location);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function addRecent(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedRecentLocations.update((list) =>
|
||||||
|
[loc, ...list.filter((l) => locationKey(l) !== key)].slice(0, 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFavorite(loc: GeoLocation) {
|
||||||
|
const key = locationKey(loc);
|
||||||
|
storedFavoriteLocations.update((list) =>
|
||||||
|
list.some((l) => locationKey(l) === key)
|
||||||
|
? list.filter((l) => locationKey(l) !== key)
|
||||||
|
: [loc, ...list].slice(0, 24)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$: favKeys = new Set($storedFavoriteLocations.map(locationKey));
|
||||||
|
$: recentToShow = $storedRecentLocations.filter((l) => !favKeys.has(locationKey(l)));
|
||||||
|
|
||||||
async function focusInput() {
|
async function focusInput() {
|
||||||
await tick();
|
await tick();
|
||||||
searchInputEl?.focus();
|
searchInputEl?.focus();
|
||||||
@@ -64,17 +89,19 @@
|
|||||||
return {
|
return {
|
||||||
results: [
|
results: [
|
||||||
{
|
{
|
||||||
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000),
|
// coordinate-only location: id 0 + COORD makes
|
||||||
|
// buildLocationRoute emit a "52.52N13.41E" route
|
||||||
|
id: 0,
|
||||||
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
|
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
elevation: position.coords.altitude ?? NaN,
|
elevation: position.coords.altitude ?? 0,
|
||||||
feature_code: '',
|
feature_code: 'COORD',
|
||||||
country_code: undefined,
|
country_code: undefined,
|
||||||
admin1_id: undefined,
|
admin1_id: undefined,
|
||||||
admin3_id: undefined,
|
admin3_id: undefined,
|
||||||
admin4_id: undefined,
|
admin4_id: undefined,
|
||||||
timezone: '',
|
timezone: 'UTC',
|
||||||
population: undefined,
|
population: undefined,
|
||||||
postcodes: undefined,
|
postcodes: undefined,
|
||||||
country_id: undefined,
|
country_id: undefined,
|
||||||
@@ -99,12 +126,61 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open={popoverOpen}>
|
{#snippet locationRow(location: GeoLocation)}
|
||||||
<Popover.Trigger
|
{@const fav = favKeys.has(locationKey(location))}
|
||||||
class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary"
|
<div
|
||||||
|
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left"
|
||||||
|
onclick={() => selectLocation(location)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="h-7 w-7 shrink-0 rounded-full"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-foreground">{location.name}</div>
|
||||||
|
<div class="truncate text-xs text-muted-foreground">
|
||||||
|
{location.admin1 || ''}
|
||||||
|
{location.country || ''}
|
||||||
|
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
||||||
|
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-background hover:text-amber-500 {fav
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-muted-foreground/50'}"
|
||||||
|
onclick={() => toggleFavorite(location)}
|
||||||
|
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 shrink-0 opacity-50"
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={fav ? 'currentColor' : 'none'}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 3.6l2.5 5.1 5.6.8-4 3.9 1 5.6-5.1-2.7-5 2.7 1-5.6-4-3.9 5.5-.8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Popover.Root bind:open={popoverOpen}>
|
||||||
|
<Popover.Trigger
|
||||||
|
class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-full border-2 border-primary/30 bg-background px-4 text-[0.8125rem] font-medium text-muted-foreground shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-primary/70 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-primary"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -176,8 +252,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:then results}
|
{:then results}
|
||||||
{#if results.results && results.results.length === 0}
|
|
||||||
{#if searchQuery.length < 2}
|
{#if searchQuery.length < 2}
|
||||||
|
{#if $storedFavoriteLocations.length > 0 || recentToShow.length > 0}
|
||||||
|
{#if $storedFavoriteLocations.length > 0}
|
||||||
|
<div
|
||||||
|
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Favorites
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||||
|
{@render locationRow(loc)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if recentToShow.length > 0}
|
||||||
|
<div
|
||||||
|
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase {$storedFavoriteLocations.length
|
||||||
|
? 'mt-3'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
Recent
|
||||||
|
</div>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each recentToShow as loc (locationKey(loc))}
|
||||||
|
{@render locationRow(loc)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
||||||
>
|
>
|
||||||
@@ -198,7 +301,14 @@
|
|||||||
Start typing to search or use GPS to detect your position
|
Start typing to search or use GPS to detect your position
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
|
{:else if results.results && results.results.length > 0}
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each results.results as location, i (i)}
|
||||||
|
{@render locationRow(location)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if results.results}
|
||||||
<Alert.Root
|
<Alert.Root
|
||||||
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
||||||
>
|
>
|
||||||
@@ -206,54 +316,10 @@
|
|||||||
No locations found for "{searchQuery}". Try a different term.
|
No locations found for "{searchQuery}". Try a different term.
|
||||||
</Alert.Description>
|
</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{:else}
|
||||||
{:else if !results.results}
|
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
<Alert.Description>No locations found</Alert.Description>
|
<Alert.Description>No locations found</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{:else}
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
{#each results.results || [] as location, i (i)}
|
|
||||||
<button
|
|
||||||
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
|
||||||
onclick={() => selectLocation(location)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<img
|
|
||||||
class="h-7 w-7 rounded-full"
|
|
||||||
src="/images/country-flags/{(
|
|
||||||
location.country_code || 'united_nations'
|
|
||||||
).toLowerCase()}.svg"
|
|
||||||
alt={location.country}
|
|
||||||
/>
|
|
||||||
<div class="flex-1 text-left">
|
|
||||||
<div class="text-sm font-medium text-foreground">
|
|
||||||
{location.name}
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
{location.admin1 || ''}
|
|
||||||
{location.country || ''}
|
|
||||||
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
|
||||||
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:catch error}
|
{:catch error}
|
||||||
<Alert.Root variant="destructive">
|
<Alert.Root variant="destructive">
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||||
|
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||||
|
|
||||||
|
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onMenuToggle?: () => void;
|
onMenuToggle?: () => void;
|
||||||
@@ -23,6 +26,19 @@
|
|||||||
location = value;
|
location = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const themeCycle: Theme[] = ['system', 'light', 'dark'];
|
||||||
|
const themeTitles: Record<Theme, string> = {
|
||||||
|
system: 'Theme: follow system',
|
||||||
|
light: 'Theme: light',
|
||||||
|
dark: 'Theme: dark'
|
||||||
|
};
|
||||||
|
|
||||||
|
function cycleTheme() {
|
||||||
|
storedTheme.update(
|
||||||
|
(current) => themeCycle[(themeCycle.indexOf(current) + 1) % themeCycle.length]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function navigateToLocation(newLocation: GeoLocation) {
|
function navigateToLocation(newLocation: GeoLocation) {
|
||||||
storedLocation.set(newLocation);
|
storedLocation.set(newLocation);
|
||||||
const locationRoute = buildLocationRoute(newLocation);
|
const locationRoute = buildLocationRoute(newLocation);
|
||||||
@@ -32,6 +48,8 @@
|
|||||||
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
|
||||||
} else if (currentPath.startsWith('/weather/14-day')) {
|
} else if (currentPath.startsWith('/weather/14-day')) {
|
||||||
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
|
||||||
|
} else if (currentPath.startsWith('/weather/historical')) {
|
||||||
|
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
|
||||||
} else {
|
} else {
|
||||||
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
goto(resolve('/weather/week/[location]', { location: locationRoute }));
|
||||||
}
|
}
|
||||||
@@ -43,7 +61,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Mobile menu toggle -->
|
<!-- Mobile menu toggle -->
|
||||||
<button
|
<button
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
|
class="-ms-1 flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
|
||||||
onclick={onMenuToggle}
|
onclick={onMenuToggle}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
>
|
>
|
||||||
@@ -54,28 +72,31 @@
|
|||||||
|
|
||||||
<!-- Current location display -->
|
<!-- Current location display -->
|
||||||
{#if location}
|
{#if location}
|
||||||
<div class="hidden items-center gap-2 sm:flex">
|
<div
|
||||||
|
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class="h-5 w-5 rounded-full"
|
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
alt={location.country}
|
alt={location.country}
|
||||||
/>
|
/>
|
||||||
<span class="text-sm font-medium text-foreground">
|
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||||
|
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
||||||
{location.name}
|
{location.name}
|
||||||
</span>
|
|
||||||
{#if location.admin1 || location.country}
|
{#if location.admin1 || location.country}
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="font-normal text-muted-foreground">
|
||||||
{#if location.admin1}{location.admin1},{/if}
|
· {#if location.admin1}{location.admin1},
|
||||||
{location.country}
|
{/if}{location.country ?? ''}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Spacer -->
|
<!-- Spacer -->
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<!-- Location search -->
|
<!-- Location search: primary way to switch places, so keep it loud -->
|
||||||
<div class="w-full max-w-sm md:max-w-md">
|
<div class="w-full max-w-sm md:max-w-md">
|
||||||
<LocationSearch
|
<LocationSearch
|
||||||
label="Search location..."
|
label="Search location..."
|
||||||
@@ -84,6 +105,64 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
onclick={cycleTheme}
|
||||||
|
title={themeTitles[$storedTheme]}
|
||||||
|
aria-label={themeTitles[$storedTheme]}
|
||||||
|
>
|
||||||
|
{#if $storedTheme === 'light'}
|
||||||
|
<!-- sun -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32 1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32 1.41-1.41"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else if $storedTheme === 'dark'}
|
||||||
|
<!-- moon -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- monitor (system) -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="13" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 21h8m-4-4v4" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Historical',
|
||||||
|
url: '/weather/historical' as const,
|
||||||
|
// clock with a counter-clockwise arrow (history)
|
||||||
|
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Maps',
|
title: 'Maps',
|
||||||
url: '/weather/maps' as const,
|
url: '/weather/maps' as const,
|
||||||
@@ -52,57 +58,45 @@
|
|||||||
class:w-55={!collapsed}
|
class:w-55={!collapsed}
|
||||||
class:w-14={collapsed}
|
class:w-14={collapsed}
|
||||||
>
|
>
|
||||||
<!-- Sidebar header -->
|
<!-- Sidebar header: same height as the topbar so the borders align; the
|
||||||
<div class="flex items-center border-b border-sidebar-border px-3 py-4">
|
home link fills the entire row, padding included -->
|
||||||
{#if !collapsed}
|
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
|
||||||
<a
|
<a
|
||||||
href={resolve('/weather/week')}
|
href={resolve('/weather/week')}
|
||||||
class="flex items-center gap-2.5 px-1"
|
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
|
||||||
|
? 'justify-center'
|
||||||
|
: 'px-4'}"
|
||||||
onclick={onMobileClose}
|
onclick={onMobileClose}
|
||||||
|
aria-label="Drizzli home"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
||||||
>
|
>
|
||||||
|
<!-- umbrella-with-rain logo mark (matches favicon) -->
|
||||||
<svg
|
<svg
|
||||||
class="h-4.5 w-4.5"
|
class="h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="2"
|
stroke-width="1.75"
|
||||||
>
|
>
|
||||||
|
<!-- rain falls from above onto the canopy -->
|
||||||
|
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
|
||||||
|
<path stroke-linecap="round" d="M12 4.5V6" />
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
fill="currentColor"
|
||||||
stroke-linejoin="round"
|
stroke="none"
|
||||||
d="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"
|
d="M4 14a8 8 0 0 1 16 0c-.66-1-1.99-1-2.66 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0-.67-1-2-1-2.67 0C8.66 13 7.33 13 6.66 14 6 13 4.66 13 4 14Z"
|
||||||
/>
|
/>
|
||||||
|
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
|
{#if !collapsed}
|
||||||
Open-Meteo
|
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
|
||||||
|
Drizz.li
|
||||||
</span>
|
</span>
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={resolve('/weather/week')}
|
|
||||||
class="mx-auto flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
|
||||||
onclick={onMobileClose}
|
|
||||||
aria-label="Open-Meteo Home"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="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"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation links -->
|
<!-- Navigation links -->
|
||||||
@@ -137,23 +131,28 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Collapse toggle -->
|
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||||
|
{#if onToggle}
|
||||||
<div class="border-t border-sidebar-border px-2 py-3">
|
<div class="border-t border-sidebar-border px-2 py-3">
|
||||||
<button
|
<button
|
||||||
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
class="relative flex w-full cursor-pointer items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||||
onclick={onToggle}
|
onclick={onToggle}
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
>
|
>
|
||||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
<svg
|
<svg
|
||||||
class="h-4.5 w-4.5 transition-transform duration-200"
|
class="h-4.5 w-4.5 transition-transform duration-200"
|
||||||
class:rotate-180={!collapsed}
|
class:rotate-180={collapsed}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke-width="1.75"
|
stroke-width="1.75"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
{#if !collapsed}
|
||||||
@@ -161,6 +160,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
||||||
|
|||||||
@@ -22,6 +22,26 @@
|
|||||||
children: Snippet;
|
children: Snippet;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// Keep the (fixed, layout-viewport-centered) dialog centered within the
|
||||||
|
// VISUAL viewport, so the mobile keyboard shifts it up instead of covering it.
|
||||||
|
let kbShift = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
if (!vv) return;
|
||||||
|
const update = () => {
|
||||||
|
const visualCenter = vv.offsetTop + vv.height / 2;
|
||||||
|
// negative when the keyboard eats the bottom → moves the dialog up
|
||||||
|
kbShift = Math.min(0, visualCenter - window.innerHeight / 2);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
vv.addEventListener('resize', update);
|
||||||
|
vv.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
vv.removeEventListener('resize', update);
|
||||||
|
vv.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<DialogPortal {...portalProps}>
|
<DialogPortal {...portalProps}>
|
||||||
@@ -33,6 +53,7 @@
|
|||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style="margin-top:{kbShift}px;transition:margin-top 0.18s ease"
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
|
||||||
|
// each group maps a stored unit key to its selectable options
|
||||||
|
const UNIT_GROUPS: {
|
||||||
|
key: keyof UnitPrefs;
|
||||||
|
label: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: 'temperature_unit',
|
||||||
|
label: 'Temperature',
|
||||||
|
options: [
|
||||||
|
{ value: 'celsius', label: '°C' },
|
||||||
|
{ value: 'fahrenheit', label: '°F' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind_speed_unit',
|
||||||
|
label: 'Wind speed',
|
||||||
|
options: [
|
||||||
|
{ value: 'kmh', label: 'km/h' },
|
||||||
|
{ value: 'ms', label: 'm/s' },
|
||||||
|
{ value: 'mph', label: 'mph' },
|
||||||
|
{ value: 'kn', label: 'kn' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation_unit',
|
||||||
|
label: 'Precipitation',
|
||||||
|
options: [
|
||||||
|
{ value: 'mm', label: 'mm' },
|
||||||
|
{ value: 'inch', label: 'inch' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time_format',
|
||||||
|
label: 'Time',
|
||||||
|
options: [
|
||||||
|
{ value: '24h', label: '24h' },
|
||||||
|
{ value: '12h', label: 'AM/PM' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||||
|
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger
|
||||||
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||||
|
aria-label="Choose measurement units"
|
||||||
|
title="Units"
|
||||||
|
>
|
||||||
|
<!-- gauge icon -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4.5 15a7.5 7.5 0 1 1 15 0M12 15l3.2-3.2"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
</Popover.Trigger>
|
||||||
|
<Popover.Content align="end" class="w-64 border-border">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
{#each UNIT_GROUPS as group (group.key)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||||
|
{#each group.options as opt (opt.value)}
|
||||||
|
{@const active = $storedUnits[group.key] === opt.value}
|
||||||
|
<button
|
||||||
|
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={active}
|
||||||
|
onclick={() => setUnit(group.key, opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import PauseIcon from '@lucide/svelte/icons/pause';
|
||||||
|
import PlayIcon from '@lucide/svelte/icons/play';
|
||||||
|
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
|
||||||
|
import * as maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
|
||||||
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
|
||||||
|
import {
|
||||||
|
clockPlaying,
|
||||||
|
clockScrub,
|
||||||
|
clockSpan,
|
||||||
|
clockSpeed,
|
||||||
|
sharedCamera
|
||||||
|
} from '$lib/maps/map-sync';
|
||||||
|
import {
|
||||||
|
type ResolvedSource,
|
||||||
|
omSourceUrl,
|
||||||
|
registerOmProtocol,
|
||||||
|
resolveSource
|
||||||
|
} from '$lib/maps/om';
|
||||||
|
|
||||||
|
import { modelGroups } from '../../routes/weather/options';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
|
||||||
|
variable: string;
|
||||||
|
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
|
||||||
|
title: string;
|
||||||
|
/**
|
||||||
|
* Accumulation variable (e.g. precipitation): its first timestep is the
|
||||||
|
* analysis time with no accumulation window, so skip it. Leave off for
|
||||||
|
* instantaneous variables like wind.
|
||||||
|
*/
|
||||||
|
accumulation?: boolean;
|
||||||
|
/** Overlay animated wind-arrow vectors on top of the raster field. */
|
||||||
|
arrows?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { variable, title, accumulation = false, arrows = false }: Props = $props();
|
||||||
|
|
||||||
|
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
|
||||||
|
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
|
||||||
|
// tiles) - the requested preload parallelism.
|
||||||
|
const PRELOAD_MAX_FRAMES = 8;
|
||||||
|
|
||||||
|
// Playback speeds: ms of minimum wall-time per forecast frame.
|
||||||
|
const SPEEDS = [
|
||||||
|
{ label: '0.5×', ms: 900 },
|
||||||
|
{ label: '1×', ms: 450 },
|
||||||
|
{ label: '2×', ms: 220 }
|
||||||
|
];
|
||||||
|
// Loop / preload span presets (Infinity = the complete model run).
|
||||||
|
const SPANS = [
|
||||||
|
{ label: '2½ d', days: 2.5 },
|
||||||
|
{ label: '5 d', days: 5 },
|
||||||
|
{ label: 'Full run', days: Infinity }
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelLabel = (id: string): string => {
|
||||||
|
for (const g of modelGroups) {
|
||||||
|
for (const m of g.models) if (m.value === id) return m.label;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mapEl: HTMLDivElement;
|
||||||
|
let map: maplibregl.Map | undefined;
|
||||||
|
let animator: FrameAnimator | undefined;
|
||||||
|
let marker: maplibregl.Marker | undefined;
|
||||||
|
let syncingCamera = false;
|
||||||
|
|
||||||
|
// resolved source (full run) + windowed timeline
|
||||||
|
let sourceBase: ResolvedSource | null = null;
|
||||||
|
let fullTimes: Date[] = [];
|
||||||
|
let source = $state<ResolvedSource | null>(null);
|
||||||
|
let index = $state(0);
|
||||||
|
let loading = $state(true);
|
||||||
|
let firstFrameLoading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
/** Per-frame load state, aligned with `times`. */
|
||||||
|
let loaded = $state<boolean[]>([]);
|
||||||
|
|
||||||
|
// dark mode mirrors the root layout's `.dark` class on <html>
|
||||||
|
let dark = $state(false);
|
||||||
|
|
||||||
|
// preload covers the whole windowed timeline (the loop == the window)
|
||||||
|
let loopStart = 0;
|
||||||
|
let loopEnd = 0;
|
||||||
|
|
||||||
|
let loadController: AbortController | undefined;
|
||||||
|
let lastKey = '';
|
||||||
|
let appliedSpan = get(clockSpan);
|
||||||
|
let appliedScrubEpoch = get(clockScrub).epoch;
|
||||||
|
|
||||||
|
const times = $derived(source?.times ?? []);
|
||||||
|
const currentTime = $derived(times[index]);
|
||||||
|
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
|
||||||
|
|
||||||
|
// ---- helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
const closestIndexToNow = (list: Date[]): number => {
|
||||||
|
const now = Date.now();
|
||||||
|
let best = 0;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
for (let i = 0; i < list.length; i++) {
|
||||||
|
const delta = Math.abs(list[i].getTime() - now);
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
|
||||||
|
const windowTimes = (all: Date[], spanDays: number): Date[] => {
|
||||||
|
if (!isFinite(spanDays) || spanDays <= 0) return all;
|
||||||
|
const dayStart = new SvelteDate();
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
|
||||||
|
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
|
||||||
|
return windowed.length ? windowed : all;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildUrl = (i: number): string => {
|
||||||
|
const s = source;
|
||||||
|
if (!s) return '';
|
||||||
|
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
|
||||||
|
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
|
||||||
|
|
||||||
|
// ---- animator / channels -------------------------------------------------
|
||||||
|
|
||||||
|
const makeChannels = (): FrameChannel[] => {
|
||||||
|
const raster: FrameChannel = {
|
||||||
|
key: 'raster',
|
||||||
|
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
|
||||||
|
addLayer: (m, sourceId, layerId, before) =>
|
||||||
|
m.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'raster',
|
||||||
|
source: sourceId,
|
||||||
|
paint: {
|
||||||
|
'raster-opacity': 0,
|
||||||
|
'raster-opacity-transition': { duration: 0, delay: 0 },
|
||||||
|
'raster-fade-duration': 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
before
|
||||||
|
),
|
||||||
|
opacityProp: 'raster-opacity',
|
||||||
|
peakOpacity: dark ? 0.85 : 0.9
|
||||||
|
};
|
||||||
|
if (!arrows) return [raster];
|
||||||
|
|
||||||
|
const arrowLayer: FrameChannel = {
|
||||||
|
key: 'arrows',
|
||||||
|
sourceSpec: (url) => ({ type: 'vector', url }),
|
||||||
|
addLayer: (m, sourceId, layerId, before) =>
|
||||||
|
m.addLayer(
|
||||||
|
{
|
||||||
|
id: layerId,
|
||||||
|
type: 'line',
|
||||||
|
source: sourceId,
|
||||||
|
'source-layer': 'wind-arrows',
|
||||||
|
layout: { 'line-cap': 'round' },
|
||||||
|
paint: {
|
||||||
|
'line-opacity': 0,
|
||||||
|
'line-opacity-transition': { duration: 0, delay: 0 },
|
||||||
|
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
before
|
||||||
|
),
|
||||||
|
opacityProp: 'line-opacity',
|
||||||
|
peakOpacity: 1
|
||||||
|
};
|
||||||
|
return [raster, arrowLayer];
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeAnimator = (m: maplibregl.Map): void => {
|
||||||
|
animator?.destroy();
|
||||||
|
animator = new FrameAnimator({
|
||||||
|
map: m,
|
||||||
|
urlForFrame: buildUrl,
|
||||||
|
channels: makeChannels(),
|
||||||
|
beforeLayer: firstSymbolLayer(m),
|
||||||
|
opacity: dark ? 0.85 : 0.9,
|
||||||
|
onShow: (i) => {
|
||||||
|
index = i;
|
||||||
|
if (i < loaded.length) loaded[i] = true;
|
||||||
|
},
|
||||||
|
onReady: (i) => {
|
||||||
|
if (i < loaded.length) loaded[i] = true;
|
||||||
|
},
|
||||||
|
onFirstFrame: () => {
|
||||||
|
firstFrameLoading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Chronological preload so the current day fills front-to-back, no gap. */
|
||||||
|
const preloadOrder = (): number[] => {
|
||||||
|
const order: number[] = [];
|
||||||
|
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
|
||||||
|
return order;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAnimation = (m: maplibregl.Map): void => {
|
||||||
|
if (!source) return;
|
||||||
|
makeAnimator(m);
|
||||||
|
animator!.setFrameCount(source.times.length);
|
||||||
|
animator!.setLoopRange(loopStart, loopEnd);
|
||||||
|
const speed = get(clockSpeed);
|
||||||
|
animator!.setSpeed(speed);
|
||||||
|
const initial = closestIndexToNow(source.times);
|
||||||
|
index = initial;
|
||||||
|
if (get(clockPlaying)) animator!.play(speed, initial);
|
||||||
|
else animator!.scrubTo(initial);
|
||||||
|
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- windowing / (re)load ------------------------------------------------
|
||||||
|
|
||||||
|
const applyWindow = (): void => {
|
||||||
|
if (!sourceBase || !map) return;
|
||||||
|
const list = windowTimes(fullTimes, get(clockSpan));
|
||||||
|
source = { ...sourceBase, times: list };
|
||||||
|
loopStart = 0;
|
||||||
|
loopEnd = Math.max(0, list.length - 1);
|
||||||
|
loaded = new Array(list.length).fill(false);
|
||||||
|
firstFrameLoading = true;
|
||||||
|
startAnimation(map);
|
||||||
|
};
|
||||||
|
|
||||||
|
const load = async (model: string): Promise<void> => {
|
||||||
|
loadController?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
loadController = controller;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
const resolved = await resolveSource(model, variable, {
|
||||||
|
signal: controller.signal,
|
||||||
|
skipFirst: accumulation
|
||||||
|
});
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
error = `No ${title.toLowerCase()} data available for this model right now.`;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceBase = resolved;
|
||||||
|
fullTimes = resolved.times;
|
||||||
|
loading = false;
|
||||||
|
applyWindow();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- theme ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
|
||||||
|
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
|
||||||
|
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
|
||||||
|
applyPadding(m);
|
||||||
|
// setStyle wipes all layers; rebuild the animator for the new theme
|
||||||
|
if (source) startAnimation(m);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- camera / marker -----------------------------------------------------
|
||||||
|
|
||||||
|
// 20% bottom padding keeps the focused location above the time scrubber.
|
||||||
|
const applyPadding = (m: maplibregl.Map): void => {
|
||||||
|
const h = mapEl?.clientHeight ?? 0;
|
||||||
|
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMarkerEl = (): HTMLDivElement => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'wx-marker';
|
||||||
|
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- lifecycle -----------------------------------------------------------
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
registerOmProtocol();
|
||||||
|
dark = document.documentElement.classList.contains('dark');
|
||||||
|
lastKey = `${$storedModel}`;
|
||||||
|
|
||||||
|
map = new maplibregl.Map({
|
||||||
|
container: mapEl,
|
||||||
|
style: dark ? STYLE_DARK : STYLE_LIGHT,
|
||||||
|
center: [$storedLocation.longitude, $storedLocation.latitude],
|
||||||
|
zoom: 7.5,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
maxPitch: 0,
|
||||||
|
dragRotate: false
|
||||||
|
});
|
||||||
|
const m = map;
|
||||||
|
m.scrollZoom.disable(); // don't hijack page scroll to zoom
|
||||||
|
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||||
|
|
||||||
|
const pushBounds = () => {
|
||||||
|
const b = m.getBounds();
|
||||||
|
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
|
||||||
|
};
|
||||||
|
// mirror this map's camera to siblings (unless the move came from a sync)
|
||||||
|
m.on('move', () => {
|
||||||
|
pushBounds();
|
||||||
|
if (syncingCamera) return;
|
||||||
|
const c = m.getCenter();
|
||||||
|
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
|
||||||
|
});
|
||||||
|
m.on('resize', () => applyPadding(m));
|
||||||
|
|
||||||
|
m.on('load', () => {
|
||||||
|
pushBounds();
|
||||||
|
applyPadding(m);
|
||||||
|
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
|
||||||
|
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
|
||||||
|
.addTo(m);
|
||||||
|
void load($storedModel);
|
||||||
|
});
|
||||||
|
|
||||||
|
// follow a sibling map's camera
|
||||||
|
const unsubCamera = sharedCamera.subscribe((cam) => {
|
||||||
|
if (!cam || !map) return;
|
||||||
|
const c = map.getCenter();
|
||||||
|
if (
|
||||||
|
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
|
||||||
|
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
|
||||||
|
Math.abs(map.getZoom() - cam.zoom) < 1e-6
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
syncingCamera = true;
|
||||||
|
map.jumpTo({ center: cam.center, zoom: cam.zoom });
|
||||||
|
syncingCamera = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
if (isDark !== dark) {
|
||||||
|
dark = isDark;
|
||||||
|
if (map && map.isStyleLoaded()) void applyStyle(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
unsubCamera();
|
||||||
|
animator?.destroy();
|
||||||
|
loadController?.abort();
|
||||||
|
marker?.remove();
|
||||||
|
map?.remove();
|
||||||
|
map = undefined;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// reload when the shared model selection changes
|
||||||
|
$effect(() => {
|
||||||
|
const key = `${$storedModel}`;
|
||||||
|
if (map && map.isStyleLoaded() && key !== lastKey) {
|
||||||
|
lastKey = key;
|
||||||
|
void load($storedModel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// recenter + move the marker when the user picks a new location
|
||||||
|
$effect(() => {
|
||||||
|
const loc = $storedLocation;
|
||||||
|
marker?.setLngLat([loc.longitude, loc.latitude]);
|
||||||
|
if (map && map.isStyleLoaded()) {
|
||||||
|
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- shared-clock effects ------------------------------------------------
|
||||||
|
|
||||||
|
// play / pause together
|
||||||
|
$effect(() => {
|
||||||
|
const playing = $clockPlaying;
|
||||||
|
if (!animator) return;
|
||||||
|
if (playing) {
|
||||||
|
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
|
||||||
|
animator.play(get(clockSpeed), from);
|
||||||
|
} else {
|
||||||
|
animator.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// speed together
|
||||||
|
$effect(() => {
|
||||||
|
const ms = $clockSpeed;
|
||||||
|
animator?.setSpeed(ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
// span together (re-window + restart)
|
||||||
|
$effect(() => {
|
||||||
|
const days = $clockSpan;
|
||||||
|
if (days !== appliedSpan) {
|
||||||
|
appliedSpan = days;
|
||||||
|
if (sourceBase) applyWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// scrub together
|
||||||
|
$effect(() => {
|
||||||
|
const s = $clockScrub;
|
||||||
|
if (s.epoch !== appliedScrubEpoch) {
|
||||||
|
appliedScrubEpoch = s.epoch;
|
||||||
|
if (s.index >= 0) {
|
||||||
|
index = s.index;
|
||||||
|
animator?.scrubTo(s.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- controls (write to the shared clock so every map follows) -----------
|
||||||
|
|
||||||
|
const togglePlay = (): void => clockPlaying.update((p) => !p);
|
||||||
|
const setSpeed = (ms: number): void => clockSpeed.set(ms);
|
||||||
|
const setSpan = (days: number): void => clockSpan.set(days);
|
||||||
|
|
||||||
|
const onScrubInput = (e: Event): void => {
|
||||||
|
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||||
|
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
|
||||||
|
};
|
||||||
|
const onScrubStart = (): void => clockPlaying.set(false);
|
||||||
|
const onScrubEnd = (): void => clockPlaying.set(true);
|
||||||
|
|
||||||
|
// ---- formatting / scrubber decorations -----------------------------------
|
||||||
|
|
||||||
|
const hour12 = $derived($storedUnits.time_format === '12h');
|
||||||
|
const fmtTime = (d: Date | undefined): string =>
|
||||||
|
d
|
||||||
|
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
|
||||||
|
: '--:--';
|
||||||
|
const fmtDay = (d: Date | undefined): string =>
|
||||||
|
d
|
||||||
|
? new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
}).format(d)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
|
||||||
|
|
||||||
|
const dayMarkers = $derived.by(() => {
|
||||||
|
const out: { i: number; label: string; left: number }[] = [];
|
||||||
|
let prevDay = '';
|
||||||
|
times.forEach((t, i) => {
|
||||||
|
const day = t.toDateString();
|
||||||
|
if (day !== prevDay) {
|
||||||
|
prevDay = day;
|
||||||
|
out.push({
|
||||||
|
i,
|
||||||
|
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
|
||||||
|
left: pct(i)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative h-full w-full">
|
||||||
|
<div bind:this={mapEl} class="h-full w-full"></div>
|
||||||
|
|
||||||
|
<!-- top-left status: variable + model + fallback notice -->
|
||||||
|
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
|
||||||
|
>
|
||||||
|
{title} · {modelLabel($storedModel)}
|
||||||
|
</div>
|
||||||
|
{#if source?.fellBack}
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
|
||||||
|
>
|
||||||
|
No map for this model - showing global {source.domain}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
|
||||||
|
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- big scrubber -->
|
||||||
|
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
|
||||||
|
<div
|
||||||
|
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 sm:gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePlay}
|
||||||
|
disabled={loading || !!error || times.length < 2}
|
||||||
|
aria-label={$clockPlaying ? 'Pause' : 'Play'}
|
||||||
|
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
|
||||||
|
>
|
||||||
|
{#if loading || firstFrameLoading}
|
||||||
|
<LoaderIcon class="size-6 animate-spin" />
|
||||||
|
{:else if $clockPlaying}
|
||||||
|
<PauseIcon class="size-6" />
|
||||||
|
{:else}
|
||||||
|
<PlayIcon class="size-6 translate-x-0.5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
|
||||||
|
{fmtTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
|
||||||
|
{fmtDay(currentTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{#if times.length}
|
||||||
|
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
|
||||||
|
{loadedCount}/{times.length} cached
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<!-- range: how much to loop / preload -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1 rounded-lg bg-muted p-1"
|
||||||
|
title="Loop & preload range"
|
||||||
|
>
|
||||||
|
{#each SPANS as s (s.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setSpan(s.days)}
|
||||||
|
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
|
||||||
|
{$clockSpan === s.days
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<!-- playback speed -->
|
||||||
|
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
|
||||||
|
{#each SPEEDS as s (s.ms)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setSpeed(s.ms)}
|
||||||
|
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
|
||||||
|
{$clockSpeed === s.ms
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- track -->
|
||||||
|
<div class="relative mt-3">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="scrubber relative"
|
||||||
|
min="0"
|
||||||
|
max={Math.max(0, times.length - 1)}
|
||||||
|
step="1"
|
||||||
|
value={index}
|
||||||
|
disabled={loading || !!error || !times.length}
|
||||||
|
oninput={onScrubInput}
|
||||||
|
onpointerdown={onScrubStart}
|
||||||
|
onpointerup={onScrubEnd}
|
||||||
|
onpointercancel={onScrubEnd}
|
||||||
|
aria-label="Forecast time"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- now marker -->
|
||||||
|
{#if times.length}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
|
||||||
|
style="left: {nowLeft}%"
|
||||||
|
title="Now"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- buffered-frames strip: shows which steps are cached -->
|
||||||
|
{#if times.length}
|
||||||
|
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
|
||||||
|
{#each times as _t, i (i)}
|
||||||
|
<div
|
||||||
|
class="h-full flex-1 transition-colors {loaded[i]
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- day labels -->
|
||||||
|
<div class="relative mt-1 h-4">
|
||||||
|
{#each dayMarkers as d (d.i)}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
|
||||||
|
style="left: {Math.min(96, Math.max(4, d.left))}%"
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Big, prominent scrubber that works in light and dark. */
|
||||||
|
.scrubber {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.scrubber:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.scrubber::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 3px solid var(--color-background);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.scrubber::-webkit-slider-thumb:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.scrubber::-moz-range-thumb {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 3px solid var(--color-background);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.scrubber::-moz-range-progress {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsing "current location" marker (added to the map's own DOM, so styles
|
||||||
|
must be global). */
|
||||||
|
:global(.wx-marker) {
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
:global(.wx-marker__dot) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px #fff,
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
:global(.wx-marker__ring) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(239, 68, 68, 0.55);
|
||||||
|
animation: wx-pulse 1.5s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes -global-wx-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.4);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(2);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
import * as maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth cross-fade animator for a stack of MapLibre weather frames.
|
||||||
|
*
|
||||||
|
* Instead of tearing down and rebuilding a source per timestep (which flashes
|
||||||
|
* and stutters), each forecast step gets its own persistent layer(s). Playback
|
||||||
|
* drives a floating-point playhead and holds each frame crisp for most of its
|
||||||
|
* dwell, then cross-fades to the next over a short window - a fluid hand-off
|
||||||
|
* rather than a muddy full-interval dissolve.
|
||||||
|
*
|
||||||
|
* A frame can render several **channels** (e.g. a raster field plus a vector
|
||||||
|
* wind-arrows overlay); all a frame's channels fade together and it is only
|
||||||
|
* "ready" once every channel's source has loaded.
|
||||||
|
*
|
||||||
|
* Frames inside the active loop range stay resident so repeat loops are
|
||||||
|
* instant; frames visited only by scrubbing are evicted LRU. The playhead only
|
||||||
|
* advances once both the current and next frame have loaded, so a slow tile
|
||||||
|
* never produces a gap - it just briefly holds the current frame.
|
||||||
|
*/
|
||||||
|
export interface FrameChannel {
|
||||||
|
/** Unique per channel (part of source/layer ids). */
|
||||||
|
key: string;
|
||||||
|
sourceSpec: (url: string) => maplibregl.SourceSpecification;
|
||||||
|
/** Add the layer at opacity 0 (it is faded in by the animator). */
|
||||||
|
addLayer: (
|
||||||
|
map: maplibregl.Map,
|
||||||
|
sourceId: string,
|
||||||
|
layerId: string,
|
||||||
|
beforeLayer: string | undefined
|
||||||
|
) => void;
|
||||||
|
/** Paint property used to fade this channel, e.g. `raster-opacity`. */
|
||||||
|
opacityProp: string;
|
||||||
|
/** Peak opacity for this channel (defaults to the animator's `opacity`). */
|
||||||
|
peakOpacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FrameAnimatorOptions {
|
||||||
|
map: maplibregl.Map;
|
||||||
|
/** `om://` source URL for frame `i`. */
|
||||||
|
urlForFrame: (i: number) => string;
|
||||||
|
channels: FrameChannel[];
|
||||||
|
beforeLayer?: string;
|
||||||
|
/** Default peak opacity for channels that don't set their own. */
|
||||||
|
opacity?: number;
|
||||||
|
/** Cross-fade window in ms (0 = instant swaps). Default 130. */
|
||||||
|
crossFadeMs?: number;
|
||||||
|
/** LRU cap for frames outside the loop range (scrub-visited). */
|
||||||
|
outOfRangePool?: number;
|
||||||
|
/** Fired when the dominant integer frame changes (drives the scrubber). */
|
||||||
|
onShow?: (i: number) => void;
|
||||||
|
/** Fired when frame `i` finishes loading (drives the buffer strip). */
|
||||||
|
onReady?: (i: number) => void;
|
||||||
|
/** Fired once, when the very first frame becomes visible. */
|
||||||
|
onFirstFrame?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LayerInfo {
|
||||||
|
layerId: string;
|
||||||
|
opacityProp: string;
|
||||||
|
peak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
sources: string[];
|
||||||
|
layers: LayerInfo[];
|
||||||
|
loadedSources: Set<string>;
|
||||||
|
ready: boolean;
|
||||||
|
onData?: (e: maplibregl.MapSourceDataEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FrameAnimator {
|
||||||
|
private map: maplibregl.Map;
|
||||||
|
private opt: FrameAnimatorOptions;
|
||||||
|
private target: number;
|
||||||
|
private crossFadeMs: number;
|
||||||
|
|
||||||
|
private frames = new Map<number, Entry>();
|
||||||
|
private visible = new Set<number>();
|
||||||
|
private lru: number[] = []; // out-of-loop frames, oldest first
|
||||||
|
|
||||||
|
private frameCount = 0;
|
||||||
|
private loopStart = 0;
|
||||||
|
private loopEnd = 0;
|
||||||
|
|
||||||
|
private mode: 'still' | 'play' = 'still';
|
||||||
|
private q = 0; // loop-local playhead (float) when playing
|
||||||
|
private stillIndex = 0;
|
||||||
|
private speedMs = 450;
|
||||||
|
|
||||||
|
private raf = 0;
|
||||||
|
private lastTs = 0;
|
||||||
|
private lastShown = -1;
|
||||||
|
private firstShown = false;
|
||||||
|
private alive = true;
|
||||||
|
|
||||||
|
// bounded-parallel preloading
|
||||||
|
private preloadQueue: number[] = [];
|
||||||
|
private preloading = new Set<number>();
|
||||||
|
private preloadInFlight = 0;
|
||||||
|
private preloadMax = 8;
|
||||||
|
|
||||||
|
constructor(opt: FrameAnimatorOptions) {
|
||||||
|
this.map = opt.map;
|
||||||
|
this.opt = opt;
|
||||||
|
this.target = opt.opacity ?? 0.9;
|
||||||
|
this.crossFadeMs = opt.crossFadeMs ?? 200;
|
||||||
|
this.tick = this.tick.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFrameCount(n: number): void {
|
||||||
|
this.frameCount = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoopRange(start: number, end: number): void {
|
||||||
|
this.loopStart = Math.max(0, start);
|
||||||
|
this.loopEnd = Math.max(this.loopStart, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeed(ms: number): void {
|
||||||
|
this.speedMs = ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentIndex(): number {
|
||||||
|
return this.mode === 'play'
|
||||||
|
? this.loopStart + (Math.floor(this.q) % this.loopLen())
|
||||||
|
: this.stillIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loopLen(): number {
|
||||||
|
return Math.max(1, this.loopEnd - this.loopStart + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private inLoop(i: number): boolean {
|
||||||
|
return i >= this.loopStart && i <= this.loopEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a single frame and stop animating (used for scrubbing / paused). */
|
||||||
|
scrubTo(i: number): void {
|
||||||
|
this.mode = 'still';
|
||||||
|
this.stillIndex = Math.max(0, Math.min(this.frameCount - 1, i));
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start looping over the loop range, entering at `fromIndex` when given. */
|
||||||
|
play(speedMs: number, fromIndex?: number): void {
|
||||||
|
this.speedMs = speedMs;
|
||||||
|
this.mode = 'play';
|
||||||
|
const L = this.loopLen();
|
||||||
|
this.q = fromIndex != null && this.inLoop(fromIndex) ? (fromIndex - this.loopStart) % L : 0;
|
||||||
|
this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
if (this.mode === 'play') this.stillIndex = this.currentIndex;
|
||||||
|
this.mode = 'still';
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(): void {
|
||||||
|
if (!this.alive) return;
|
||||||
|
this.lastTs = 0;
|
||||||
|
if (!this.raf) this.raf = requestAnimationFrame(this.tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopRaf(): void {
|
||||||
|
if (this.raf) cancelAnimationFrame(this.raf);
|
||||||
|
this.raf = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(ts: number): void {
|
||||||
|
if (!this.alive) return;
|
||||||
|
const dt = this.lastTs ? ts - this.lastTs : 16;
|
||||||
|
this.lastTs = ts;
|
||||||
|
if (this.mode === 'play') this.tickPlay(dt);
|
||||||
|
else this.tickStill();
|
||||||
|
this.raf = requestAnimationFrame(this.tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tickStill(): void {
|
||||||
|
const i = this.stillIndex;
|
||||||
|
this.ensure(i);
|
||||||
|
if (!this.frames.get(i)?.ready) return; // hold previous frame until ready
|
||||||
|
this.markFirst();
|
||||||
|
this.setOp(i, 1);
|
||||||
|
this.hideExcept(new Set([i]));
|
||||||
|
this.emitShown(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private tickPlay(dt: number): void {
|
||||||
|
const L = this.loopLen();
|
||||||
|
const baseLocal = Math.floor(this.q) % L;
|
||||||
|
const curr = this.loopStart + baseLocal;
|
||||||
|
const next = this.loopStart + ((baseLocal + 1) % L);
|
||||||
|
this.ensure(curr);
|
||||||
|
this.ensure(next);
|
||||||
|
this.ensure(this.loopStart + ((baseLocal + 2) % L));
|
||||||
|
|
||||||
|
const currReady = this.frames.get(curr)?.ready;
|
||||||
|
const nextReady = this.frames.get(next)?.ready;
|
||||||
|
if (currReady) this.markFirst();
|
||||||
|
|
||||||
|
if (currReady && nextReady) {
|
||||||
|
this.q += dt / this.speedMs;
|
||||||
|
if (this.q >= L) this.q -= L;
|
||||||
|
const bl = Math.floor(this.q) % L;
|
||||||
|
const cB = this.loopStart + bl;
|
||||||
|
const cN = this.loopStart + ((bl + 1) % L);
|
||||||
|
const frac = this.q - Math.floor(this.q);
|
||||||
|
// Hold the current frame crisp for most of its dwell, then cross-fade
|
||||||
|
// to the next only over the short crossFadeMs window at the end.
|
||||||
|
const fadeFrac = Math.min(0.9, this.crossFadeMs / this.speedMs);
|
||||||
|
const fadeStart = 1 - fadeFrac;
|
||||||
|
if (fadeFrac <= 0 || frac < fadeStart) {
|
||||||
|
this.setOp(cB, 1);
|
||||||
|
this.hideExcept(new Set([cB]));
|
||||||
|
this.emitShown(cB);
|
||||||
|
} else {
|
||||||
|
const blend = (frac - fadeStart) / fadeFrac;
|
||||||
|
this.setOp(cB, 1 - blend);
|
||||||
|
this.setOp(cN, blend);
|
||||||
|
this.hideExcept(new Set([cB, cN]));
|
||||||
|
this.emitShown(blend < 0.5 ? cB : cN);
|
||||||
|
}
|
||||||
|
} else if (currReady) {
|
||||||
|
// next frame still loading - hold the current frame at full opacity
|
||||||
|
this.setOp(curr, 1);
|
||||||
|
this.hideExcept(new Set([curr]));
|
||||||
|
this.emitShown(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markFirst(): void {
|
||||||
|
if (this.firstShown) return;
|
||||||
|
this.firstShown = true;
|
||||||
|
this.opt.onFirstFrame?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitShown(i: number): void {
|
||||||
|
if (i === this.lastShown) return;
|
||||||
|
this.lastShown = i;
|
||||||
|
this.opt.onShow?.(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure(i: number): void {
|
||||||
|
if (i < 0 || i >= this.frameCount) return;
|
||||||
|
if (this.frames.has(i)) {
|
||||||
|
this.touch(i);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const url = this.opt.urlForFrame(i);
|
||||||
|
const sources: string[] = [];
|
||||||
|
const layers: LayerInfo[] = [];
|
||||||
|
for (const ch of this.opt.channels) {
|
||||||
|
const sourceId = `omAnim_${ch.key}_${i}`;
|
||||||
|
const layerId = `omAnimL_${ch.key}_${i}`;
|
||||||
|
this.map.addSource(sourceId, ch.sourceSpec(url));
|
||||||
|
ch.addLayer(this.map, sourceId, layerId, this.opt.beforeLayer);
|
||||||
|
if (this.map.getLayer(layerId)) {
|
||||||
|
sources.push(sourceId);
|
||||||
|
layers.push({ layerId, opacityProp: ch.opacityProp, peak: ch.peakOpacity ?? this.target });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: Entry = { sources, layers, loadedSources: new Set(), ready: false };
|
||||||
|
const allLoaded = () => entry.sources.every((s) => this.map.getSource(s)?.loaded());
|
||||||
|
const markReady = () => {
|
||||||
|
entry.ready = true;
|
||||||
|
if (entry.onData) {
|
||||||
|
this.map.off('sourcedata', entry.onData);
|
||||||
|
entry.onData = undefined;
|
||||||
|
}
|
||||||
|
this.opt.onReady?.(i);
|
||||||
|
if (this.preloading.has(i)) {
|
||||||
|
this.preloading.delete(i);
|
||||||
|
this.preloadInFlight = Math.max(0, this.preloadInFlight - 1);
|
||||||
|
queueMicrotask(() => this.pumpPreload());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (allLoaded()) {
|
||||||
|
markReady();
|
||||||
|
} else {
|
||||||
|
const onData = (e: maplibregl.MapSourceDataEvent) => {
|
||||||
|
if (!e.sourceId || !entry.sources.includes(e.sourceId)) return;
|
||||||
|
if (!e.isSourceLoaded) return;
|
||||||
|
if (allLoaded()) markReady();
|
||||||
|
};
|
||||||
|
entry.onData = onData;
|
||||||
|
this.map.on('sourcedata', onData);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.frames.set(i, entry);
|
||||||
|
this.touch(i);
|
||||||
|
this.evict();
|
||||||
|
}
|
||||||
|
|
||||||
|
private touch(i: number): void {
|
||||||
|
if (this.inLoop(i)) return; // loop frames are never evicted
|
||||||
|
const at = this.lru.indexOf(i);
|
||||||
|
if (at >= 0) this.lru.splice(at, 1);
|
||||||
|
this.lru.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private evict(): void {
|
||||||
|
const cap = this.opt.outOfRangePool ?? 6;
|
||||||
|
while (this.lru.length > cap) {
|
||||||
|
const i = this.lru.shift();
|
||||||
|
if (i === undefined || i === this.stillIndex) continue;
|
||||||
|
this.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(i: number): void {
|
||||||
|
const e = this.frames.get(i);
|
||||||
|
if (!e) return;
|
||||||
|
if (e.onData) this.map.off('sourcedata', e.onData);
|
||||||
|
for (const { layerId } of e.layers)
|
||||||
|
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
|
||||||
|
for (const s of e.sources) if (this.map.getSource(s)) this.map.removeSource(s);
|
||||||
|
this.frames.delete(i);
|
||||||
|
this.visible.delete(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set every channel of frame `i` to `mul` × its peak opacity. */
|
||||||
|
private setOp(i: number, mul: number): void {
|
||||||
|
const e = this.frames.get(i);
|
||||||
|
if (!e) return;
|
||||||
|
for (const { layerId, opacityProp, peak } of e.layers) {
|
||||||
|
if (this.map.getLayer(layerId)) this.map.setPaintProperty(layerId, opacityProp, peak * mul);
|
||||||
|
}
|
||||||
|
if (mul > 0.001) this.visible.add(i);
|
||||||
|
else this.visible.delete(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideExcept(keep: Set<number>): void {
|
||||||
|
for (const i of [...this.visible]) if (!keep.has(i)) this.setOp(i, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly load `indices` with at most `maxInFlight` frames loading at once.
|
||||||
|
* Each frame is a set of layers, so MapLibre + the tile worker pool fetch and
|
||||||
|
* render them in parallel; the cap bounds the number of open tile requests.
|
||||||
|
*/
|
||||||
|
preload(indices: number[], maxInFlight = 8): void {
|
||||||
|
this.preloadMax = Math.max(1, maxInFlight);
|
||||||
|
this.preloadQueue = indices.slice();
|
||||||
|
this.pumpPreload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pumpPreload(): void {
|
||||||
|
while (this.preloadInFlight < this.preloadMax && this.preloadQueue.length) {
|
||||||
|
const i = this.preloadQueue.shift();
|
||||||
|
if (i === undefined || i < 0 || i >= this.frameCount) continue;
|
||||||
|
if (this.frames.get(i)?.ready || this.preloading.has(i)) continue;
|
||||||
|
this.preloading.add(i);
|
||||||
|
this.preloadInFlight++;
|
||||||
|
this.ensure(i); // synchronous cache hits settle via markReady immediately
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove every layer/source (e.g. before a style reload). */
|
||||||
|
reset(): void {
|
||||||
|
this.stopRaf();
|
||||||
|
for (const i of [...this.frames.keys()]) this.remove(i);
|
||||||
|
this.lru = [];
|
||||||
|
this.visible.clear();
|
||||||
|
this.lastShown = -1;
|
||||||
|
this.firstShown = false;
|
||||||
|
this.preloadQueue = [];
|
||||||
|
this.preloading.clear();
|
||||||
|
this.preloadInFlight = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.alive = false;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-map sync state, shared by every WeatherMap on the page so the maps stay
|
||||||
|
* matched. Split into one store per concern so a change to one (e.g. speed)
|
||||||
|
* doesn't wake effects that only care about another (e.g. the playhead).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Camera shared across maps; the last map the user moved writes it. */
|
||||||
|
export interface SharedCamera {
|
||||||
|
center: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
export const sharedCamera = writable<SharedCamera | null>(null);
|
||||||
|
|
||||||
|
/** Whether playback is running. */
|
||||||
|
export const clockPlaying = writable(true);
|
||||||
|
|
||||||
|
/** Minimum wall-time per frame, in ms. */
|
||||||
|
export const clockSpeed = writable(450);
|
||||||
|
|
||||||
|
/** Loop / preload span in days from today 00:00 (Infinity = the whole run). */
|
||||||
|
export const clockSpan = writable(2.5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scrub target. `epoch` bumps on every scrub so followers re-apply even if
|
||||||
|
* the index repeats; `index` is -1 before the first scrub.
|
||||||
|
*/
|
||||||
|
export interface ScrubState {
|
||||||
|
index: number;
|
||||||
|
epoch: number;
|
||||||
|
}
|
||||||
|
export const clockScrub = writable<ScrubState>({ index: -1, epoch: 0 });
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Thin integration layer over @openmeteo/weather-map-layer for the native
|
||||||
|
* animated weather maps. Responsibilities:
|
||||||
|
*
|
||||||
|
* - register the `om://` MapLibre protocol once (shared settings/cache)
|
||||||
|
* - resolve drizzli's selected model id to a maps domain that actually serves
|
||||||
|
* the requested variable, falling back to a global model when the model has
|
||||||
|
* no map domain (or its run lacks the variable) - "use the global models as
|
||||||
|
* fallback"
|
||||||
|
* - build the fully-resolved `.om` tile-source URLs for a given model run and
|
||||||
|
* forecast time (the timestamp lives in the path, the variable in the query)
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type DomainMetaDataJson,
|
||||||
|
defaultOmProtocolSettings,
|
||||||
|
omProtocol
|
||||||
|
} from '@openmeteo/weather-map-layer';
|
||||||
|
import * as maplibregl from 'maplibre-gl';
|
||||||
|
|
||||||
|
import { modelDomainCandidates } from '$lib/utils/maps-domain';
|
||||||
|
|
||||||
|
/** Tile host. Always the production CDN (serves CORS/CORP for our headers). */
|
||||||
|
const BASE = 'https://map-tiles.open-meteo.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global models (widest coverage) tried after the selected model's own
|
||||||
|
* candidates. dwd_icon leads: it is truly global, hourly, and carries the
|
||||||
|
* longest run, which makes for the smoothest animation.
|
||||||
|
*/
|
||||||
|
const GLOBAL_FALLBACKS = ['dwd_icon', 'ecmwf_ifs025', 'ncep_gfs013'];
|
||||||
|
|
||||||
|
// Register the protocol exactly once with a single shared settings object.
|
||||||
|
export const omSettings = { ...defaultOmProtocolSettings };
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
export const registerOmProtocol = (): void => {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
maplibregl.addProtocol('om', (params, abortController) =>
|
||||||
|
omProtocol(params, abortController, omSettings)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pad = (n: number): string => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
/** Model-run directory, e.g. `2026/07/25/0600Z` (UTC). */
|
||||||
|
export const fmtModelRun = (run: Date): string =>
|
||||||
|
`${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/${pad(
|
||||||
|
run.getUTCHours()
|
||||||
|
)}${pad(run.getUTCMinutes())}Z`;
|
||||||
|
|
||||||
|
/** Forecast-time filename stem, e.g. `2026-07-25T1400` (UTC). */
|
||||||
|
export const fmtValidTime = (t: Date): string =>
|
||||||
|
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}T${pad(
|
||||||
|
t.getUTCHours()
|
||||||
|
)}${pad(t.getUTCMinutes())}`;
|
||||||
|
|
||||||
|
/** Plain https `.om` URL for a single forecast step. */
|
||||||
|
export const omFileUrl = (domain: string, run: Date, time: Date): string =>
|
||||||
|
`${BASE}/data_spatial/${domain}/${fmtModelRun(run)}/${fmtValidTime(time)}.om`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `om://` source URL for one forecast step of a given variable. The same URL
|
||||||
|
* serves both a raster field and (with `arrows`) a vector `wind-arrows` layer;
|
||||||
|
* MapLibre asks the protocol for image vs vector tiles per source type.
|
||||||
|
*/
|
||||||
|
export const omSourceUrl = (
|
||||||
|
domain: string,
|
||||||
|
run: Date,
|
||||||
|
time: Date,
|
||||||
|
variable: string,
|
||||||
|
dark: boolean,
|
||||||
|
arrows = false
|
||||||
|
): string =>
|
||||||
|
`om://${omFileUrl(domain, run, time)}?variable=${variable}${arrows ? '&arrows=true' : ''}${
|
||||||
|
dark ? '&dark=true' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
/** Ordered, de-duplicated domain candidates for a model, then global fallbacks. */
|
||||||
|
const domainCandidates = (model: string): string[] => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const d of [...(modelDomainCandidates[model] ?? [model]), ...GLOBAL_FALLBACKS]) {
|
||||||
|
if (!seen.has(d)) {
|
||||||
|
seen.add(d);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaCache = new Map<string, Promise<DomainMetaDataJson | null>>();
|
||||||
|
|
||||||
|
const fetchLatestMeta = (
|
||||||
|
domain: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<DomainMetaDataJson | null> => {
|
||||||
|
let pending = metaCache.get(domain);
|
||||||
|
if (!pending) {
|
||||||
|
pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal })
|
||||||
|
.then((r) => (r.ok ? (r.json() as Promise<DomainMetaDataJson>) : null))
|
||||||
|
.catch(() => null);
|
||||||
|
metaCache.set(domain, pending);
|
||||||
|
// short TTL so a fresh model run is picked up without a reload
|
||||||
|
setTimeout(() => metaCache.delete(domain), 60_000);
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ResolvedSource {
|
||||||
|
/** The maps domain we actually render (may be a global fallback). */
|
||||||
|
domain: string;
|
||||||
|
/** Model run of this domain's latest data. */
|
||||||
|
modelRun: Date;
|
||||||
|
/** Forecast steps as Dates, ascending. */
|
||||||
|
times: Date[];
|
||||||
|
/** True when we fell back off the user's selected model. */
|
||||||
|
fellBack: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/**
|
||||||
|
* Drop the first timestep. Accumulation variables (e.g. precipitation, an
|
||||||
|
* hourly sum) have no value at the analysis time; instantaneous variables
|
||||||
|
* (e.g. wind) do, so leave this off for them.
|
||||||
|
*/
|
||||||
|
skipFirst?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the selected model to a renderable source for `variable`. Probes each
|
||||||
|
* candidate domain's `latest.json` in order and returns the first that serves
|
||||||
|
* the variable. Global fallbacks are appended so there is (almost) always a
|
||||||
|
* result.
|
||||||
|
*/
|
||||||
|
export const resolveSource = async (
|
||||||
|
model: string,
|
||||||
|
variable: string,
|
||||||
|
opts: ResolveOptions = {}
|
||||||
|
): Promise<ResolvedSource | null> => {
|
||||||
|
const { signal, skipFirst } = opts;
|
||||||
|
const candidates = domainCandidates(model);
|
||||||
|
const preferred = new Set(modelDomainCandidates[model] ?? [model]);
|
||||||
|
|
||||||
|
for (const domain of candidates) {
|
||||||
|
const meta = await fetchLatestMeta(domain, signal);
|
||||||
|
if (signal?.aborted) return null;
|
||||||
|
if (!meta || !meta.variables?.includes(variable) || !meta.valid_times?.length) continue;
|
||||||
|
|
||||||
|
const sorted = meta.valid_times
|
||||||
|
.map((s) => new Date(s))
|
||||||
|
.filter((d) => !isNaN(d.getTime()))
|
||||||
|
.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
const times = skipFirst && sorted.length > 1 ? sorted.slice(1) : sorted;
|
||||||
|
if (!times.length) continue;
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
modelRun: new Date(meta.reference_time),
|
||||||
|
times,
|
||||||
|
fellBack: !preferred.has(domain)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
|
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
|
||||||
|
import { isPremium, premiumState, refreshPremium } from './premium';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Short feature name shown in the locked panel headline. */
|
||||||
|
feature?: string;
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { feature = 'This page', children }: Props = $props();
|
||||||
|
|
||||||
|
let unlockOpen = $state(false);
|
||||||
|
const price = getPremiumPrice();
|
||||||
|
|
||||||
|
// Re-verify the stored key whenever the gate mounts.
|
||||||
|
onMount(refreshPremium);
|
||||||
|
|
||||||
|
// Show a brief spinner only when we have no cached answer yet and are checking.
|
||||||
|
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $isPremium}
|
||||||
|
{@render children()}
|
||||||
|
{:else if initialChecking}
|
||||||
|
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
|
||||||
|
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Checking your subscription…
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mx-auto max-w-xl rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-sm"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<!-- padlock -->
|
||||||
|
<svg
|
||||||
|
class="h-7 w-7"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
|
||||||
|
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||||
|
Support this open-source weather project and unlock the extras from {price}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||||
|
{#each PREMIUM_PERKS as perk (perk)}
|
||||||
|
<li class="flex items-start gap-2.5 text-sm">
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
<span>{perk}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
||||||
|
<a
|
||||||
|
href={SIGNUP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
|
||||||
|
onclick={() => (unlockOpen = true)}
|
||||||
|
>
|
||||||
|
I have a key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $premiumState.status === 'invalid'}
|
||||||
|
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
Your saved key is no longer valid or has expired.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<UnlockDialog bind:open={unlockOpen} />
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import UnlockDialog from './UnlockDialog.svelte';
|
||||||
|
import { isPremium, refreshPremium } from './premium';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
// Verify once on load so the badge reflects real status site-wide.
|
||||||
|
onMount(refreshPremium);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors {$isPremium
|
||||||
|
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
|
||||||
|
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
|
||||||
|
onclick={() => (open = true)}
|
||||||
|
title={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||||
|
aria-label={$isPremium ? 'Premium active' : 'Unlock premium'}
|
||||||
|
>
|
||||||
|
{#if $isPremium}
|
||||||
|
<!-- star -->
|
||||||
|
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- padlock -->
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="4" y="10" width="16" height="11" rx="2" />
|
||||||
|
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<UnlockDialog bind:open />
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
import { SIGNUP_URL, getPremiumPrice } from './config';
|
||||||
|
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
const price = getPremiumPrice();
|
||||||
|
|
||||||
|
// Prefill with the stored key so an existing subscriber sees their key.
|
||||||
|
let keyInput = $state('');
|
||||||
|
let submitting = $state(false);
|
||||||
|
let localError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
keyInput = $storedLicenseKey;
|
||||||
|
localError = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
localError = null;
|
||||||
|
const result = await verifyKey(keyInput);
|
||||||
|
submitting = false;
|
||||||
|
if (result.valid) {
|
||||||
|
// close shortly so the success state is visible for a beat
|
||||||
|
setTimeout(() => (open = false), 700);
|
||||||
|
} else {
|
||||||
|
localError = result.error ?? 'That key is not valid or has expired.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKey() {
|
||||||
|
clearLicense();
|
||||||
|
keyInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let expiresLabel = $derived.by(() => {
|
||||||
|
const exp = $premiumState.expires;
|
||||||
|
if ($premiumState.status !== 'valid') return null;
|
||||||
|
if (!exp) return 'Lifetime access';
|
||||||
|
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Drizz.li Premium</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Paste the access key from your subscription email to unlock premium pages.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
{#if $isPremium && $premiumState.status === 'valid'}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="mt-0.5 h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4" />
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">Premium is active</p>
|
||||||
|
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={submit} class="grid gap-3">
|
||||||
|
<div class="grid gap-1.5">
|
||||||
|
<Label for="license-key">Access key</Label>
|
||||||
|
<Input
|
||||||
|
id="license-key"
|
||||||
|
bind:value={keyInput}
|
||||||
|
placeholder="DRZ-XXXX-XXXX-XXXX"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
class="font-mono tracking-wide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if localError}
|
||||||
|
<p class="text-sm text-destructive">{localError}</p>
|
||||||
|
{:else if $premiumState.status === 'error'}
|
||||||
|
<p class="text-sm text-destructive">
|
||||||
|
Couldn't reach the server. Check your connection and try again.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
||||||
|
{#if submitting}Verifying…{:else}Unlock{/if}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
|
||||||
|
{#if $isPremium}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||||
|
onclick={removeKey}
|
||||||
|
>
|
||||||
|
Remove key from this device
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-center text-xs text-muted-foreground">
|
||||||
|
No key yet?
|
||||||
|
<a
|
||||||
|
href={SIGNUP_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Subscribe from {price}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Paywall configuration.
|
||||||
|
*
|
||||||
|
* The frontend stays fully static and open source; the only server piece is the
|
||||||
|
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
|
||||||
|
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
|
||||||
|
* `.env` file), otherwise the sensible drizz.li defaults are used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const env = import.meta.env as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
/** Base URL of the self-hosted verify API (drizzli-paywall). */
|
||||||
|
export const PAYWALL_API_BASE = stripTrailingSlash(
|
||||||
|
env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
|
||||||
|
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
|
||||||
|
|
||||||
|
// ─── Location-based pricing ──────────────────────────────────────────────────
|
||||||
|
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
|
||||||
|
// from their location. Mirror that here so the paywall copy matches. Detection
|
||||||
|
// is timezone/locale based (no network geo lookup) and guarded for SSR.
|
||||||
|
|
||||||
|
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
|
||||||
|
const US_ZONES =
|
||||||
|
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
|
||||||
|
|
||||||
|
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
|
||||||
|
|
||||||
|
export function detectCurrency(): PremiumCurrency {
|
||||||
|
if (typeof Intl === 'undefined') return 'EUR';
|
||||||
|
let tz = '';
|
||||||
|
try {
|
||||||
|
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
|
||||||
|
let region = '';
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined') {
|
||||||
|
region = new Intl.Locale(navigator.language).maximize().region || '';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (region === 'CH') return 'CHF';
|
||||||
|
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
|
||||||
|
return 'EUR';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
|
||||||
|
export function getPremiumPrice(): string {
|
||||||
|
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
|
||||||
|
const currency = detectCurrency();
|
||||||
|
const symbol = CURRENCY_SYMBOL[currency];
|
||||||
|
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short, human list of what premium unlocks (shown on the locked panel). */
|
||||||
|
export const PREMIUM_PERKS = [
|
||||||
|
'Historical weather & climate-normal comparisons',
|
||||||
|
'New premium features as they land'
|
||||||
|
];
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Premium (subscription) state.
|
||||||
|
*
|
||||||
|
* The user pastes an access key once; it is stored locally and re-verified
|
||||||
|
* against the self-hosted verify API on load. The last good result is cached so
|
||||||
|
* gated content shows instantly on reload (and keeps working briefly offline)
|
||||||
|
* without waiting for the network round-trip.
|
||||||
|
*
|
||||||
|
* This gate is a convenience/honor-system gate: the frontend is open source and
|
||||||
|
* static, so it can be bypassed. Keeping the subscriber list server-side (in the
|
||||||
|
* paywall repo) is what makes it meaningful in practice.
|
||||||
|
*/
|
||||||
|
import { derived, get, writable } from 'svelte/store';
|
||||||
|
|
||||||
|
import { persisted } from 'svelte-persisted-store';
|
||||||
|
|
||||||
|
import { PAYWALL_API_BASE } from './config';
|
||||||
|
|
||||||
|
/** The subscriber's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||||
|
export const storedLicenseKey = persisted<string>('license_key', '');
|
||||||
|
|
||||||
|
export interface PremiumCache {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
/** ISO date the subscription lapses, or null for a lifetime key. */
|
||||||
|
expires?: string | null;
|
||||||
|
/** epoch ms of the last verify call */
|
||||||
|
checkedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last verify result, persisted so the UI doesn't flash "locked" on reload. */
|
||||||
|
export const storedPremiumCache = persisted<PremiumCache | null>('premium_cache_v1', null);
|
||||||
|
|
||||||
|
export type PremiumStatus = 'idle' | 'checking' | 'valid' | 'invalid' | 'error';
|
||||||
|
|
||||||
|
export interface PremiumState {
|
||||||
|
status: PremiumStatus;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live verification state for the current session. */
|
||||||
|
export const premiumState = writable<PremiumState>({ status: 'idle' });
|
||||||
|
|
||||||
|
function notExpired(expires: string | null | undefined): boolean {
|
||||||
|
if (!expires) return true; // lifetime key
|
||||||
|
const t = Date.parse(expires);
|
||||||
|
return Number.isFinite(t) && t > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether premium content should be shown. A live "valid"/"invalid" result wins;
|
||||||
|
* otherwise we fall back to the cached result (so a reload or a brief network
|
||||||
|
* blip doesn't lock a paying user out).
|
||||||
|
*/
|
||||||
|
export const isPremium = derived(
|
||||||
|
[premiumState, storedPremiumCache],
|
||||||
|
([$state, $cache]): boolean => {
|
||||||
|
if ($state.status === 'valid') return true;
|
||||||
|
if ($state.status === 'invalid') return false;
|
||||||
|
return !!($cache && $cache.valid && notExpired($cache.expires));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
valid: boolean;
|
||||||
|
tier?: string;
|
||||||
|
expires?: string | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a key against the API. On success the key is persisted and the cache
|
||||||
|
* updated. On an invalid key the stored key is left untouched (so an expired
|
||||||
|
* subscription can still show a "renew" state) but the cache is marked invalid.
|
||||||
|
*/
|
||||||
|
export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
premiumState.set({ status: 'invalid' });
|
||||||
|
return { valid: false, error: 'Enter your access key.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
premiumState.set({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${PAYWALL_API_BASE}/verify?key=${encodeURIComponent(trimmed)}`, {
|
||||||
|
headers: { accept: 'application/json' }
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as VerifyResult;
|
||||||
|
|
||||||
|
if (res.ok && data.valid) {
|
||||||
|
storedLicenseKey.set(trimmed);
|
||||||
|
storedPremiumCache.set({
|
||||||
|
valid: true,
|
||||||
|
tier: data.tier,
|
||||||
|
expires: data.expires ?? null,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
});
|
||||||
|
premiumState.set({ status: 'valid', tier: data.tier, expires: data.expires ?? null });
|
||||||
|
return { valid: true, tier: data.tier, expires: data.expires ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
storedPremiumCache.set({ valid: false, checkedAt: Date.now() });
|
||||||
|
premiumState.set({ status: 'invalid' });
|
||||||
|
return { valid: false };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
premiumState.set({ status: 'error', error: message });
|
||||||
|
return { valid: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-verify the stored key (call on app / gated-page load). No-op if signed out. */
|
||||||
|
export async function refreshPremium(): Promise<void> {
|
||||||
|
const key = get(storedLicenseKey);
|
||||||
|
if (!key) {
|
||||||
|
premiumState.set({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await verifyKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget the key and premium state ("sign out"). */
|
||||||
|
export function clearLicense(): void {
|
||||||
|
storedLicenseKey.set('');
|
||||||
|
storedPremiumCache.set(null);
|
||||||
|
premiumState.set({ status: 'idle' });
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export {
|
|
||||||
fetchWeekForecast,
|
|
||||||
fetchModelComparison,
|
|
||||||
fetchEnsembleForecast,
|
|
||||||
range,
|
|
||||||
getTimestamps,
|
|
||||||
getDates,
|
|
||||||
getValues,
|
|
||||||
getInt64Values,
|
|
||||||
unitToDisplayString
|
|
||||||
} from './weather';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
WeatherLocation,
|
|
||||||
WeatherUnitParams,
|
|
||||||
MarkArea,
|
|
||||||
WeekForecastParams,
|
|
||||||
WeekHourlyData,
|
|
||||||
WeekDailyData,
|
|
||||||
WeekForecastResult,
|
|
||||||
ModelCompareParams,
|
|
||||||
ModelSeriesData,
|
|
||||||
ModelCompareResult,
|
|
||||||
EnsembleForecastParams,
|
|
||||||
EnsembleVariableData,
|
|
||||||
EnsembleForecastResult
|
|
||||||
} from './weather';
|
|
||||||
+353
-27
@@ -13,7 +13,7 @@
|
|||||||
import { Unit } from '@openmeteo/sdk/unit';
|
import { Unit } from '@openmeteo/sdk/unit';
|
||||||
import { fetchWeatherApi } from 'openmeteo';
|
import { fetchWeatherApi } from 'openmeteo';
|
||||||
|
|
||||||
import { buildDaylightMarkAreas } from '$lib/utils/echarts';
|
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
|
||||||
|
|
||||||
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
||||||
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||||
@@ -22,6 +22,7 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
|||||||
|
|
||||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||||
|
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
|
||||||
|
|
||||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ export interface WeatherUnitParams {
|
|||||||
precipitation_unit?: 'mm' | 'inch';
|
precipitation_unit?: 'mm' | 'inch';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }];
|
export type { DaylightBand };
|
||||||
|
|
||||||
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -165,6 +166,8 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
|||||||
model?: string;
|
model?: string;
|
||||||
forecast_days?: number;
|
forecast_days?: number;
|
||||||
past_days?: number;
|
past_days?: number;
|
||||||
|
/** Hourly API variables to request; defaults to the full core set */
|
||||||
|
hourlyVariables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekHourlyData {
|
export interface WeekHourlyData {
|
||||||
@@ -178,6 +181,19 @@ export interface WeekHourlyData {
|
|||||||
relative_humidity_2m: number[];
|
relative_humidity_2m: number[];
|
||||||
apparent_temperature: number[];
|
apparent_temperature: number[];
|
||||||
dew_point_2m: number[];
|
dew_point_2m: number[];
|
||||||
|
// Additional popular variables available for the customizable meteograms
|
||||||
|
wind_gusts_10m: number[];
|
||||||
|
pressure_msl: number[];
|
||||||
|
surface_pressure: number[];
|
||||||
|
rain: number[];
|
||||||
|
showers: number[];
|
||||||
|
snowfall: number[];
|
||||||
|
cloud_cover_low: number[];
|
||||||
|
cloud_cover_mid: number[];
|
||||||
|
cloud_cover_high: number[];
|
||||||
|
uv_index: number[];
|
||||||
|
visibility: number[];
|
||||||
|
cape: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekDailyData {
|
export interface WeekDailyData {
|
||||||
@@ -201,7 +217,7 @@ export interface WeekForecastResult {
|
|||||||
hourlyTimestamps: number[];
|
hourlyTimestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
dailyDates: Date[];
|
dailyDates: Date[];
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
||||||
@@ -221,7 +237,7 @@ export interface ModelCompareResult {
|
|||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
sunrise: number[];
|
sunrise: number[];
|
||||||
sunset: number[];
|
sunset: number[];
|
||||||
units: Record<string, string>;
|
units: Record<string, string>;
|
||||||
@@ -251,7 +267,7 @@ export interface EnsembleForecastResult {
|
|||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
||||||
hourlyFlat: Record<string, number[]>;
|
hourlyFlat: Record<string, number[]>;
|
||||||
hourlyUnitsFlat: Record<string, string>;
|
hourlyUnitsFlat: Record<string, string>;
|
||||||
@@ -259,6 +275,9 @@ export interface EnsembleForecastResult {
|
|||||||
|
|
||||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Fallback set when the caller does not specify which hourly variables it
|
||||||
|
// needs. Callers normally pass an explicit list so only shown variables are
|
||||||
|
// requested.
|
||||||
const WEEK_HOURLY_VARS = [
|
const WEEK_HOURLY_VARS = [
|
||||||
'temperature_2m',
|
'temperature_2m',
|
||||||
'precipitation',
|
'precipitation',
|
||||||
@@ -285,6 +304,40 @@ const WEEK_DAILY_VARS = [
|
|||||||
'wind_direction_10m_dominant'
|
'wind_direction_10m_dominant'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles a WeekHourlyData structure from a name→values map, so variables that
|
||||||
|
* were not requested resolve to empty arrays. Shared by the week and historical
|
||||||
|
* fetchers (both return the same hourly shape, which the meteograms and hourly
|
||||||
|
* table consume).
|
||||||
|
*/
|
||||||
|
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
|
||||||
|
const g = (name: string): number[] => byName[name] ?? [];
|
||||||
|
return {
|
||||||
|
temperature_2m: g('temperature_2m'),
|
||||||
|
precipitation: g('precipitation'),
|
||||||
|
precipitation_probability: g('precipitation_probability'),
|
||||||
|
weather_code: g('weather_code'),
|
||||||
|
windspeed_10m: g('wind_speed_10m'),
|
||||||
|
winddirection_10m: g('wind_direction_10m'),
|
||||||
|
cloud_cover: g('cloud_cover'),
|
||||||
|
relative_humidity_2m: g('relative_humidity_2m'),
|
||||||
|
apparent_temperature: g('apparent_temperature'),
|
||||||
|
dew_point_2m: g('dew_point_2m'),
|
||||||
|
wind_gusts_10m: g('wind_gusts_10m'),
|
||||||
|
pressure_msl: g('pressure_msl'),
|
||||||
|
surface_pressure: g('surface_pressure'),
|
||||||
|
rain: g('rain'),
|
||||||
|
showers: g('showers'),
|
||||||
|
snowfall: g('snowfall'),
|
||||||
|
cloud_cover_low: g('cloud_cover_low'),
|
||||||
|
cloud_cover_mid: g('cloud_cover_mid'),
|
||||||
|
cloud_cover_high: g('cloud_cover_high'),
|
||||||
|
uv_index: g('uv_index'),
|
||||||
|
visibility: g('visibility'),
|
||||||
|
cape: g('cape')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the 7-day (week) weather forecast for a single location and model.
|
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||||
* Returns typed hourly and daily data structures.
|
* Returns typed hourly and daily data structures.
|
||||||
@@ -294,10 +347,16 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const pastDays = params.past_days ?? 0;
|
const pastDays = params.past_days ?? 0;
|
||||||
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
||||||
|
|
||||||
|
// Request only the variables the caller needs; fall back to the core set.
|
||||||
|
const hourlyVars =
|
||||||
|
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||||
|
? [...new Set(params.hourlyVariables)]
|
||||||
|
: [...WEEK_HOURLY_VARS];
|
||||||
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {
|
const apiParams: Record<string, string | number | undefined> = {
|
||||||
latitude: params.latitude,
|
latitude: params.latitude,
|
||||||
longitude: params.longitude,
|
longitude: params.longitude,
|
||||||
hourly: WEEK_HOURLY_VARS.join(','),
|
hourly: hourlyVars.join(','),
|
||||||
daily: WEEK_DAILY_VARS.join(','),
|
daily: WEEK_DAILY_VARS.join(','),
|
||||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
@@ -328,18 +387,15 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||||
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||||
|
|
||||||
const hourly: WeekHourlyData = {
|
// Values come back in the requested order; index them by API name so
|
||||||
temperature_2m: getValues(hourlyBlock.variables(0)!),
|
// variables that were not requested resolve to empty arrays.
|
||||||
precipitation: getValues(hourlyBlock.variables(1)!),
|
const byName: Record<string, number[]> = {};
|
||||||
precipitation_probability: getValues(hourlyBlock.variables(2)!),
|
hourlyVars.forEach((name, i) => {
|
||||||
weather_code: getValues(hourlyBlock.variables(3)!),
|
const variable = hourlyBlock.variables(i);
|
||||||
windspeed_10m: getValues(hourlyBlock.variables(4)!),
|
byName[name] = variable ? getValues(variable) : [];
|
||||||
winddirection_10m: getValues(hourlyBlock.variables(5)!),
|
});
|
||||||
cloud_cover: getValues(hourlyBlock.variables(6)!),
|
|
||||||
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
|
const hourly = weekHourlyFromByName(byName);
|
||||||
apparent_temperature: getValues(hourlyBlock.variables(8)!),
|
|
||||||
dew_point_2m: getValues(hourlyBlock.variables(9)!)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||||
const dailyDates = getDates(dailyBlock);
|
const dailyDates = getDates(dailyBlock);
|
||||||
@@ -360,7 +416,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
|
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hourly,
|
hourly,
|
||||||
@@ -370,7 +426,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
|
|||||||
hourlyTimestamps,
|
hourlyTimestamps,
|
||||||
hourlyDates,
|
hourlyDates,
|
||||||
dailyDates,
|
dailyDates,
|
||||||
markAreas
|
daylightBands
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +465,7 @@ export async function fetchModelComparison(
|
|||||||
const timestamps = getTimestamps(hourlyBlock);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
|
|
||||||
// Extract sunrise/sunset from the first response's daily block
|
// Extract sunrise/sunset from the first response's daily block
|
||||||
let markAreas: MarkArea[] = [];
|
let daylightBands: DaylightBand[] = [];
|
||||||
let sunrise: number[] = [];
|
let sunrise: number[] = [];
|
||||||
let sunset: number[] = [];
|
let sunset: number[] = [];
|
||||||
const dailyBlock = firstResponse.daily();
|
const dailyBlock = firstResponse.daily();
|
||||||
@@ -418,7 +474,7 @@ export async function fetchModelComparison(
|
|||||||
const sunsetVar = dailyBlock.variables(1)!;
|
const sunsetVar = dailyBlock.variables(1)!;
|
||||||
sunrise = getInt64Values(sunriseVar);
|
sunrise = getInt64Values(sunriseVar);
|
||||||
sunset = getInt64Values(sunsetVar);
|
sunset = getInt64Values(sunsetVar);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process each model's response
|
// Process each model's response
|
||||||
@@ -474,7 +530,7 @@ export async function fetchModelComparison(
|
|||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
timezone,
|
timezone,
|
||||||
markAreas,
|
daylightBands,
|
||||||
sunrise,
|
sunrise,
|
||||||
sunset,
|
sunset,
|
||||||
units,
|
units,
|
||||||
@@ -531,15 +587,15 @@ export async function fetchEnsembleForecast(
|
|||||||
const timestamps = getTimestamps(hourlyBlock);
|
const timestamps = getTimestamps(hourlyBlock);
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
|
|
||||||
// Extract sunrise/sunset for mark areas
|
// Extract sunrise/sunset for daylight bands
|
||||||
let markAreas: MarkArea[] = [];
|
let daylightBands: DaylightBand[] = [];
|
||||||
if (dailyResponses.length > 0) {
|
if (dailyResponses.length > 0) {
|
||||||
const dailyResponse = dailyResponses[0];
|
const dailyResponse = dailyResponses[0];
|
||||||
const dailyBlock = dailyResponse.daily();
|
const dailyBlock = dailyResponse.daily();
|
||||||
if (dailyBlock) {
|
if (dailyBlock) {
|
||||||
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
||||||
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,7 +685,7 @@ export async function fetchEnsembleForecast(
|
|||||||
timestamps,
|
timestamps,
|
||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
timezone,
|
timezone,
|
||||||
markAreas,
|
daylightBands,
|
||||||
hourlyFlat,
|
hourlyFlat,
|
||||||
hourlyUnitsFlat
|
hourlyUnitsFlat
|
||||||
};
|
};
|
||||||
@@ -758,3 +814,273 @@ function modelEnumToString(modelEnum: number): string {
|
|||||||
};
|
};
|
||||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Historical (Archive) Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HistoricalDailyData {
|
||||||
|
weather_code: number[];
|
||||||
|
temperature_2m_max: number[];
|
||||||
|
temperature_2m_min: number[];
|
||||||
|
temperature_2m_mean: number[];
|
||||||
|
apparent_temperature_max: number[];
|
||||||
|
apparent_temperature_min: number[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
|
sunshine_duration: number[];
|
||||||
|
precipitation_sum: number[];
|
||||||
|
rain_sum: number[];
|
||||||
|
snowfall_sum: number[];
|
||||||
|
precipitation_hours: number[];
|
||||||
|
windspeed_10m_max: number[];
|
||||||
|
windgusts_10m_max: number[];
|
||||||
|
winddirection_10m_dominant: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
/** Inclusive range, YYYY-MM-DD (location-local dates). */
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
/** Hourly API variables to request; defaults to the core week set. */
|
||||||
|
hourlyVariables?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalForecastResult {
|
||||||
|
hourly: WeekHourlyData;
|
||||||
|
daily: HistoricalDailyData;
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
timezone: string;
|
||||||
|
hourlyTimestamps: number[];
|
||||||
|
hourlyDates: Date[];
|
||||||
|
dailyDates: Date[];
|
||||||
|
daylightBands: DaylightBand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requested in this exact order; the daily block returns variables positionally.
|
||||||
|
const HISTORICAL_DAILY_VARS = [
|
||||||
|
'weather_code',
|
||||||
|
'temperature_2m_max',
|
||||||
|
'temperature_2m_min',
|
||||||
|
'temperature_2m_mean',
|
||||||
|
'apparent_temperature_max',
|
||||||
|
'apparent_temperature_min',
|
||||||
|
'sunrise',
|
||||||
|
'sunset',
|
||||||
|
'sunshine_duration',
|
||||||
|
'precipitation_sum',
|
||||||
|
'rain_sum',
|
||||||
|
'snowfall_sum',
|
||||||
|
'precipitation_hours',
|
||||||
|
'wind_speed_10m_max',
|
||||||
|
'wind_gusts_10m_max',
|
||||||
|
'wind_direction_10m_dominant'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
|
||||||
|
* archive API. Returns the same hourly shape as the week forecast (so the
|
||||||
|
* existing meteograms and hourly table render it unchanged) plus a richer daily
|
||||||
|
* block for the climate/statistics view.
|
||||||
|
*/
|
||||||
|
export async function fetchHistoricalWeather(
|
||||||
|
params: HistoricalForecastParams
|
||||||
|
): Promise<HistoricalForecastResult> {
|
||||||
|
const hourlyVars =
|
||||||
|
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||||
|
? [...new Set(params.hourlyVariables)]
|
||||||
|
: [...WEEK_HOURLY_VARS];
|
||||||
|
|
||||||
|
const apiParams: Record<string, string | number | undefined> = {
|
||||||
|
latitude: params.latitude,
|
||||||
|
longitude: params.longitude,
|
||||||
|
start_date: params.start_date,
|
||||||
|
end_date: params.end_date,
|
||||||
|
hourly: hourlyVars.join(','),
|
||||||
|
daily: HISTORICAL_DAILY_VARS.join(','),
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: params.timezone
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanParams: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(apiParams)) {
|
||||||
|
if (value !== undefined) cleanParams[key] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
|
||||||
|
const response = responses[0];
|
||||||
|
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||||
|
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||||
|
|
||||||
|
const hourlyBlock = response.hourly()!;
|
||||||
|
const dailyBlock = response.daily()!;
|
||||||
|
|
||||||
|
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||||
|
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||||
|
|
||||||
|
const byName: Record<string, number[]> = {};
|
||||||
|
hourlyVars.forEach((name, i) => {
|
||||||
|
const variable = hourlyBlock.variables(i);
|
||||||
|
byName[name] = variable ? getValues(variable) : [];
|
||||||
|
});
|
||||||
|
const hourly = weekHourlyFromByName(byName);
|
||||||
|
|
||||||
|
// Daily variables come back in HISTORICAL_DAILY_VARS order.
|
||||||
|
const dailyDates = getDates(dailyBlock);
|
||||||
|
const d = (i: number): number[] => {
|
||||||
|
const v = dailyBlock.variables(i);
|
||||||
|
return v ? getValues(v) : [];
|
||||||
|
};
|
||||||
|
const sunrise = getInt64Values(dailyBlock.variables(6)!);
|
||||||
|
const sunset = getInt64Values(dailyBlock.variables(7)!);
|
||||||
|
|
||||||
|
const daily: HistoricalDailyData = {
|
||||||
|
weather_code: d(0),
|
||||||
|
temperature_2m_max: d(1),
|
||||||
|
temperature_2m_min: d(2),
|
||||||
|
temperature_2m_mean: d(3),
|
||||||
|
apparent_temperature_max: d(4),
|
||||||
|
apparent_temperature_min: d(5),
|
||||||
|
sunrise,
|
||||||
|
sunset,
|
||||||
|
sunshine_duration: d(8),
|
||||||
|
precipitation_sum: d(9),
|
||||||
|
rain_sum: d(10),
|
||||||
|
snowfall_sum: d(11),
|
||||||
|
precipitation_hours: d(12),
|
||||||
|
windspeed_10m_max: d(13),
|
||||||
|
windgusts_10m_max: d(14),
|
||||||
|
winddirection_10m_dominant: d(15)
|
||||||
|
};
|
||||||
|
|
||||||
|
const daylightBands = buildDaylightBands(sunrise, sunset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hourly,
|
||||||
|
daily,
|
||||||
|
utcOffsetSeconds,
|
||||||
|
timezone,
|
||||||
|
hourlyTimestamps,
|
||||||
|
hourlyDates,
|
||||||
|
dailyDates,
|
||||||
|
daylightBands
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Climate Normals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ClimateNormals {
|
||||||
|
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
|
||||||
|
tmax: number[];
|
||||||
|
tmin: number[];
|
||||||
|
tmean: number[];
|
||||||
|
/** Mean daily precipitation (per calendar day). */
|
||||||
|
precip: number[];
|
||||||
|
baseStart: string;
|
||||||
|
baseEnd: string;
|
||||||
|
temperature_unit: string;
|
||||||
|
precipitation_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
|
||||||
|
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
|
||||||
|
baseStart?: string;
|
||||||
|
baseEnd?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days before the first of each month in a leap reference year, so that a
|
||||||
|
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
|
||||||
|
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||||
|
|
||||||
|
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
|
||||||
|
export function monthDayToOrdinal(month: number, day: number): number {
|
||||||
|
const m = Math.min(12, Math.max(1, Math.round(month)));
|
||||||
|
return CUM_DAYS_LEAP[m - 1] + day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes daily climate normals for a location by averaging a multi-decade
|
||||||
|
* archive across years, per day-of-year, with a ±7-day smoothing window so the
|
||||||
|
* curve is stable. One archive request; used for the "vs normal" comparison.
|
||||||
|
*/
|
||||||
|
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
|
||||||
|
const baseStart = params.baseStart ?? '1991-01-01';
|
||||||
|
const baseEnd = params.baseEnd ?? '2020-12-31';
|
||||||
|
|
||||||
|
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
|
||||||
|
// timezone is irrelevant to a per-calendar-day normal.
|
||||||
|
const apiParams: Record<string, string> = {
|
||||||
|
latitude: String(params.latitude),
|
||||||
|
longitude: String(params.longitude),
|
||||||
|
start_date: baseStart,
|
||||||
|
end_date: baseEnd,
|
||||||
|
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||||
|
timezone: 'UTC'
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
|
||||||
|
const response = responses[0];
|
||||||
|
const dailyBlock = response.daily()!;
|
||||||
|
const dates = getDates(dailyBlock);
|
||||||
|
const tmaxV = getValues(dailyBlock.variables(0)!);
|
||||||
|
const tminV = getValues(dailyBlock.variables(1)!);
|
||||||
|
const tmeanV = getValues(dailyBlock.variables(2)!);
|
||||||
|
const precipV = getValues(dailyBlock.variables(3)!);
|
||||||
|
|
||||||
|
const N = 367; // ordinals 1..366
|
||||||
|
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
|
||||||
|
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
|
||||||
|
|
||||||
|
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
|
||||||
|
if (Number.isFinite(val)) {
|
||||||
|
bucket.sum[ord] += val;
|
||||||
|
bucket.cnt[ord] += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const dt = dates[i];
|
||||||
|
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
|
||||||
|
add(acc.tmax, ord, tmaxV[i]);
|
||||||
|
add(acc.tmin, ord, tminV[i]);
|
||||||
|
add(acc.tmean, ord, tmeanV[i]);
|
||||||
|
add(acc.precip, ord, precipV[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
|
||||||
|
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
|
||||||
|
|
||||||
|
// Circular ±window smoothing across the 366 ordinals (skips empty days).
|
||||||
|
const smooth = (arr: number[], window = 7): number[] => {
|
||||||
|
const out = new Array<number>(N).fill(NaN);
|
||||||
|
for (let o = 1; o <= 366; o++) {
|
||||||
|
let s = 0;
|
||||||
|
let c = 0;
|
||||||
|
for (let k = -window; k <= window; k++) {
|
||||||
|
const idx = ((o - 1 + k + 366) % 366) + 1;
|
||||||
|
const v = arr[idx];
|
||||||
|
if (Number.isFinite(v)) {
|
||||||
|
s += v;
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[o] = c > 0 ? s / c : NaN;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
tmax: smooth(mean(acc.tmax)),
|
||||||
|
tmin: smooth(mean(acc.tmin)),
|
||||||
|
tmean: smooth(mean(acc.tmean)),
|
||||||
|
precip: smooth(mean(acc.precip)),
|
||||||
|
baseStart,
|
||||||
|
baseEnd,
|
||||||
|
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||||
|
precipitation_unit: params.precipitation_unit ?? 'mm'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,3 +43,96 @@ export const defaultLocation: GeoLocation = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const storedLocation = persisted('stored_location', defaultLocation as GeoLocation);
|
export const storedLocation = persisted('stored_location', defaultLocation as GeoLocation);
|
||||||
|
|
||||||
|
export type Theme = 'system' | 'light' | 'dark';
|
||||||
|
|
||||||
|
export const storedTheme = persisted<Theme>('theme', 'system');
|
||||||
|
|
||||||
|
/** Selected forecast model, shared across the whole site. */
|
||||||
|
export const storedModel = persisted<string>('selected_model', 'best_match');
|
||||||
|
|
||||||
|
/** Which variables are visible in the hourly table and the meteograms. */
|
||||||
|
export interface VariablePrefs {
|
||||||
|
table: Record<string, boolean>;
|
||||||
|
charts: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultVariablePrefs: VariablePrefs = {
|
||||||
|
table: {
|
||||||
|
icons: true,
|
||||||
|
temperature: true,
|
||||||
|
feels: true,
|
||||||
|
wind: true,
|
||||||
|
humidity: true,
|
||||||
|
clouds: true,
|
||||||
|
precipitation: true,
|
||||||
|
// extra rows, off by default
|
||||||
|
dew_point: false,
|
||||||
|
gusts: false,
|
||||||
|
pressure: false,
|
||||||
|
uv: false,
|
||||||
|
visibility: false,
|
||||||
|
snowfall: false
|
||||||
|
},
|
||||||
|
charts: {
|
||||||
|
temperature: true,
|
||||||
|
cloud_cover: true,
|
||||||
|
precipitation: true,
|
||||||
|
precipitation_probability: true,
|
||||||
|
wind: true,
|
||||||
|
humidity: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||||
|
|
||||||
|
/** Hourly table interval: 3-hourly (default) or 1-hourly. */
|
||||||
|
export const storedHourlyInterval = persisted<1 | 3>('hourly_interval', 3);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meteogram layout: an ordered list of chart panels, each holding an ordered
|
||||||
|
* list of variable keys (see the chart variable registry). Users drag
|
||||||
|
* variables between panels to fully customise the meteograms.
|
||||||
|
*/
|
||||||
|
export interface ChartPanel {
|
||||||
|
id: string;
|
||||||
|
variables: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultChartLayout: ChartPanel[] = [
|
||||||
|
{ id: 'panel-1', variables: ['temperature', 'weather_icons'] },
|
||||||
|
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability', 'cloud_cover'] },
|
||||||
|
{ id: 'panel-3', variables: ['wind', 'wind_gusts', 'wind_direction'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defaultChartLayout);
|
||||||
|
|
||||||
|
/** Selected ensemble model for the 14-day spread forecast. */
|
||||||
|
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||||
|
|
||||||
|
/** Measurement units, shared across every forecast page and persisted. */
|
||||||
|
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',
|
||||||
|
time_format: '24h'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
|
||||||
|
|
||||||
|
/** Recently visited and starred locations, shown in the search dropdown. */
|
||||||
|
export const storedRecentLocations = persisted<GeoLocation[]>('recent_locations_v1', []);
|
||||||
|
export const storedFavoriteLocations = persisted<GeoLocation[]>('favorite_locations_v1', []);
|
||||||
|
|
||||||
|
/** Stable key for de-duping locations (geocoding id, or rounded coordinates). */
|
||||||
|
export function locationKey(l: GeoLocation): string {
|
||||||
|
return l.id && l.id !== 0 ? `id:${l.id}` : `c:${l.latitude.toFixed(3)},${l.longitude.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
|
||||||
import { isSameDay as isSameDayDateFns } from 'date-fns';
|
import { isSameDay as isSameDayDateFns } from 'date-fns';
|
||||||
|
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Download Utilities
|
|
||||||
*
|
|
||||||
* Provides programmatic chart export functionality for downloading
|
|
||||||
* charts as PNG or SVG images. Supports stitching multiple chart
|
|
||||||
* instances into a single combined meteogram image.
|
|
||||||
*/
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type ExportFormat = 'png' | 'svg';
|
|
||||||
|
|
||||||
export interface DownloadOptions {
|
|
||||||
/** The file name (without extension) */
|
|
||||||
fileName?: string;
|
|
||||||
/** Export format: 'png' or 'svg' */
|
|
||||||
format?: ExportFormat;
|
|
||||||
/** Pixel ratio for PNG exports (default: 2 for retina quality) */
|
|
||||||
pixelRatio?: number;
|
|
||||||
/** Background color (default: '#ffffff' for PNG, 'none' for SVG) */
|
|
||||||
backgroundColor?: string;
|
|
||||||
/** Components to exclude from the export (e.g. ['toolbox']) */
|
|
||||||
excludeComponents?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_FILE_NAME = 'open-meteo-chart';
|
|
||||||
const DEFAULT_PIXEL_RATIO = 2;
|
|
||||||
|
|
||||||
// ─── Download Functions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a single ECharts instance as an image file.
|
|
||||||
*
|
|
||||||
* @param chart - The ECharts instance to export
|
|
||||||
* @param options - Download configuration options
|
|
||||||
*/
|
|
||||||
export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void {
|
|
||||||
const {
|
|
||||||
fileName = DEFAULT_FILE_NAME,
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
const dataUrl = chart.getDataURL({
|
|
||||||
type: format === 'svg' ? 'svg' : 'png',
|
|
||||||
pixelRatio: format === 'png' ? pixelRatio : 1,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
|
|
||||||
triggerDownload(dataUrl, `${fileName}.${format}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads multiple ECharts instances stitched into a single combined
|
|
||||||
* meteogram image. Charts are stacked vertically in the order provided.
|
|
||||||
*
|
|
||||||
* For a single chart, delegates to `downloadChart`.
|
|
||||||
*
|
|
||||||
* @param charts - Array of ECharts instances to combine
|
|
||||||
* @param options - Download configuration options
|
|
||||||
*/
|
|
||||||
export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
|
|
||||||
const validCharts = charts.filter((c) => c && !c.isDisposed());
|
|
||||||
if (validCharts.length === 0) return;
|
|
||||||
|
|
||||||
if (validCharts.length === 1) {
|
|
||||||
downloadChart(validCharts[0], options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
fileName = DEFAULT_FILE_NAME,
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
|
||||||
downloadMeteogramSvg(validCharts, {
|
|
||||||
fileName,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadMeteogramPng(validCharts, {
|
|
||||||
fileName,
|
|
||||||
pixelRatio,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the data URL of a chart without triggering a download.
|
|
||||||
* Useful for previewing or embedding chart images programmatically.
|
|
||||||
*
|
|
||||||
* @param chart - The ECharts instance to export
|
|
||||||
* @param options - Export configuration options
|
|
||||||
* @returns A base64-encoded data URL string
|
|
||||||
*/
|
|
||||||
export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string {
|
|
||||||
const {
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
return chart.getDataURL({
|
|
||||||
type: format === 'svg' ? 'svg' : 'png',
|
|
||||||
pixelRatio: format === 'png' ? pixelRatio : 1,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal: PNG Meteogram ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PngStitchOptions {
|
|
||||||
fileName: string;
|
|
||||||
pixelRatio: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
excludeComponents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stitches multiple charts into a single PNG by rendering each chart's
|
|
||||||
* data URL onto an off-screen canvas, stacked vertically.
|
|
||||||
*/
|
|
||||||
function downloadMeteogramPng(charts: echarts.ECharts[], opts: PngStitchOptions): void {
|
|
||||||
const { fileName, pixelRatio, backgroundColor, excludeComponents } = opts;
|
|
||||||
|
|
||||||
const dataUrls = charts.map((chart) =>
|
|
||||||
chart.getDataURL({
|
|
||||||
type: 'png',
|
|
||||||
pixelRatio,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const images: HTMLImageElement[] = [];
|
|
||||||
let loadedCount = 0;
|
|
||||||
|
|
||||||
dataUrls.forEach((url, index) => {
|
|
||||||
const img = new Image();
|
|
||||||
images[index] = img;
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
loadedCount++;
|
|
||||||
if (loadedCount === dataUrls.length) {
|
|
||||||
composePngAndDownload(images, fileName, backgroundColor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
loadedCount++;
|
|
||||||
if (loadedCount === dataUrls.length) {
|
|
||||||
composePngAndDownload(images, fileName, backgroundColor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function composePngAndDownload(
|
|
||||||
images: HTMLImageElement[],
|
|
||||||
fileName: string,
|
|
||||||
backgroundColor: string
|
|
||||||
): void {
|
|
||||||
const validImages = images.filter((img) => img.naturalWidth > 0);
|
|
||||||
if (validImages.length === 0) return;
|
|
||||||
|
|
||||||
const maxWidth = Math.max(...validImages.map((img) => img.naturalWidth));
|
|
||||||
const totalHeight = validImages.reduce((sum, img) => sum + img.naturalHeight, 0);
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = maxWidth;
|
|
||||||
canvas.height = totalHeight;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'none') {
|
|
||||||
ctx.fillStyle = backgroundColor;
|
|
||||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
let y = 0;
|
|
||||||
for (const img of validImages) {
|
|
||||||
ctx.drawImage(img, 0, y);
|
|
||||||
y += img.naturalHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
|
||||||
triggerDownload(dataUrl, `${fileName}.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal: SVG Meteogram ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface SvgStitchOptions {
|
|
||||||
fileName: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
excludeComponents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stitches multiple charts into a single SVG by extracting each chart's
|
|
||||||
* SVG markup and embedding them as nested groups with vertical offsets.
|
|
||||||
*/
|
|
||||||
function downloadMeteogramSvg(charts: echarts.ECharts[], opts: SvgStitchOptions): void {
|
|
||||||
const { fileName, backgroundColor, excludeComponents } = opts;
|
|
||||||
|
|
||||||
const svgStrings = charts.map((chart) =>
|
|
||||||
chart.getDataURL({
|
|
||||||
type: 'svg',
|
|
||||||
pixelRatio: 1,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const fragments: { svg: SVGSVGElement; width: number; height: number }[] = [];
|
|
||||||
|
|
||||||
for (const svgDataUrl of svgStrings) {
|
|
||||||
const svgContent = decodeSvgDataUrl(svgDataUrl);
|
|
||||||
if (!svgContent) continue;
|
|
||||||
|
|
||||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
|
|
||||||
const svg = doc.querySelector('svg');
|
|
||||||
if (!svg) continue;
|
|
||||||
|
|
||||||
const width = parseFloat(svg.getAttribute('width') || '0');
|
|
||||||
const height = parseFloat(svg.getAttribute('height') || '0');
|
|
||||||
|
|
||||||
if (width > 0 && height > 0) {
|
|
||||||
fragments.push({ svg, width, height });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fragments.length === 0) return;
|
|
||||||
|
|
||||||
const maxWidth = Math.max(...fragments.map((f) => f.width));
|
|
||||||
const totalHeight = fragments.reduce((sum, f) => sum + f.height, 0);
|
|
||||||
|
|
||||||
let combinedSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${maxWidth}" height="${totalHeight}" viewBox="0 0 ${maxWidth} ${totalHeight}">`;
|
|
||||||
|
|
||||||
if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') {
|
|
||||||
combinedSvg += `<rect width="${maxWidth}" height="${totalHeight}" fill="${backgroundColor}"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let yOffset = 0;
|
|
||||||
for (const fragment of fragments) {
|
|
||||||
combinedSvg += `<g transform="translate(0,${yOffset})">`;
|
|
||||||
combinedSvg += fragment.svg.innerHTML;
|
|
||||||
combinedSvg += `</g>`;
|
|
||||||
yOffset += fragment.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSvg += `</svg>`;
|
|
||||||
|
|
||||||
const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
triggerDownload(url, `${fileName}.svg`);
|
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeSvgDataUrl(dataUrl: string): string | null {
|
|
||||||
try {
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) {
|
|
||||||
return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length));
|
|
||||||
}
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml;base64,')) {
|
|
||||||
return atob(dataUrl.slice('data:image/svg+xml;base64,'.length));
|
|
||||||
}
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml,')) {
|
|
||||||
return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a browser file download from a data URL or object URL.
|
|
||||||
* Creates a temporary anchor element, clicks it, and removes it.
|
|
||||||
*/
|
|
||||||
function triggerDownload(url: string, fileName: string): void {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.removeChild(link);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Utilities — Barrel Export
|
|
||||||
*
|
|
||||||
* Re-exports all ECharts-related utilities from a single entry point.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts';
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Theme: dark/light detection, color palettes, theme color accessors
|
|
||||||
export {
|
|
||||||
SERIES_COLORS,
|
|
||||||
CHART_COLORS,
|
|
||||||
isDarkMode,
|
|
||||||
getThemeColors,
|
|
||||||
getTextColor,
|
|
||||||
getAxisLineColor,
|
|
||||||
getSplitLineColor
|
|
||||||
} from './theme';
|
|
||||||
export type { ThemeColors } from './theme';
|
|
||||||
|
|
||||||
// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer
|
|
||||||
export {
|
|
||||||
buildGrid,
|
|
||||||
buildTitle,
|
|
||||||
buildTooltip,
|
|
||||||
buildLegend,
|
|
||||||
buildTimeXAxis,
|
|
||||||
buildValueYAxis,
|
|
||||||
buildCreditGraphic,
|
|
||||||
buildToolbox,
|
|
||||||
composeChartOption,
|
|
||||||
isColumnUnit
|
|
||||||
} from './options';
|
|
||||||
export type {
|
|
||||||
GridOptions,
|
|
||||||
TitleOptions,
|
|
||||||
LegendOptions,
|
|
||||||
TooltipOptions,
|
|
||||||
AxisOptions,
|
|
||||||
CreditOptions,
|
|
||||||
BuildGridParams,
|
|
||||||
ToolboxOptions,
|
|
||||||
ChartOptionParams
|
|
||||||
} from './options';
|
|
||||||
|
|
||||||
// Series builders: model lines, averages, time markers, daylight bands, ensemble spread
|
|
||||||
export {
|
|
||||||
buildModelSeries,
|
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightMarkAreas,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildSpreadSeries,
|
|
||||||
calculateAverage,
|
|
||||||
calculateSpread,
|
|
||||||
findUnit
|
|
||||||
} from './series';
|
|
||||||
export type {
|
|
||||||
ModelSeriesParams,
|
|
||||||
AverageSeriesParams,
|
|
||||||
CurrentTimeSeriesParams,
|
|
||||||
DaylightSeriesParams,
|
|
||||||
SpreadSeriesParams,
|
|
||||||
AverageResult,
|
|
||||||
SpreadResult
|
|
||||||
} from './series';
|
|
||||||
|
|
||||||
// Download: export charts as PNG or SVG
|
|
||||||
export { downloadChart, downloadMeteogram, getChartDataUrl } from './download';
|
|
||||||
export type { ExportFormat, DownloadOptions } from './download';
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Option Builders
|
|
||||||
*
|
|
||||||
* Shared factory functions for constructing common ECharts option fragments.
|
|
||||||
* These builders ensure visual consistency across all chart pages and reduce
|
|
||||||
* boilerplate in page-level components.
|
|
||||||
*/
|
|
||||||
import { formatZoned } from '../date';
|
|
||||||
import { getThemeColors } from './theme';
|
|
||||||
|
|
||||||
import type { ThemeColors } from './theme';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface GridOptions {
|
|
||||||
left?: number;
|
|
||||||
right?: number;
|
|
||||||
top?: number;
|
|
||||||
bottom?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TitleOptions {
|
|
||||||
text: string;
|
|
||||||
subtext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegendOptions {
|
|
||||||
show: boolean;
|
|
||||||
data?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TooltipOptions {
|
|
||||||
unit: string;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AxisOptions {
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreditOptions {
|
|
||||||
show: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Default Constants ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_GRID: GridOptions = {
|
|
||||||
left: 60,
|
|
||||||
right: 16,
|
|
||||||
top: 40,
|
|
||||||
bottom: 40
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRID_WITH_TITLE: Partial<GridOptions> = {
|
|
||||||
top: 80
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRID_WITH_LEGEND: Partial<GridOptions> = {
|
|
||||||
bottom: 60
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Grid ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BuildGridParams {
|
|
||||||
hasTitle?: boolean;
|
|
||||||
hasSubtitle?: boolean;
|
|
||||||
showLegend?: boolean;
|
|
||||||
overrides?: Partial<GridOptions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a grid configuration with sensible defaults.
|
|
||||||
* Automatically adjusts top/bottom spacing for title and legend presence.
|
|
||||||
*/
|
|
||||||
export function buildGrid(params: BuildGridParams = {}): GridOptions {
|
|
||||||
const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...DEFAULT_GRID,
|
|
||||||
...(hasTitle ? GRID_WITH_TITLE : {}),
|
|
||||||
...(hasSubtitle ? { top: 90 } : {}),
|
|
||||||
...(showLegend ? GRID_WITH_LEGEND : {}),
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Title ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a title configuration. Pass `null` to hide the title.
|
|
||||||
*/
|
|
||||||
export function buildTitle(
|
|
||||||
options: TitleOptions | null,
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return { title: { show: false } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
text: options.text,
|
|
||||||
left: 'left',
|
|
||||||
textStyle: {
|
|
||||||
fontWeight: 'normal',
|
|
||||||
fontSize: 16,
|
|
||||||
color: c.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.subtext) {
|
|
||||||
result.subtext = options.subtext;
|
|
||||||
result.subtextStyle = {
|
|
||||||
fontWeight: 'normal',
|
|
||||||
fontSize: 12,
|
|
||||||
color: c.textMuted
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tooltip ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a tooltip with cross-axis pointer and unit-aware value formatting.
|
|
||||||
*/
|
|
||||||
export function buildTooltip(
|
|
||||||
options: TooltipOptions,
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
const { unit, timezone } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
color: c.text,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
textStyle: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
formatter: timezone
|
|
||||||
? (
|
|
||||||
params: Array<{
|
|
||||||
axisValue: number;
|
|
||||||
seriesName: string;
|
|
||||||
marker: string;
|
|
||||||
value: number | number[] | null;
|
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
if (!params || params.length === 0) return '';
|
|
||||||
const date = new Date(params[0].axisValue);
|
|
||||||
let html = `<b>${formatZoned(date, timezone, 'EEE d MMM HH:mm')}</b><br/>`;
|
|
||||||
params.forEach((item) => {
|
|
||||||
if (item.seriesName === 'Daylight' || item.seriesName === 'Current Time') return;
|
|
||||||
const val = Array.isArray(item.value) ? item.value[1] : item.value;
|
|
||||||
if (val === null || val === undefined) return;
|
|
||||||
html += `${item.marker} ${item.seriesName}: <b>${val.toFixed(1)} ${unit}</b><br/>`;
|
|
||||||
});
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
valueFormatter: (value: number) => {
|
|
||||||
if (value === null || value === undefined) return '-';
|
|
||||||
return value.toFixed(1) + ' ' + unit;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Legend ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a scrollable legend configuration pinned to the bottom.
|
|
||||||
*/
|
|
||||||
export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: options.show,
|
|
||||||
bottom: 0,
|
|
||||||
type: 'scroll',
|
|
||||||
...(options.data ? { data: options.data } : {}),
|
|
||||||
textStyle: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
pageTextStyle: {
|
|
||||||
color: c.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a time-based X axis with theme-aware styling and timezone-aware labels.
|
|
||||||
*/
|
|
||||||
export function buildTimeXAxis(timezone?: string, colors?: ThemeColors): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'time',
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.axisLine
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisPointer: {
|
|
||||||
label: {
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { value: number }) =>
|
|
||||||
formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: c.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
formatter: timezone
|
|
||||||
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
axisTick: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.axisLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Y Axis (Value) ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a value-based Y axis with optional unit label.
|
|
||||||
*/
|
|
||||||
export function buildValueYAxis(
|
|
||||||
options: AxisOptions = {},
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'value',
|
|
||||||
...(options.unit ? { name: options.unit } : {}),
|
|
||||||
nameTextStyle: {
|
|
||||||
color: c.text,
|
|
||||||
padding: [0, 0, 0, 4]
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.splitLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Credit Watermark ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the Open-Meteo.com credit watermark graphic element.
|
|
||||||
*/
|
|
||||||
export function buildCreditGraphic(colors?: ThemeColors): Record<string, unknown>[] {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
right: 10,
|
|
||||||
bottom: 5,
|
|
||||||
style: {
|
|
||||||
text: 'Open-Meteo.com',
|
|
||||||
fontSize: 10,
|
|
||||||
fill: c.text,
|
|
||||||
opacity: 0.4
|
|
||||||
},
|
|
||||||
onclick: function () {
|
|
||||||
window.open('https://open-meteo.com', '_blank');
|
|
||||||
},
|
|
||||||
cursor: 'pointer'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Toolbox (Download) ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ToolboxOptions {
|
|
||||||
/** Show the save-as-image button */
|
|
||||||
saveAsImage?: boolean;
|
|
||||||
/** File name prefix for downloaded images */
|
|
||||||
fileName?: string;
|
|
||||||
/** Export format: 'png' or 'svg' */
|
|
||||||
format?: 'png' | 'svg';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the ECharts toolbox with download functionality.
|
|
||||||
*/
|
|
||||||
export function buildToolbox(
|
|
||||||
options: ToolboxOptions = {},
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: true,
|
|
||||||
right: 16,
|
|
||||||
top: 4,
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.textMuted
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
feature: {
|
|
||||||
...(saveAsImage
|
|
||||||
? {
|
|
||||||
saveAsImage: {
|
|
||||||
type: format,
|
|
||||||
name: fileName,
|
|
||||||
title: format === 'svg' ? 'Save as SVG' : 'Save as PNG',
|
|
||||||
pixelRatio: 2,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents: ['toolbox'],
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.textMuted
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Full Option Composer ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ChartOptionParams {
|
|
||||||
title?: TitleOptions | null;
|
|
||||||
tooltip: TooltipOptions;
|
|
||||||
legend?: LegendOptions;
|
|
||||||
grid?: BuildGridParams;
|
|
||||||
yAxis?: AxisOptions;
|
|
||||||
series: Array<Record<string, unknown>>;
|
|
||||||
toolbox?: ToolboxOptions | false;
|
|
||||||
showCredit?: boolean;
|
|
||||||
colors?: ThemeColors;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composes a complete ECharts option object from individual builder params.
|
|
||||||
* This is the primary entry point for building chart options — it calls all
|
|
||||||
* the individual builders and merges the results into a single config object.
|
|
||||||
*/
|
|
||||||
export function composeChartOption(params: ChartOptionParams): Record<string, unknown> {
|
|
||||||
const colors = params.colors ?? getThemeColors();
|
|
||||||
const hasTitle = params.title != null && params.title.text !== '';
|
|
||||||
const showLegend = params.legend?.show ?? false;
|
|
||||||
|
|
||||||
const option: Record<string, unknown> = {
|
|
||||||
title: buildTitle(params.title ?? null, colors),
|
|
||||||
tooltip: buildTooltip(params.tooltip, colors),
|
|
||||||
legend: buildLegend(params.legend ?? { show: false }, colors),
|
|
||||||
grid: buildGrid({
|
|
||||||
...params.grid,
|
|
||||||
hasTitle,
|
|
||||||
hasSubtitle: hasTitle && !!params.title?.subtext,
|
|
||||||
showLegend
|
|
||||||
}),
|
|
||||||
xAxis: buildTimeXAxis(params.timezone, colors),
|
|
||||||
yAxis: buildValueYAxis(params.yAxis, colors),
|
|
||||||
series: params.series,
|
|
||||||
textStyle: {
|
|
||||||
color: colors.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add toolbox unless explicitly disabled
|
|
||||||
if (params.toolbox !== false) {
|
|
||||||
option.toolbox = buildToolbox(params.toolbox ?? {}, colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add credit watermark
|
|
||||||
if (params.showCredit) {
|
|
||||||
option.graphic = buildCreditGraphic(colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
return option;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Utility: Detect column-type variables ───────────────────────────────────
|
|
||||||
|
|
||||||
/** Units that should be rendered as bar/column charts instead of lines. */
|
|
||||||
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the given unit should be rendered as a bar chart.
|
|
||||||
*/
|
|
||||||
export function isColumnUnit(unit: string): boolean {
|
|
||||||
return COLUMN_UNITS.has(unit);
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Series Builders
|
|
||||||
*
|
|
||||||
* Factory functions for constructing common series patterns used across
|
|
||||||
* weather chart visualizations. These builders encapsulate the styling
|
|
||||||
* and configuration details so page-level code only needs to provide data.
|
|
||||||
*/
|
|
||||||
import { isColumnUnit } from './options';
|
|
||||||
import { CHART_COLORS } from './theme';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ModelSeriesParams {
|
|
||||||
/** The series name (typically the model key from the API response) */
|
|
||||||
name: string;
|
|
||||||
/** Array of [timestamp, value] data points */
|
|
||||||
data: Array<[number, number | null]>;
|
|
||||||
/** The unit string, used to determine bar vs line rendering */
|
|
||||||
unit: string;
|
|
||||||
/** Optional line width override (default: 2) */
|
|
||||||
lineWidth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AverageSeriesParams {
|
|
||||||
/** The variable name, used to construct the series name */
|
|
||||||
variable: string;
|
|
||||||
/** Array of [timestamp, value] data points */
|
|
||||||
data: Array<[number, number]>;
|
|
||||||
/** The unit string, used to determine bar vs line rendering */
|
|
||||||
unit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentTimeSeriesParams {
|
|
||||||
/** UTC offset in seconds from the API response */
|
|
||||||
utcOffsetSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaylightSeriesParams {
|
|
||||||
/** Array of mark area pairs: [[start, end], [start, end], ...] */
|
|
||||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpreadSeriesParams {
|
|
||||||
/** The variable name, used to construct series names */
|
|
||||||
variable: string;
|
|
||||||
/** Array of [timestamp, min, max] data points */
|
|
||||||
spreadData: Array<[number, number, number]>;
|
|
||||||
/** Optional color for the spread area (default: theme spread color) */
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkAreaEntry {
|
|
||||||
xAxis: number;
|
|
||||||
itemStyle?: { color: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Model Series ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a single model series (line or bar depending on the unit).
|
|
||||||
* Used on the Model Comparison page where each weather model gets its own series.
|
|
||||||
*/
|
|
||||||
export function buildModelSeries(params: ModelSeriesParams): Record<string, unknown> {
|
|
||||||
const { name, data, unit, lineWidth = 2 } = params;
|
|
||||||
const isColumn = isColumnUnit(unit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
type: isColumn ? 'bar' : 'line',
|
|
||||||
data,
|
|
||||||
smooth: !isColumn,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: {
|
|
||||||
width: lineWidth
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
lineStyle: {
|
|
||||||
width: lineWidth + 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 5
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Average Series ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the ensemble/model average series.
|
|
||||||
* Rendered as a dashed line (or bar) that stands out from individual model lines.
|
|
||||||
*/
|
|
||||||
export function buildAverageSeries(params: AverageSeriesParams): Record<string, unknown> {
|
|
||||||
const { variable, data, unit } = params;
|
|
||||||
const isColumn = isColumnUnit(unit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: variable + '_average',
|
|
||||||
type: isColumn ? 'bar' : 'line',
|
|
||||||
data,
|
|
||||||
smooth: !isColumn,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: {
|
|
||||||
type: 'dashed',
|
|
||||||
width: 4,
|
|
||||||
color: CHART_COLORS.average
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: CHART_COLORS.average
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
lineStyle: {
|
|
||||||
width: 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 5,
|
|
||||||
z: 10
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Current Time Marker ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a helper series that renders a vertical red line at the current time.
|
|
||||||
* Uses an empty data series with a markLine to overlay onto the chart.
|
|
||||||
*/
|
|
||||||
export function buildCurrentTimeSeries(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
name: 'Current Time',
|
|
||||||
type: 'line',
|
|
||||||
data: [],
|
|
||||||
markLine: {
|
|
||||||
silent: true,
|
|
||||||
symbol: 'none',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
xAxis: Date.now(),
|
|
||||||
lineStyle: {
|
|
||||||
color: CHART_COLORS.currentTimeLine,
|
|
||||||
width: 2,
|
|
||||||
type: 'solid'
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Daylight Bands ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds mark area entries from sunrise/sunset arrays.
|
|
||||||
* Each entry is a pair of axis markers that ECharts renders as a shaded band.
|
|
||||||
*
|
|
||||||
* @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset)
|
|
||||||
* @param sunset - Array of sunset timestamps (unix seconds, without UTC offset)
|
|
||||||
* @param utcOffsetSeconds - UTC offset to apply (from the API response)
|
|
||||||
*/
|
|
||||||
export function buildDaylightMarkAreas(
|
|
||||||
sunrise: number[],
|
|
||||||
sunset: number[]
|
|
||||||
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
|
||||||
return sunrise.map((r: number, i: number) => [
|
|
||||||
{
|
|
||||||
xAxis: r * 1000,
|
|
||||||
itemStyle: {
|
|
||||||
color: CHART_COLORS.daylight
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
xAxis: sunset[i] * 1000
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a helper series that renders day/night shading bands via markArea.
|
|
||||||
* Returns null if no mark areas are provided (so callers can filter it out).
|
|
||||||
*/
|
|
||||||
export function buildDaylightSeries(params: DaylightSeriesParams): Record<string, unknown> | null {
|
|
||||||
if (params.markAreas.length === 0) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'Daylight',
|
|
||||||
type: 'line',
|
|
||||||
data: [],
|
|
||||||
markArea: {
|
|
||||||
silent: true,
|
|
||||||
data: params.markAreas
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Ensemble Spread (Min/Max Area) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a pair of stacked area series that visualize the ensemble spread
|
|
||||||
* (min-to-max range). The lower bound is rendered invisibly and the upper
|
|
||||||
* bound delta is stacked on top with a translucent fill.
|
|
||||||
*
|
|
||||||
* Returns an array of two series that should be spread into the series list.
|
|
||||||
*/
|
|
||||||
export function buildSpreadSeries(params: SpreadSeriesParams): Array<Record<string, unknown>> {
|
|
||||||
const { variable, spreadData, color = CHART_COLORS.spreadArea } = params;
|
|
||||||
|
|
||||||
const lowerBound: Record<string, unknown> = {
|
|
||||||
name: variable + '_spread_lower',
|
|
||||||
type: 'line',
|
|
||||||
data: spreadData.map((d) => [d[0], d[1]]),
|
|
||||||
areaStyle: {
|
|
||||||
color,
|
|
||||||
origin: 'auto'
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
showSymbol: false,
|
|
||||||
stack: 'spread_' + variable,
|
|
||||||
smooth: true,
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const upperDelta: Record<string, unknown> = {
|
|
||||||
name: variable + '_spread_upper',
|
|
||||||
type: 'line',
|
|
||||||
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
|
|
||||||
areaStyle: {
|
|
||||||
color,
|
|
||||||
origin: 'auto'
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
showSymbol: false,
|
|
||||||
stack: 'spread_' + variable,
|
|
||||||
smooth: true,
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
};
|
|
||||||
|
|
||||||
return [lowerBound, upperDelta];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AverageResult {
|
|
||||||
average: number[];
|
|
||||||
averageCount: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpreadResult {
|
|
||||||
minValues: (number | undefined)[];
|
|
||||||
maxValues: (number | undefined)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates per-timestep average and count from hourly model data.
|
|
||||||
* Shared between the Model Compare and 14-Day Forecast pages.
|
|
||||||
*
|
|
||||||
* @param hourlyData - The `data.hourly` object from the API response
|
|
||||||
* @param variable - The variable prefix to filter on (e.g. 'temperature_2m')
|
|
||||||
* @param timeLength - Number of timesteps
|
|
||||||
* @returns Object containing running average and count arrays
|
|
||||||
*/
|
|
||||||
export function calculateAverage(
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): AverageResult {
|
|
||||||
const average = new Array<number>(timeLength).fill(0);
|
|
||||||
const averageCount = new Array<number>(timeLength).fill(0);
|
|
||||||
|
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (!model.startsWith(variable)) continue;
|
|
||||||
|
|
||||||
for (const [index, val] of (values as number[]).entries()) {
|
|
||||||
if (val !== null && val !== undefined && isFinite(val)) {
|
|
||||||
average[index] += val;
|
|
||||||
averageCount[index]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize average values
|
|
||||||
for (let i = 0; i < timeLength; i++) {
|
|
||||||
if (averageCount[i] > 0) {
|
|
||||||
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { average, averageCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates per-timestep min and max values from hourly ensemble data.
|
|
||||||
* Used by the 14-Day Forecast page to render the ensemble spread.
|
|
||||||
*
|
|
||||||
* @param hourlyData - The `data.hourly` object from the API response
|
|
||||||
* @param variable - The variable prefix to filter on
|
|
||||||
* @param timeLength - Number of timesteps
|
|
||||||
* @returns Object containing min and max value arrays
|
|
||||||
*/
|
|
||||||
export function calculateSpread(
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): SpreadResult {
|
|
||||||
const minValues = new Array<number | undefined>(timeLength).fill(undefined);
|
|
||||||
const maxValues = new Array<number | undefined>(timeLength).fill(undefined);
|
|
||||||
|
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (!model.startsWith(variable)) continue;
|
|
||||||
|
|
||||||
for (const [index, val] of (values as number[]).entries()) {
|
|
||||||
if (val !== null && val !== undefined) {
|
|
||||||
if (minValues[index] === undefined || val < minValues[index]!) {
|
|
||||||
minValues[index] = val;
|
|
||||||
}
|
|
||||||
if (maxValues[index] === undefined || val > maxValues[index]!) {
|
|
||||||
maxValues[index] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { minValues, maxValues };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the unit string for a given variable from the hourly_units map.
|
|
||||||
* Returns an empty string if the variable is not found.
|
|
||||||
*/
|
|
||||||
export function findUnit(
|
|
||||||
hourlyUnits: Record<string, string>,
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string
|
|
||||||
): string {
|
|
||||||
for (const model of Object.keys(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (model.startsWith(variable) && hourlyUnits[model]) {
|
|
||||||
return hourlyUnits[model];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Theme Utilities
|
|
||||||
*
|
|
||||||
* Centralized dark/light mode detection and color helpers for consistent
|
|
||||||
* chart theming across all ECharts visualizations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ─── Color Palette ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Default series color palette matching the application's design system */
|
|
||||||
export const SERIES_COLORS = [
|
|
||||||
'#5470c6',
|
|
||||||
'#91cc75',
|
|
||||||
'#fac858',
|
|
||||||
'#ee6666',
|
|
||||||
'#73c0de',
|
|
||||||
'#3ba272',
|
|
||||||
'#fc8452',
|
|
||||||
'#9a60b4',
|
|
||||||
'#ea7ccc',
|
|
||||||
'#4dc9f6'
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Semantic colors used for specific chart elements */
|
|
||||||
export const CHART_COLORS = {
|
|
||||||
average: '#5e5e5e',
|
|
||||||
currentTimeLine: '#ef4444',
|
|
||||||
daylight: 'rgba(255, 255, 194, 0.3)',
|
|
||||||
spreadArea: 'rgba(173, 216, 230, 0.3)',
|
|
||||||
creditText: { light: '#374151', dark: '#e5e7eb' }
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ─── Dark Mode Detection ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects whether the application is currently in dark mode.
|
|
||||||
* Checks multiple sources: HTML class, data-theme attribute, and media query.
|
|
||||||
*/
|
|
||||||
export function isDarkMode(): boolean {
|
|
||||||
if (typeof document === 'undefined') return false;
|
|
||||||
|
|
||||||
const html = document.documentElement;
|
|
||||||
const dataTheme = html.getAttribute('data-theme');
|
|
||||||
|
|
||||||
// Explicit data-theme takes priority
|
|
||||||
if (dataTheme === 'dark') return true;
|
|
||||||
if (dataTheme === 'light') return false;
|
|
||||||
|
|
||||||
// Check for dark class (e.g. Tailwind dark mode)
|
|
||||||
if (html.classList.contains('dark')) return true;
|
|
||||||
|
|
||||||
// Fall back to system preference
|
|
||||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Theme Colors ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ThemeColors {
|
|
||||||
text: string;
|
|
||||||
textMuted: string;
|
|
||||||
axisLine: string;
|
|
||||||
splitLine: string;
|
|
||||||
background: string;
|
|
||||||
tooltipBg: string;
|
|
||||||
tooltipBorder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIGHT_COLORS: ThemeColors = {
|
|
||||||
text: '#374151',
|
|
||||||
textMuted: 'rgba(55, 65, 81, 0.6)',
|
|
||||||
axisLine: 'rgba(55, 65, 81, 0.3)',
|
|
||||||
splitLine: 'rgba(55, 65, 81, 0.1)',
|
|
||||||
background: 'transparent',
|
|
||||||
tooltipBg: '#ffffff',
|
|
||||||
tooltipBorder: '#e5e7eb'
|
|
||||||
};
|
|
||||||
|
|
||||||
const DARK_COLORS: ThemeColors = {
|
|
||||||
text: '#e5e7eb',
|
|
||||||
textMuted: 'rgba(229, 231, 235, 0.6)',
|
|
||||||
axisLine: 'rgba(229, 231, 235, 0.3)',
|
|
||||||
splitLine: 'rgba(229, 231, 235, 0.1)',
|
|
||||||
background: 'transparent',
|
|
||||||
tooltipBg: '#1f2937',
|
|
||||||
tooltipBorder: '#374151'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the full set of theme colors based on current dark/light mode.
|
|
||||||
*/
|
|
||||||
export function getThemeColors(): ThemeColors {
|
|
||||||
return isDarkMode() ? DARK_COLORS : LIGHT_COLORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shorthand helpers kept for backward compatibility and convenience */
|
|
||||||
export function getTextColor(): string {
|
|
||||||
return getThemeColors().text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAxisLineColor(): string {
|
|
||||||
return getThemeColors().axisLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSplitLineColor(): string {
|
|
||||||
return getThemeColors().splitLine;
|
|
||||||
}
|
|
||||||
+65
-46
@@ -1,13 +1,21 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
import type { GeoLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
export const geoLocationNameToRoute = (name: string) => {
|
export const geoLocationNameToRoute = (name: string) => {
|
||||||
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
|
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
|
||||||
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// coordinate routes look like "52.52N13.41E" (negative values for S/W); GPS
|
||||||
|
// selections navigate here directly, no geocoding id involved
|
||||||
|
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
|
||||||
|
|
||||||
export function buildLocationRoute(location: GeoLocation): string {
|
export function buildLocationRoute(location: GeoLocation): string {
|
||||||
|
// coordinate-only locations (GPS) have no real geocoding id
|
||||||
|
if (location.feature_code === 'COORD' || !location.id) {
|
||||||
|
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
|
||||||
|
}
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
const locationRoute = geoLocationNameToRoute(location.name);
|
||||||
if (location.population && location.population > 543000) {
|
if (location.population && location.population > 543000) {
|
||||||
return locationRoute;
|
return locationRoute;
|
||||||
@@ -15,30 +23,9 @@ export function buildLocationRoute(location: GeoLocation): string {
|
|||||||
return locationRoute + '_' + location.id;
|
return locationRoute + '_' + location.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveLocationOptions {
|
export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
|
||||||
urlLocation: string;
|
|
||||||
routePrefix: string;
|
|
||||||
event: {
|
|
||||||
fetch: typeof fetch;
|
|
||||||
url: URL;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveLocationFromRoute({
|
|
||||||
urlLocation,
|
|
||||||
routePrefix,
|
|
||||||
event
|
|
||||||
}: ResolveLocationOptions): Promise<GeoLocation> {
|
|
||||||
let location: GeoLocation;
|
|
||||||
|
|
||||||
if (urlLocation.includes('N') && urlLocation.includes('E')) {
|
|
||||||
const parts = urlLocation.split(/N|E/);
|
|
||||||
const latitude = parseFloat(parts[0]);
|
|
||||||
const longitude = parseFloat(parts[1]);
|
|
||||||
|
|
||||||
location = {
|
|
||||||
id: 0,
|
id: 0,
|
||||||
name: `${latitude}N° ${longitude}E°`,
|
name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -55,8 +42,41 @@ export async function resolveLocationFromRoute({
|
|||||||
admin1: undefined,
|
admin1: undefined,
|
||||||
admin3: undefined,
|
admin3: undefined,
|
||||||
admin4: undefined
|
admin4: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// the geocoding API response is untrusted input: it can be an error object or
|
||||||
|
// (with a crafted URL) something else entirely, so the shape is checked before
|
||||||
|
// anything downstream dereferences it
|
||||||
|
const isGeoLocation = (value: unknown): value is GeoLocation => {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof candidate.name === 'string' &&
|
||||||
|
typeof candidate.id === 'number' &&
|
||||||
|
Number.isFinite(candidate.latitude) &&
|
||||||
|
Number.isFinite(candidate.longitude)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResolveLocationOptions {
|
||||||
|
urlLocation: string;
|
||||||
|
routePrefix: string;
|
||||||
|
event: {
|
||||||
|
fetch: typeof fetch;
|
||||||
|
url: URL;
|
||||||
};
|
};
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
export async function resolveLocationFromRoute({
|
||||||
|
urlLocation,
|
||||||
|
routePrefix,
|
||||||
|
event
|
||||||
|
}: ResolveLocationOptions): Promise<GeoLocation> {
|
||||||
|
const coordMatch = urlLocation.match(COORD_ROUTE);
|
||||||
|
if (coordMatch) {
|
||||||
|
return coordinateLocation(parseFloat(coordMatch[1]), parseFloat(coordMatch[2]));
|
||||||
|
}
|
||||||
|
|
||||||
let urlLocationName: string;
|
let urlLocationName: string;
|
||||||
let urlLocationId: string | undefined;
|
let urlLocationId: string | undefined;
|
||||||
|
|
||||||
@@ -64,45 +84,44 @@ export async function resolveLocationFromRoute({
|
|||||||
const split = urlLocation.split('_');
|
const split = urlLocation.split('_');
|
||||||
urlLocationName = split[0];
|
urlLocationName = split[0];
|
||||||
urlLocationId = split[1];
|
urlLocationId = split[1];
|
||||||
} else if (urlLocation.includes('-')) {
|
|
||||||
urlLocationName = urlLocation.replace(/-/g, ' ');
|
|
||||||
} else if (/^\d+$/.test(urlLocation)) {
|
} else if (/^\d+$/.test(urlLocation)) {
|
||||||
urlLocationName = '';
|
urlLocationName = '';
|
||||||
urlLocationId = urlLocation;
|
urlLocationId = urlLocation;
|
||||||
} else {
|
} else {
|
||||||
urlLocationName = urlLocation;
|
urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : urlLocation;
|
||||||
urlLocationId = undefined;
|
urlLocationId = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlLocationId) {
|
let location: GeoLocation;
|
||||||
|
|
||||||
|
// route params are attacker-controlled: ids must be numeric and names are
|
||||||
|
// URL-encoded so nothing can be injected into the API query string
|
||||||
|
if (urlLocationId && /^\d+$/.test(urlLocationId)) {
|
||||||
const res = await event.fetch(
|
const res = await event.fetch(
|
||||||
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
|
`https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
|
||||||
);
|
);
|
||||||
location = await res.json();
|
if (!res.ok) error(404, 'Location not found');
|
||||||
|
const candidate = await res.json();
|
||||||
|
if (!isGeoLocation(candidate)) error(404, 'Location not found');
|
||||||
|
location = candidate;
|
||||||
} else {
|
} else {
|
||||||
const res = await event.fetch(
|
const res = await event.fetch(
|
||||||
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
|
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
|
||||||
);
|
);
|
||||||
|
if (!res.ok) error(404, 'Location not found');
|
||||||
const geocodingResponse = await res.json();
|
const geocodingResponse = await res.json();
|
||||||
if (geocodingResponse.results) {
|
const candidate = geocodingResponse?.results?.[0];
|
||||||
location = geocodingResponse.results[0];
|
if (!isGeoLocation(candidate)) error(404, 'Location not found');
|
||||||
} else {
|
location = candidate;
|
||||||
error(404, 'Location not found');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||||
const canonicalSuffix =
|
// every path with a trailing slash. Match that here or the equality check
|
||||||
location.population && location.population > 543000
|
// never holds and the redirect loops forever.
|
||||||
? locationRoute
|
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
|
||||||
: locationRoute + '_' + location.id;
|
|
||||||
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
|
|
||||||
|
|
||||||
if (event.url.pathname !== canonicalPath) {
|
if (event.url.pathname !== canonicalPath) {
|
||||||
throw redirect(303, canonicalPath);
|
throw redirect(303, canonicalPath);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
storedLocation.set(location);
|
|
||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Translates drizzli's model ids (Open-Meteo API model names, see
|
||||||
|
* src/routes/weather/options.ts) into domain values understood by the maps
|
||||||
|
* viewer (open-meteo/maps, weather-map-layer src/domains.ts).
|
||||||
|
*
|
||||||
|
* The map advertises the domains it actually supports in its `om-maps:ready`
|
||||||
|
* handshake; candidates are tried in order and the first advertised one wins.
|
||||||
|
* Seamless API models list their seamless domain first, so they upgrade
|
||||||
|
* automatically once the maps app ships seamless support; until then they fall
|
||||||
|
* back to their widest-coverage member (a regional member could be entirely
|
||||||
|
* off-screen). Models without an entry are tried under their own id; models
|
||||||
|
* the map does not serve at all (best_match, bom, google, UKMO ensembles)
|
||||||
|
* resolve to null.
|
||||||
|
*/
|
||||||
|
export const modelDomainCandidates: Record<string, string[]> = {
|
||||||
|
// DWD Germany
|
||||||
|
icon_seamless: ['dwd_icon_seamless', 'dwd_icon'],
|
||||||
|
icon_global: ['dwd_icon'],
|
||||||
|
icon_eu: ['dwd_icon_eu'],
|
||||||
|
icon_d2: ['dwd_icon_d2'],
|
||||||
|
|
||||||
|
// NOAA U.S.
|
||||||
|
gfs_seamless: ['ncep_gfs_seamless', 'ncep_gfs013'],
|
||||||
|
gfs_global: ['ncep_gfs013'],
|
||||||
|
gfs_hrrr: ['ncep_hrrr_conus'],
|
||||||
|
gfs_graphcast025: ['ncep_gfs_graphcast025'],
|
||||||
|
|
||||||
|
// Météo-France
|
||||||
|
meteofrance_seamless: ['meteofrance_seamless', 'meteofrance_arpege_world025'],
|
||||||
|
meteofrance_arpege_world: ['meteofrance_arpege_world025'],
|
||||||
|
meteofrance_arome_france: ['meteofrance_arome_france0025'],
|
||||||
|
|
||||||
|
// UK Met Office
|
||||||
|
ukmo_seamless: ['ukmo_seamless', 'ukmo_global_deterministic_10km'],
|
||||||
|
|
||||||
|
// KNMI Netherlands
|
||||||
|
knmi_seamless: ['knmi_seamless', 'knmi_harmonie_arome_europe'],
|
||||||
|
|
||||||
|
// DMI Denmark (no DMI seamless domain in the maps project)
|
||||||
|
dmi_seamless: ['dmi_harmonie_arome_europe'],
|
||||||
|
|
||||||
|
// MET Norway
|
||||||
|
metno_seamless: ['metno_nordic_pp'],
|
||||||
|
metno_nordic: ['metno_nordic_pp'],
|
||||||
|
|
||||||
|
// MeteoSwiss (CH2 covers a wider area than CH1)
|
||||||
|
meteoswiss_icon_seamless: ['meteoswiss_icon_ch2'],
|
||||||
|
|
||||||
|
// KMA Korea (kma_ldps is not served by the maps project)
|
||||||
|
kma_seamless: ['kma_gdps'],
|
||||||
|
kma_ldps: ['kma_gdps'],
|
||||||
|
|
||||||
|
// JMA Japan
|
||||||
|
jma_seamless: ['jma_seamless', 'jma_gsm'],
|
||||||
|
|
||||||
|
// GEM Canada (gdps/rdps carry resolution suffixes in newer map builds;
|
||||||
|
// older builds advertise the plain names, so try both)
|
||||||
|
gem_seamless: ['cmc_gem_seamless', 'cmc_gem_gdps_15km', 'cmc_gem_gdps'],
|
||||||
|
gem_global: ['cmc_gem_gdps_15km', 'cmc_gem_gdps'],
|
||||||
|
gem_regional: ['cmc_gem_rdps_10km', 'cmc_gem_rdps'],
|
||||||
|
|
||||||
|
// Ensemble models
|
||||||
|
icon_seamless_eps: ['dwd_icon_eps'],
|
||||||
|
icon_global_eps: ['dwd_icon_eps'],
|
||||||
|
icon_eu_eps: ['dwd_icon_eu_eps'],
|
||||||
|
icon_d2_eps: ['dwd_icon_d2_eps'],
|
||||||
|
ncep_gefs_seamless: ['ncep_gefs025'],
|
||||||
|
gem_global_ensemble: ['cmc_gem_geps']
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve a model id to a maps domain the map advertised as supported. */
|
||||||
|
export const mapsDomainForModel = (
|
||||||
|
model: string,
|
||||||
|
supportedDomains: ReadonlySet<string>
|
||||||
|
): string | null => {
|
||||||
|
for (const candidate of modelDomainCandidates[model] ?? [model]) {
|
||||||
|
if (supportedDomains.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const prerender = true;
|
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { storedTheme } from '$lib/stores/settings';
|
||||||
|
|
||||||
import Header from '$lib/components/navigation/header.svelte';
|
import Header from '$lib/components/navigation/header.svelte';
|
||||||
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
|
||||||
|
|
||||||
@@ -8,6 +14,23 @@
|
|||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
// keep the .dark class in sync with the persisted theme; in 'system' mode
|
||||||
|
// follow the OS preference live
|
||||||
|
$effect(() => {
|
||||||
|
const theme = $storedTheme;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const apply = () => {
|
||||||
|
const dark = theme === 'dark' || (theme === 'system' && mq.matches);
|
||||||
|
document.documentElement.classList.toggle('dark', dark);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
mq.addEventListener('change', apply);
|
||||||
|
return () => mq.removeEventListener('change', apply);
|
||||||
|
});
|
||||||
|
|
||||||
|
// the maps page embeds a full-bleed map: no padding, no scrolling
|
||||||
|
let fullBleed = $derived($page.url.pathname.startsWith('/weather/maps'));
|
||||||
|
|
||||||
let sidebarCollapsed = $state(false);
|
let sidebarCollapsed = $state(false);
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
@@ -42,10 +65,14 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/30"
|
class="absolute inset-0 bg-black/30"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
onclick={closeMobileMenu}
|
onclick={closeMobileMenu}
|
||||||
onkeydown={closeMobileMenu}
|
onkeydown={closeMobileMenu}
|
||||||
></div>
|
></div>
|
||||||
<div class="relative z-1 h-full w-55 shadow-lg">
|
<div
|
||||||
|
class="relative z-1 h-full w-55 shadow-lg"
|
||||||
|
transition:fly={{ x: -220, duration: 200, opacity: 1 }}
|
||||||
|
>
|
||||||
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
|
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,8 +82,18 @@
|
|||||||
<div class="flex min-w-0 flex-1 flex-col h-full">
|
<div class="flex min-w-0 flex-1 flex-col h-full">
|
||||||
<Header onMenuToggle={toggleMobileMenu} />
|
<Header onMenuToggle={toggleMobileMenu} />
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto p-5 md:px-8 md:py-6">
|
<main
|
||||||
|
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
|
||||||
|
>
|
||||||
|
{#if fullBleed}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<!-- cap the content width on very large screens; generous bottom room
|
||||||
|
so the last chart/table never sits flush against the viewport edge -->
|
||||||
|
<div class="mx-auto w-full max-w-[1536px] pb-80">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
// Static hosting: emit every page as <path>/index.html so plain file servers
|
||||||
|
// resolve URLs like /weather/week/ without pretty-URL rewrites.
|
||||||
|
export const trailingSlash = 'always';
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Open-Meteo Weather</title>
|
<title>Drizzli</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|||||||
+10
-2
@@ -18,8 +18,8 @@
|
|||||||
--secondary-foreground: oklch(0.25 0.02 60);
|
--secondary-foreground: oklch(0.25 0.02 60);
|
||||||
--muted: oklch(0.96 0.008 85);
|
--muted: oklch(0.96 0.008 85);
|
||||||
--muted-foreground: oklch(0.5 0.02 60);
|
--muted-foreground: oklch(0.5 0.02 60);
|
||||||
--accent: oklch(0.96 0.008 85);
|
--accent: oklch(0.95 0.02 70);
|
||||||
--accent-foreground: oklch(1 0 0);
|
--accent-foreground: oklch(0.25 0.02 60);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.91 0.01 80);
|
--border: oklch(0.91 0.01 80);
|
||||||
--input: oklch(0.91 0.01 80);
|
--input: oklch(0.91 0.01 80);
|
||||||
@@ -118,6 +118,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
/* color-scheme drives native widgets AND propagates into embedded
|
||||||
|
iframes (the open-meteo map reads it via prefers-color-scheme) */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
|
|||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const title = document.querySelector('title');
|
const title = document.querySelector('title');
|
||||||
expect(title?.textContent).toBe('Open-Meteo Weather');
|
expect(title?.textContent).toBe('Drizz.li');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
return {
|
|
||||||
title: `Weather ${location.name}`,
|
|
||||||
location: location
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
return {
|
|
||||||
heroTitle: `14 Day Weather ${location.name}`,
|
|
||||||
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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/14-day/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||||
|
{
|
||||||
|
replaceState: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
const locationRoute = buildLocationRoute(location);
|
|
||||||
throw redirect(303, '/weather/14-day/' + locationRoute);
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,53 +1,61 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildSpreadSeries,
|
|
||||||
composeChartOption,
|
|
||||||
getThemeColors
|
|
||||||
} from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
|
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
|
||||||
import {
|
import {
|
||||||
|
type DaylightBand,
|
||||||
type EnsembleForecastResult,
|
type EnsembleForecastResult,
|
||||||
type MarkArea,
|
|
||||||
fetchEnsembleForecast
|
fetchEnsembleForecast
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters, ensembleModelGroups } from '../../options';
|
||||||
|
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
const CHART_GROUP = '14-day-ensemble';
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(true);
|
||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: CanvasChart[] = $state([]);
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let requestVersion = 0;
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
let { data }: { data: PageData } = $props();
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
// the URL is the source of truth: location comes from the load function,
|
||||||
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
|
let location = $derived(data.location);
|
||||||
|
$effect(() => {
|
||||||
|
storedLocation.set(data.location);
|
||||||
});
|
});
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: ['temperature_2m'],
|
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
|
||||||
models: ['gfs_seamless']
|
models: ['ncep_gefs_seamless']
|
||||||
|
});
|
||||||
|
|
||||||
|
// units live in a persisted store; mirror them into params so a change
|
||||||
|
// re-runs the fetch effect (which reads params.*_unit)
|
||||||
|
$effect(() => {
|
||||||
|
params.temperature_unit = $storedUnits.temperature_unit;
|
||||||
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||||
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||||
@@ -56,7 +64,7 @@
|
|||||||
ensembleResult: EnsembleForecastResult;
|
ensembleResult: EnsembleForecastResult;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
let fetchedData: FetchedData | null = $state(null);
|
||||||
@@ -64,20 +72,13 @@
|
|||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// preselect the persisted ensemble model (client-only, keeps SSR stable)
|
||||||
|
params.models = [get(storedEnsembleModel)];
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
// components persist across refetches; entries are null while unmounted
|
||||||
chartInstances = [];
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||||
|
|
||||||
@@ -89,12 +90,12 @@
|
|||||||
|
|
||||||
const loc = location;
|
const loc = location;
|
||||||
|
|
||||||
const loadData = async () => {
|
// versioned so a slow stale response can never overwrite a newer one
|
||||||
|
const version = ++requestVersion;
|
||||||
loading = true;
|
loading = true;
|
||||||
chartInstances = [];
|
loadError = null;
|
||||||
chartComponents = [];
|
|
||||||
|
|
||||||
const result: EnsembleForecastResult = await fetchEnsembleForecast({
|
fetchEnsembleForecast({
|
||||||
latitude: loc.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: loc.longitude!,
|
longitude: loc.longitude!,
|
||||||
hourlyVariables: hourlyVars,
|
hourlyVariables: hourlyVars,
|
||||||
@@ -104,121 +105,287 @@
|
|||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
timezone: loc.timezone
|
timezone: loc.timezone
|
||||||
});
|
})
|
||||||
|
.then((result: EnsembleForecastResult) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
|
||||||
fetchedData = {
|
fetchedData = {
|
||||||
ensembleResult: result,
|
ensembleResult: result,
|
||||||
timestamps: result.timestamps,
|
timestamps: result.timestamps,
|
||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas
|
daylightBands: result.daylightBands
|
||||||
};
|
};
|
||||||
console.log(fetchedData.timezone);
|
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
};
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
loadData();
|
if (version !== requestVersion) return;
|
||||||
|
loadError = err instanceof Error ? err.message : String(err);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
||||||
|
|
||||||
$effect(() => {
|
// Ensemble members stop at the model's horizon; past it the service collapses
|
||||||
if (!fetchedData) return;
|
// every value to 0 (min = max = mean = 0). Trim the axis to the last hour that
|
||||||
|
// actually has data so the charts cut off instead of flat-lining to zero.
|
||||||
|
let validLength = $derived.by((): number => {
|
||||||
|
if (!fetchedData) return 0;
|
||||||
|
const temp = fetchedData.ensembleResult.variables['temperature_2m'];
|
||||||
|
const n = fetchedData.timestamps.length;
|
||||||
|
if (!temp) return n;
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (!(temp.max[i] === 0 && temp.min[i] === 0 && temp.average[i] === 0)) last = i + 1;
|
||||||
|
}
|
||||||
|
return last || n;
|
||||||
|
});
|
||||||
|
|
||||||
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
const _showLegend = showLegend;
|
let timestampsSec = $derived.by(() =>
|
||||||
|
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
|
||||||
|
);
|
||||||
|
|
||||||
const colors = getThemeColors();
|
// Surface a note when the chosen model's ensemble stops short of the request.
|
||||||
const variableCount = params.hourly?.length || 0;
|
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
|
||||||
|
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
interface ChartDef {
|
||||||
const variable = params.hourly![vi];
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
unit: string;
|
||||||
|
showCredit: boolean;
|
||||||
|
series: ChartSeries[];
|
||||||
|
zeroBaseLeft?: boolean;
|
||||||
|
yMin?: number;
|
||||||
|
yMax?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensible axis behaviour per variable so the y-scale stays readable (e.g.
|
||||||
|
// pressure never anchored to zero; percentages pinned to 0-100).
|
||||||
|
function axisForVar(v: string): { zeroBaseLeft: boolean; yMin?: number; yMax?: number } {
|
||||||
|
if (['pressure_msl', 'surface_pressure', 'temperature_2m', 'dew_point_2m'].includes(v)) {
|
||||||
|
return { zeroBaseLeft: false };
|
||||||
|
}
|
||||||
|
if (['relative_humidity_2m', 'cloud_cover', 'precipitation_probability'].includes(v)) {
|
||||||
|
return { zeroBaseLeft: true, yMin: 0, yMax: 100 };
|
||||||
|
}
|
||||||
|
return { zeroBaseLeft: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAND_COLOR = 'rgba(115, 192, 222, 0.9)';
|
||||||
|
|
||||||
|
// Human labels for the plotted ensemble variables (API names → readable title)
|
||||||
|
const VAR_LABELS: Record<string, string> = {
|
||||||
|
temperature_2m: 'Temperature',
|
||||||
|
apparent_temperature: 'Feels like',
|
||||||
|
precipitation: 'Precipitation',
|
||||||
|
rain: 'Rain',
|
||||||
|
snowfall: 'Snowfall',
|
||||||
|
wind_speed_10m: 'Wind speed',
|
||||||
|
wind_gusts_10m: 'Wind gusts',
|
||||||
|
relative_humidity_2m: 'Relative humidity',
|
||||||
|
cloud_cover: 'Cloud cover',
|
||||||
|
pressure_msl: 'Pressure (MSL)',
|
||||||
|
dew_point_2m: 'Dew point'
|
||||||
|
};
|
||||||
|
const varLabel = (v: string): string =>
|
||||||
|
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
|
const { ensembleResult } = fetchedData;
|
||||||
|
const variables = params.hourly || [];
|
||||||
|
const defs: ChartDef[] = [];
|
||||||
|
|
||||||
|
for (let vi = 0; vi < variables.length; vi++) {
|
||||||
|
const variable = variables[vi];
|
||||||
const varData = ensembleResult.variables[variable];
|
const varData = ensembleResult.variables[variable];
|
||||||
if (!varData) continue;
|
if (!varData) continue;
|
||||||
|
|
||||||
const unit = varData.unit;
|
const unit = varData.unit;
|
||||||
const { average, min: minValues, max: maxValues } = varData;
|
const isColumn = isColumnUnit(unit);
|
||||||
|
const memberCount = varData.members.length;
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
// Trim to the valid horizon so the axis scale ignores the trailing
|
||||||
|
// zeros the service pads past the model's range.
|
||||||
|
const vMax = varData.max.slice(0, validLength);
|
||||||
|
const vMin = varData.min.slice(0, validLength);
|
||||||
|
const vAvg = varData.average.slice(0, validLength);
|
||||||
|
|
||||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
// Min/max spread band + mean, instead of every individual member
|
||||||
(minVal, index) =>
|
const series: ChartSeries[] = [
|
||||||
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
{
|
||||||
);
|
name: 'Max',
|
||||||
|
type: 'line',
|
||||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
color: BAND_COLOR,
|
||||||
|
data: vMax,
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
width: 1,
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
fill: true,
|
||||||
|
fillOpacity: 0.25,
|
||||||
series.push(buildCurrentTimeSeries());
|
bandTo: vMin,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
},
|
||||||
if (daylightSeries) {
|
{
|
||||||
series.push(daylightSeries);
|
name: 'Mean',
|
||||||
|
type: isColumn ? 'bar' : 'line',
|
||||||
|
color: CHART_COLORS.average,
|
||||||
|
data: vAvg,
|
||||||
|
width: 3.5,
|
||||||
|
dashed: !isColumn,
|
||||||
|
outline: !isColumn,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Min',
|
||||||
|
type: 'line',
|
||||||
|
color: BAND_COLOR,
|
||||||
|
data: vMin,
|
||||||
|
width: 1,
|
||||||
|
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||||
}
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variables.length - 1;
|
||||||
|
|
||||||
const option = composeChartOption({
|
const axis = axisForVar(variable);
|
||||||
title: isFirst
|
defs.push({
|
||||||
? {
|
// each chart is labelled so the variable is obvious at a glance
|
||||||
text: 'Model Spread',
|
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
subtitle: isFirst
|
||||||
}
|
? `min · mean · max across ${memberCount} ensemble members`
|
||||||
: null,
|
: `min · mean · max (${unit})`,
|
||||||
tooltip: { unit, timezone },
|
unit,
|
||||||
legend: {
|
|
||||||
show: _showLegend,
|
|
||||||
data: [variable + '_average']
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors,
|
series,
|
||||||
timezone
|
zeroBaseLeft: axis.zeroBaseLeft,
|
||||||
|
yMin: axis.yMin,
|
||||||
|
yMax: axis.yMax
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
return defs;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Page hero: location + ensemble 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="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
|
||||||
|
>14-day ensemble forecast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full items-center gap-3 sm:w-auto">
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={params.models?.[0] ?? 'ncep_gefs_seamless'}
|
||||||
|
groups={ensembleModelGroups}
|
||||||
|
label="Ensemble model"
|
||||||
|
onModelChange={(model) => {
|
||||||
|
params.models = [model];
|
||||||
|
storedEnsembleModel.set(model);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
<ChartContainer
|
{#if isTrimmed}
|
||||||
{loading}
|
<div
|
||||||
chartCount={params.hourly?.length || 0}
|
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
chartHeight={showLegend ? 400 : 300}
|
>
|
||||||
>
|
<svg
|
||||||
{#each chartOptions as option, i (i)}
|
class="mt-0.5 h-4 w-4 shrink-0"
|
||||||
<EChart
|
fill="none"
|
||||||
{option}
|
stroke="currentColor"
|
||||||
height={showLegend ? '400px' : '300px'}
|
viewBox="0 0 24 24"
|
||||||
onChartReady={handleChartReady}
|
stroke-width="2"
|
||||||
bind:this={chartComponents[i]}
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
|
||||||
/>
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
|
||||||
|
trimmed to its available range.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
Failed to load weather data: {loadError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fetchedData}
|
||||||
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each chartDefs as def, i (i)}
|
||||||
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
zeroBaseLeft={def.zeroBaseLeft ?? true}
|
||||||
|
yMin={def.yMin}
|
||||||
|
yMax={def.yMax}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
|
<ChartContainer loading chartCount={params.hourly?.length || 1} chartHeight={340} bleed={false} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
|
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
|
||||||
{#snippet controls()}
|
{#snippet controls()}
|
||||||
<div class="flex gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
|
|||||||
@@ -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/compare/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||||
|
{
|
||||||
|
replaceState: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
const locationRoute = buildLocationRoute(location);
|
|
||||||
throw redirect(303, '/weather/compare/' + locationRoute);
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,39 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
|
||||||
|
|
||||||
import {
|
import { formatZoned } from '$lib/utils/date';
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildModelSeries,
|
|
||||||
calculateAverage,
|
|
||||||
composeChartOption,
|
|
||||||
findUnit,
|
|
||||||
getThemeColors
|
|
||||||
} from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type MarkArea,
|
CHART_COLORS,
|
||||||
|
CanvasChart,
|
||||||
|
type ChartSeries,
|
||||||
|
SERIES_COLORS,
|
||||||
|
calculateAverage,
|
||||||
|
findUnit,
|
||||||
|
groupRange,
|
||||||
|
isColumnUnit
|
||||||
|
} from '$lib/charts';
|
||||||
|
import {
|
||||||
|
type DaylightBand,
|
||||||
type ModelCompareResult,
|
type ModelCompareResult,
|
||||||
fetchModelComparison
|
fetchModelComparison
|
||||||
} from '$lib/services/weather';
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../../options';
|
import { findModel, hourly, modelGroups } from '../../options';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
const models = [modelsFlat];
|
const models = modelGroups.map((group) => group.models);
|
||||||
|
|
||||||
|
const CHART_GROUP = 'model-compare';
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||||
|
|
||||||
@@ -41,35 +44,34 @@
|
|||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
let chartComponents: CanvasChart[] = $state([]);
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let requestVersion = 0;
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
let { data }: { data: PageData } = $props();
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
// the URL is the source of truth: location comes from the load function,
|
||||||
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
|
let location = $derived(data.location);
|
||||||
|
$effect(() => {
|
||||||
|
storedLocation.set(data.location);
|
||||||
});
|
});
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
...defaultParameters,
|
...defaultParameters,
|
||||||
hourly: [
|
hourly: ['temperature_2m', 'rain', 'wind_speed_10m'],
|
||||||
'temperature_2m',
|
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_seamless']
|
||||||
'rain',
|
});
|
||||||
'relative_humidity_2m',
|
|
||||||
'wind_speed_10m',
|
// units live in a persisted store; mirror them into params so a change
|
||||||
'wind_direction_10m'
|
// re-runs the fetch effect (which reads params.*_unit)
|
||||||
],
|
$effect(() => {
|
||||||
models: [
|
params.temperature_unit = $storedUnits.temperature_unit;
|
||||||
'ecmwf_ifs',
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||||
'ecmwf_ifs025',
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||||
'meteofrance_seamless',
|
|
||||||
'ukmo_seamless',
|
|
||||||
'icon_seamless',
|
|
||||||
'gem_seamless',
|
|
||||||
'gfs_seamless'
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||||
@@ -78,7 +80,7 @@
|
|||||||
hourly: Record<string, unknown>;
|
hourly: Record<string, unknown>;
|
||||||
hourly_units: Record<string, string>;
|
hourly_units: Record<string, string>;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
sunrise: number[];
|
sunrise: number[];
|
||||||
sunset: number[];
|
sunset: number[];
|
||||||
@@ -89,21 +91,51 @@
|
|||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// the model chosen elsewhere on the site is always part of the comparison
|
||||||
|
const selectedModel = get(storedModel);
|
||||||
|
if (selectedModel !== 'best_match' && !params.models.includes(selectedModel)) {
|
||||||
|
params.models = [selectedModel, ...params.models];
|
||||||
|
}
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
// components persist across refetches; entries are null while unmounted
|
||||||
chartInstances = [];
|
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
if (!fetchedData) return null;
|
||||||
|
const tz = fetchedData.timezone;
|
||||||
|
const target = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||||
|
const idx = fetchedData.timestamps.findIndex(
|
||||||
|
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
|
||||||
|
);
|
||||||
|
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRangeDays(from: Date, days: number): void {
|
||||||
|
const start = dayStartSec(from);
|
||||||
|
if (start == null || liveCharts.length === 0) return;
|
||||||
|
// charts share the group, so setting the range on one syncs all of them
|
||||||
|
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetZoom(): void {
|
||||||
|
liveCharts[0]?.resetRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangePresets = [
|
||||||
|
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||||
|
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||||
|
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||||
|
{ label: 'All', apply: () => resetZoom() }
|
||||||
|
];
|
||||||
|
|
||||||
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -114,12 +146,12 @@
|
|||||||
|
|
||||||
const loc = location;
|
const loc = location;
|
||||||
|
|
||||||
const loadData = async () => {
|
// versioned so a slow stale response can never overwrite a newer one
|
||||||
|
const version = ++requestVersion;
|
||||||
loading = true;
|
loading = true;
|
||||||
chartInstances = [];
|
loadError = null;
|
||||||
chartComponents = [];
|
|
||||||
|
|
||||||
const result: ModelCompareResult = await fetchModelComparison({
|
fetchModelComparison({
|
||||||
latitude: loc.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: loc.longitude!,
|
longitude: loc.longitude!,
|
||||||
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
||||||
@@ -128,116 +160,243 @@
|
|||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
timezone: loc.timezone
|
timezone: loc.timezone
|
||||||
});
|
})
|
||||||
|
.then((result: ModelCompareResult) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
|
||||||
fetchedData = {
|
fetchedData = {
|
||||||
hourly: result.hourlyFlat,
|
hourly: result.hourlyFlat,
|
||||||
hourly_units: result.hourlyUnitsFlat,
|
hourly_units: result.hourlyUnitsFlat,
|
||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas,
|
daylightBands: result.daylightBands,
|
||||||
timestamps: result.timestamps,
|
timestamps: result.timestamps,
|
||||||
sunrise: result.sunrise,
|
sunrise: result.sunrise,
|
||||||
sunset: result.sunset
|
sunset: result.sunset
|
||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
};
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
loadData();
|
if (version !== requestVersion) return;
|
||||||
|
loadError = err instanceof Error ? err.message : String(err);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
|
||||||
|
|
||||||
$effect(() => {
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
if (!fetchedData) return;
|
let timestampsSec = $derived.by(() =>
|
||||||
|
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
|
||||||
|
);
|
||||||
|
|
||||||
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
|
interface ChartDef {
|
||||||
const _showLegend = showLegend;
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
unit: string;
|
||||||
|
showCredit: boolean;
|
||||||
|
series: ChartSeries[];
|
||||||
|
}
|
||||||
|
|
||||||
const colors = getThemeColors();
|
// Human labels for the compared variables (API names → readable title)
|
||||||
|
const VAR_LABELS: Record<string, string> = {
|
||||||
|
temperature_2m: 'Temperature',
|
||||||
|
apparent_temperature: 'Feels like',
|
||||||
|
dew_point_2m: 'Dew point',
|
||||||
|
precipitation: 'Precipitation',
|
||||||
|
rain: 'Rain',
|
||||||
|
showers: 'Showers',
|
||||||
|
snowfall: 'Snowfall',
|
||||||
|
wind_speed_10m: 'Wind speed',
|
||||||
|
wind_gusts_10m: 'Wind gusts',
|
||||||
|
relative_humidity_2m: 'Relative humidity',
|
||||||
|
cloud_cover: 'Cloud cover',
|
||||||
|
pressure_msl: 'Pressure (MSL)'
|
||||||
|
};
|
||||||
|
const varLabel = (v: string): string =>
|
||||||
|
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
let chartDefs = $derived.by((): ChartDef[] => {
|
||||||
|
if (!fetchedData) return [];
|
||||||
|
|
||||||
|
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
|
||||||
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
||||||
const variableCount = chartVariables.length;
|
const variableCount = chartVariables.length;
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const defs: ChartDef[] = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = chartVariables[vi];
|
const variable = chartVariables[vi];
|
||||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||||
|
const isColumn = isColumnUnit(unit);
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: ChartSeries[] = [];
|
||||||
|
|
||||||
|
let modelIndex = 0;
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
if (model === 'time') continue;
|
if (model === 'time') continue;
|
||||||
if (!model.startsWith(variable)) continue;
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
const seriesData = (values as (number | null)[]).map(
|
// strip the variable prefix and use the concise model label so the
|
||||||
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
// tooltip/legend stay readable (model ids are very long)
|
||||||
);
|
const modelId = model.slice(variable.length + 1);
|
||||||
|
series.push({
|
||||||
series.push(
|
name: findModel(modelId)?.label ?? modelId,
|
||||||
buildModelSeries({
|
type: isColumn ? 'bar' : 'line',
|
||||||
name: model,
|
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
|
||||||
data: seriesData,
|
data: values as (number | null)[],
|
||||||
unit
|
width: 2
|
||||||
})
|
});
|
||||||
);
|
modelIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
series.push({
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
name: 'Average',
|
||||||
|
type: isColumn ? 'bar' : 'line',
|
||||||
series.push(buildCurrentTimeSeries());
|
color: CHART_COLORS.average,
|
||||||
|
data: average,
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
width: 4,
|
||||||
if (daylightSeries) {
|
dashed: !isColumn,
|
||||||
series.push(daylightSeries);
|
outline: !isColumn
|
||||||
}
|
});
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
const isFirst = vi === 0;
|
||||||
const isLast = vi === variableCount - 1;
|
const isLast = vi === variableCount - 1;
|
||||||
|
|
||||||
const option = composeChartOption({
|
defs.push({
|
||||||
title: isFirst
|
// label every chart with its variable so each is identifiable
|
||||||
? {
|
title: varLabel(variable),
|
||||||
text: 'Model Compare',
|
subtitle: isFirst
|
||||||
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
? `${params.models?.length ?? 0} models · dashed = average`
|
||||||
}
|
: `across ${params.models?.length ?? 0} models`,
|
||||||
: null,
|
unit,
|
||||||
tooltip: { unit, timezone },
|
|
||||||
legend: { show: _showLegend },
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
showCredit: isLast,
|
||||||
colors,
|
series
|
||||||
timezone
|
|
||||||
});
|
});
|
||||||
|
|
||||||
newOptions.push(option);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chartOptions = newOptions;
|
return defs;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
|
||||||
|
|
||||||
|
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country ?? ''}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
|
{location.name}
|
||||||
|
</h1>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
|
<span class="lg:hidden"
|
||||||
|
>{#if location.admin1}{location.admin1},
|
||||||
|
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||||
|
>Model comparison
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||||
|
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||||
|
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||||
|
drag or
|
||||||
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
||||||
|
>
|
||||||
|
+ scroll to zoom
|
||||||
|
</span>
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||||
|
onclick={resetZoom}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||||
|
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||||
|
</svg>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Chart time range"
|
||||||
|
>
|
||||||
|
{#each rangePresets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
|
{#if loadError}
|
||||||
{#each chartOptions as option, i (i)}
|
<div
|
||||||
<EChart
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
{option}
|
>
|
||||||
height={showLegend ? '400px' : '300px'}
|
Failed to load weather data: {loadError}
|
||||||
onChartReady={handleChartReady}
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- chart count derives from the selected variables (not the fetched data),
|
||||||
|
so the reserved height is right even before the response arrives -->
|
||||||
|
{#if fetchedData}
|
||||||
|
<!-- full-bleed graphs until lg / contained card on lg+; titles stay within
|
||||||
|
the page margins (padded), the graphs bleed to the edges -->
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each chartDefs as def, i (i)}
|
||||||
|
<div class="px-0 pt-1.5 pb-1 lg:px-4 lg:pb-3 {i > 0 ? 'border-t border-border/50' : ''}">
|
||||||
|
<div class="mb-1 px-3 lg:px-0">
|
||||||
|
<h4 class="text-sm font-bold tracking-tight">{def.title}</h4>
|
||||||
|
{#if def.subtitle}
|
||||||
|
<p class="text-xs text-muted-foreground">{def.subtitle}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<ChartContainer {loading} chartCount={1} chartHeight={300} minWidth={520} bleed={false}>
|
||||||
|
<CanvasChart
|
||||||
bind:this={chartComponents[i]}
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
series={def.series}
|
||||||
|
bands={fetchedData.daylightBands}
|
||||||
|
unit={def.unit}
|
||||||
|
showCredit={def.showCredit}
|
||||||
|
{showLegend}
|
||||||
|
height={300}
|
||||||
|
group={CHART_GROUP}
|
||||||
/>
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- reserve the chart area height before data arrives (no layout shift) -->
|
||||||
|
<ChartContainer
|
||||||
|
loading
|
||||||
|
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||||
|
chartHeight={340}
|
||||||
|
bleed={false}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if fetchedData && !loading}
|
{#if fetchedData && !loading}
|
||||||
<ModelPictogramTimeline
|
<ModelPictogramTimeline
|
||||||
@@ -253,11 +412,11 @@
|
|||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
<ChartToolbar charts={chartInstances} fileName="model-comparison">
|
<ChartToolbar charts={liveCharts} fileName="model-comparison">
|
||||||
{#snippet controls()}
|
{#snippet controls()}
|
||||||
<div class="flex gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ChartToolbar>
|
</ChartToolbar>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { buildLocationRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
// at build time this page knows nothing about the visitor, so the redirect
|
||||||
|
// target (the persisted location) is resolved in the browser instead of
|
||||||
|
// being baked to the default city during prerender
|
||||||
|
onMount(() => {
|
||||||
|
goto(
|
||||||
|
resolve('/weather/historical/[location]', {
|
||||||
|
location: buildLocationRoute(get(storedLocation))
|
||||||
|
}),
|
||||||
|
{ replaceState: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import {
|
||||||
|
storedChartLayout,
|
||||||
|
storedLocation,
|
||||||
|
storedUnits,
|
||||||
|
storedVariablePrefs
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||||
|
import { isPremium } from '$lib/paywall/premium';
|
||||||
|
import {
|
||||||
|
type ClimateNormals,
|
||||||
|
type HistoricalForecastResult,
|
||||||
|
fetchClimateNormals,
|
||||||
|
fetchHistoricalWeather
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { defaultParameters } from '../../options';
|
||||||
|
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
|
||||||
|
import { neededHourlyApiVars } from '../../week/[location]/variables';
|
||||||
|
import DateRangeControls from './DateRangeControls.svelte';
|
||||||
|
import HistoricalDaily from './HistoricalDaily.svelte';
|
||||||
|
import HistoricalMeteograms from './HistoricalMeteograms.svelte';
|
||||||
|
|
||||||
|
import type { FetchedDaily, FetchedHourly } from '../../week/[location]/types';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let location = $derived(data.location);
|
||||||
|
$effect(() => {
|
||||||
|
storedLocation.set(data.location);
|
||||||
|
});
|
||||||
|
|
||||||
|
let params = $state({ ...defaultParameters });
|
||||||
|
$effect(() => {
|
||||||
|
params.temperature_unit = $storedUnits.temperature_unit;
|
||||||
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||||
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request only what the table rows + meteogram layout actually show.
|
||||||
|
let hourlyVars = $derived(
|
||||||
|
neededHourlyApiVars(
|
||||||
|
$storedVariablePrefs.table,
|
||||||
|
$storedChartLayout.flatMap((p) => p.variables)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Date range ───────────────────────────────────────────────────────────
|
||||||
|
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
||||||
|
const addDays = (d: Date, n: number): Date => {
|
||||||
|
const c = new Date(d);
|
||||||
|
c.setUTCDate(c.getUTCDate() + n);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_DATE = '1940-01-01'; // ERA5 archive start
|
||||||
|
// The reanalysis archive lags real time by a few days.
|
||||||
|
let maxDate = $state(iso(addDays(new Date(), -5)));
|
||||||
|
let startDate = $state(iso(addDays(new Date(), -34)));
|
||||||
|
let endDate = $state(iso(addDays(new Date(), -5)));
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const today = new Date();
|
||||||
|
maxDate = iso(addDays(today, -5));
|
||||||
|
endDate = maxDate;
|
||||||
|
startDate = iso(addDays(today, -34));
|
||||||
|
mounted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRangeChange(s: string, e: string) {
|
||||||
|
startDate = s;
|
||||||
|
endDate = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fetch state ────────────────────────────────────────────────────────────
|
||||||
|
let mounted = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let requestVersion = 0;
|
||||||
|
|
||||||
|
let result = $state<HistoricalForecastResult | null>(null);
|
||||||
|
let normals = $state<ClimateNormals | null>(null);
|
||||||
|
|
||||||
|
const selectedDay = new SvelteDate();
|
||||||
|
|
||||||
|
// Historical data: refetch on location / range / units / requested-vars change.
|
||||||
|
$effect(() => {
|
||||||
|
const loc = location;
|
||||||
|
const s = startDate;
|
||||||
|
const e = endDate;
|
||||||
|
const vars = hourlyVars;
|
||||||
|
if (!mounted || !$isPremium || !loc || !s || !e) return;
|
||||||
|
|
||||||
|
const version = ++requestVersion;
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
|
||||||
|
fetchHistoricalWeather({
|
||||||
|
latitude: loc.latitude!,
|
||||||
|
longitude: loc.longitude!,
|
||||||
|
start_date: s,
|
||||||
|
end_date: e,
|
||||||
|
hourlyVariables: vars,
|
||||||
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
|
timezone: loc.timezone
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
result = r;
|
||||||
|
// default the hourly drill-down to the last day in range
|
||||||
|
if (r.dailyDates.length > 0) {
|
||||||
|
selectedDay.setTime(r.dailyDates[r.dailyDates.length - 1].getTime());
|
||||||
|
}
|
||||||
|
loading = false;
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
loadError = err instanceof Error ? err.message : String(err);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Climate normals: independent of the range, so fetch once per location/units.
|
||||||
|
let normalsKey = $derived(
|
||||||
|
`${location?.latitude},${location?.longitude},${params.temperature_unit},${params.precipitation_unit}`
|
||||||
|
);
|
||||||
|
let normalsVersion = 0;
|
||||||
|
$effect(() => {
|
||||||
|
const key = normalsKey;
|
||||||
|
const loc = location;
|
||||||
|
if (!mounted || !$isPremium || !loc) return;
|
||||||
|
|
||||||
|
const version = ++normalsVersion;
|
||||||
|
normals = null;
|
||||||
|
fetchClimateNormals({
|
||||||
|
latitude: loc.latitude!,
|
||||||
|
longitude: loc.longitude!,
|
||||||
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
|
||||||
|
})
|
||||||
|
.then((n) => {
|
||||||
|
if (version === normalsVersion) normals = n;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// normals are a nice-to-have; a failure just hides the comparison
|
||||||
|
if (version === normalsVersion) normals = null;
|
||||||
|
});
|
||||||
|
// re-read key so the effect tracks it
|
||||||
|
void key;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Adapters so the reused week components accept historical data ──────────
|
||||||
|
let fetchedHourly = $derived<FetchedHourly | null>(
|
||||||
|
result
|
||||||
|
? {
|
||||||
|
hourly: result.hourly,
|
||||||
|
utc_offset_seconds: result.utcOffsetSeconds,
|
||||||
|
timezone: result.timezone,
|
||||||
|
timestamps: result.hourlyTimestamps,
|
||||||
|
hourlyDates: result.hourlyDates,
|
||||||
|
daylightBands: result.daylightBands
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
let fetchedDaily = $derived<FetchedDaily | null>(
|
||||||
|
result
|
||||||
|
? {
|
||||||
|
daily: {
|
||||||
|
weather_code: result.daily.weather_code,
|
||||||
|
temperature_2m_max: result.daily.temperature_2m_max,
|
||||||
|
temperature_2m_min: result.daily.temperature_2m_min,
|
||||||
|
sunrise: result.daily.sunrise,
|
||||||
|
sunset: result.daily.sunset,
|
||||||
|
sunshine_duration: result.daily.sunshine_duration,
|
||||||
|
precipitation_sum: result.daily.precipitation_sum,
|
||||||
|
windspeed_10m_max: result.daily.windspeed_10m_max,
|
||||||
|
windgusts_10m_max: result.daily.windgusts_10m_max,
|
||||||
|
winddirection_10m_dominant: result.daily.winddirection_10m_dominant
|
||||||
|
},
|
||||||
|
timezone: result.timezone,
|
||||||
|
dailyDates: result.dailyDates
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function switchDay(date: Date) {
|
||||||
|
selectedDay.setTime(date.getTime());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Drizz.li | Historical weather</title>
|
||||||
|
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Page hero -->
|
||||||
|
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
|
||||||
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
|
<img
|
||||||
|
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
|
||||||
|
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||||
|
alt={location.country ?? ''}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
|
||||||
|
{location.name}
|
||||||
|
</h1>
|
||||||
|
<p class="truncate text-sm text-muted-foreground">
|
||||||
|
<span class="lg:hidden"
|
||||||
|
>{#if location.admin1}{location.admin1},
|
||||||
|
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
|
||||||
|
>Historical weather
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaywallGate feature="Historical weather">
|
||||||
|
<DateRangeControls
|
||||||
|
start={startDate}
|
||||||
|
end={endDate}
|
||||||
|
minDate={MIN_DATE}
|
||||||
|
{maxDate}
|
||||||
|
onChange={onRangeChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
Failed to load historical data: {loadError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
{#if result && fetchedHourly && fetchedDaily}
|
||||||
|
<HistoricalDaily
|
||||||
|
daily={result.daily}
|
||||||
|
dailyDates={result.dailyDates}
|
||||||
|
timezone={result.timezone}
|
||||||
|
units={params}
|
||||||
|
{normals}
|
||||||
|
{selectedDay}
|
||||||
|
onSelectDay={switchDay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<HourlyTable
|
||||||
|
data={fetchedHourly}
|
||||||
|
daily={fetchedDaily}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
locationName={location.name ?? ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HistoricalMeteograms data={fetchedHourly} units={params} {loading} {selectedDay} />
|
||||||
|
{:else}
|
||||||
|
<div transition:fade={{ duration: 200 }} class="grid gap-3">
|
||||||
|
<div class="h-28 animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||||
|
<ChartContainer loading chartCount={3} chartHeight={300} bleed={false} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PaywallGate>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { resolveLocationFromRoute } from '$lib/utils/location';
|
||||||
|
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = async (event) => {
|
||||||
|
const location = await resolveLocationFromRoute({
|
||||||
|
urlLocation: event.params.location,
|
||||||
|
routePrefix: '/weather/historical/',
|
||||||
|
event
|
||||||
|
});
|
||||||
|
|
||||||
|
return { location };
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
/** Latest selectable date (archive lags real time by a few days). */
|
||||||
|
maxDate: string;
|
||||||
|
minDate: string;
|
||||||
|
onChange: (start: string, end: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { start, end, maxDate, minDate, onChange }: Props = $props();
|
||||||
|
|
||||||
|
// ─── Presets ────────────────────────────────────────────────────────────────
|
||||||
|
const iso = (d: Date): string => d.toISOString().slice(0, 10);
|
||||||
|
const addDays = (d: Date, n: number): Date => {
|
||||||
|
const c = new Date(d);
|
||||||
|
c.setUTCDate(c.getUTCDate() + n);
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyLastDays(days: number) {
|
||||||
|
const endD = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const startD = addDays(endD, -(days - 1));
|
||||||
|
onChange(iso(startD), iso(endD));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThisMonthLastYear() {
|
||||||
|
const end = new Date(`${maxDate}T00:00:00Z`);
|
||||||
|
const y = end.getUTCFullYear() - 1;
|
||||||
|
const m = end.getUTCMonth();
|
||||||
|
const first = new Date(Date.UTC(y, m, 1));
|
||||||
|
const last = new Date(Date.UTC(y, m + 1, 0));
|
||||||
|
onChange(iso(first), iso(last));
|
||||||
|
}
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ label: '7 days', apply: () => applyLastDays(7) },
|
||||||
|
{ label: '30 days', apply: () => applyLastDays(30) },
|
||||||
|
{ label: '90 days', apply: () => applyLastDays(90) },
|
||||||
|
{ label: 'Month, last year', apply: applyThisMonthLastYear }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Manual inputs ────────────────────────────────────────────────────────────
|
||||||
|
// Editable mirrors of the props, re-seeded whenever a preset changes the range.
|
||||||
|
let localStart = $state('');
|
||||||
|
let localEnd = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
localStart = start;
|
||||||
|
localEnd = end;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep start <= end and inside the allowed window before emitting.
|
||||||
|
function commit() {
|
||||||
|
let s = localStart;
|
||||||
|
let e = localEnd;
|
||||||
|
if (s > e) [s, e] = [e, s];
|
||||||
|
if (s < minDate) s = minDate;
|
||||||
|
if (e > maxDate) e = maxDate;
|
||||||
|
onChange(s, e);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 rounded-xl border border-border/70 bg-card p-3 shadow-sm sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
|
||||||
|
<input
|
||||||
|
id="hist-start"
|
||||||
|
type="date"
|
||||||
|
bind:value={localStart}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-1">
|
||||||
|
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
|
||||||
|
<input
|
||||||
|
id="hist-end"
|
||||||
|
type="date"
|
||||||
|
bind:value={localEnd}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
onchange={commit}
|
||||||
|
class="h-9 rounded-lg border border-border bg-background px-2.5 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Quick ranges"
|
||||||
|
>
|
||||||
|
{#each presets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ClimateNormals,
|
||||||
|
type HistoricalDailyData,
|
||||||
|
monthDayToOrdinal
|
||||||
|
} from '$lib/services/weather';
|
||||||
|
|
||||||
|
import { getColor, getTempStyle } from '../../utils/colors';
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: HistoricalDailyData;
|
||||||
|
dailyDates: Date[];
|
||||||
|
timezone: string;
|
||||||
|
units: WeatherUnits;
|
||||||
|
normals: ClimateNormals | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
onSelectDay: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { daily, dailyDates, timezone, units, normals, selectedDay, onSelectDay }: Props = $props();
|
||||||
|
|
||||||
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
|
const precipUnit = $derived(getPrecipUnit(units));
|
||||||
|
|
||||||
|
// Day-of-year ordinal for each day, so we can look up its climate normal.
|
||||||
|
let ordinals = $derived(
|
||||||
|
dailyDates.map((d) =>
|
||||||
|
monthDayToOrdinal(
|
||||||
|
Number(formatZoned(d, timezone, 'M')),
|
||||||
|
Number(formatZoned(d, timezone, 'd'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finite = (v: number | undefined): v is number => v != null && Number.isFinite(v);
|
||||||
|
const mean = (xs: number[]): number =>
|
||||||
|
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
|
||||||
|
|
||||||
|
// ─── Range scaling for the min/max bars ─────────────────────────────────────
|
||||||
|
const BAR_H = 78;
|
||||||
|
let scale = $derived.by(() => {
|
||||||
|
const lo = Math.min(...daily.temperature_2m_min.filter(finite));
|
||||||
|
const hi = Math.max(...daily.temperature_2m_max.filter(finite));
|
||||||
|
const span = hi - lo || 1;
|
||||||
|
return { lo, hi, span };
|
||||||
|
});
|
||||||
|
const yOf = (v: number): number => (1 - (v - scale.lo) / scale.span) * BAR_H;
|
||||||
|
|
||||||
|
// ─── Summary statistics ─────────────────────────────────────────────────────
|
||||||
|
let stats = $derived.by(() => {
|
||||||
|
const means = daily.temperature_2m_mean.filter(finite);
|
||||||
|
const avg = mean(means);
|
||||||
|
|
||||||
|
// warmest / coldest day
|
||||||
|
let warm = { t: -Infinity, i: -1 };
|
||||||
|
let cold = { t: Infinity, i: -1 };
|
||||||
|
for (let i = 0; i < dailyDates.length; i++) {
|
||||||
|
const mx = daily.temperature_2m_max[i];
|
||||||
|
const mn = daily.temperature_2m_min[i];
|
||||||
|
if (finite(mx) && mx > warm.t) warm = { t: mx, i };
|
||||||
|
if (finite(mn) && mn < cold.t) cold = { t: mn, i };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPrecip = daily.precipitation_sum.filter(finite).reduce((a, b) => a + b, 0);
|
||||||
|
const wetDays = daily.precipitation_sum.filter((p) => finite(p) && p >= 1).length;
|
||||||
|
|
||||||
|
// climate comparison (only when normals are available)
|
||||||
|
let tempAnomaly: number | null = null;
|
||||||
|
let normalPrecip: number | null = null;
|
||||||
|
if (normals) {
|
||||||
|
const normMeans: number[] = [];
|
||||||
|
let np = 0;
|
||||||
|
let npCount = 0;
|
||||||
|
for (let i = 0; i < ordinals.length; i++) {
|
||||||
|
const nm = normals.tmean[ordinals[i]];
|
||||||
|
if (finite(nm)) normMeans.push(nm);
|
||||||
|
const npv = normals.precip[ordinals[i]];
|
||||||
|
if (finite(npv)) {
|
||||||
|
np += npv;
|
||||||
|
npCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normMeans.length && finite(avg)) tempAnomaly = avg - mean(normMeans);
|
||||||
|
if (npCount) normalPrecip = np;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avg, warm, cold, totalPrecip, wetDays, tempAnomaly, normalPrecip };
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '–');
|
||||||
|
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
|
||||||
|
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)} ${precipUnit}`;
|
||||||
|
|
||||||
|
function anomalyColor(delta: number): string {
|
||||||
|
const a = Math.min(0.9, 0.25 + Math.abs(delta) / 12);
|
||||||
|
return delta >= 0 ? `rgba(220, 70, 60, ${a})` : `rgba(50, 110, 210, ${a})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
|
||||||
|
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">
|
||||||
|
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
|
||||||
|
>{tempUnit.replace('°', '')}</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{#if stats.tempAnomaly != null}
|
||||||
|
<p
|
||||||
|
class="mt-0.5 text-xs font-semibold"
|
||||||
|
class:text-red-600={stats.tempAnomaly >= 0}
|
||||||
|
class:text-blue-600={stats.tempAnomaly < 0}
|
||||||
|
class:dark:text-red-400={stats.tempAnomaly >= 0}
|
||||||
|
class:dark:text-blue-400={stats.tempAnomaly < 0}
|
||||||
|
>
|
||||||
|
{fmtSigned(stats.tempAnomaly)} vs normal
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">1991–2020 normal loading…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
|
||||||
|
{#if stats.normalPrecip != null}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1
|
||||||
|
? 'day'
|
||||||
|
: 'days'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">{stats.wetDays} wet days</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
|
||||||
|
{#if stats.warm.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
|
||||||
|
{#if stats.cold.i >= 0}
|
||||||
|
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
{formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Daily strip: min/max range bars + anomaly + precip ─────────────────────── -->
|
||||||
|
<section
|
||||||
|
class="mt-4 -mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||||
|
<h3 class="text-base font-bold">
|
||||||
|
Daily <span class="font-semibold text-muted-foreground">– select a day for hourly detail</span
|
||||||
|
>
|
||||||
|
</h3>
|
||||||
|
{#if normals}
|
||||||
|
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||||
|
normal band = 1991–2020 mean
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="flex min-w-min">
|
||||||
|
{#each dailyDates as date, i (date.getTime())}
|
||||||
|
{@const selected = isSameDayInZone(date, selectedDay, timezone)}
|
||||||
|
{@const tMax = daily.temperature_2m_max[i]}
|
||||||
|
{@const tMin = daily.temperature_2m_min[i]}
|
||||||
|
{@const tMean = daily.temperature_2m_mean[i]}
|
||||||
|
{@const norm = normals ? normals.tmean[ordinals[i]] : NaN}
|
||||||
|
{@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null}
|
||||||
|
{@const precip = daily.precipitation_sum[i]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-[52px] shrink-0 cursor-pointer flex-col items-center gap-1 border-r border-border/40 px-1 py-2 text-center transition-colors hover:bg-muted/60 {selected
|
||||||
|
? 'bg-primary/10'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => onSelectDay(date)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span class="text-[11px] font-semibold {selected ? 'text-primary' : 'text-foreground'}">
|
||||||
|
{formatZoned(date, timezone, 'EEE')}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-muted-foreground"
|
||||||
|
>{formatZoned(date, timezone, 'd MMM')}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
daily.weather_code[i],
|
||||||
|
true
|
||||||
|
)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- min/max range bar -->
|
||||||
|
<div class="relative w-3.5" style="height:{BAR_H}px">
|
||||||
|
{#if finite(norm)}
|
||||||
|
<!-- normal marker -->
|
||||||
|
<div
|
||||||
|
class="absolute -left-0.5 -right-0.5 border-t border-dashed border-muted-foreground/50"
|
||||||
|
style="top:{yOf(norm)}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{#if finite(tMax) && finite(tMin)}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 w-full rounded-full"
|
||||||
|
style="top:{yOf(tMax)}px;height:{Math.max(
|
||||||
|
3,
|
||||||
|
yOf(tMin) - yOf(tMax)
|
||||||
|
)}px;background:{getColor(tMean ?? (tMax + tMin) / 2, units.temperature_unit)}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-bold tabular-nums"
|
||||||
|
style="color:{getTempStyle(tMax, units.temperature_unit).bg}">{fmtTemp(tMax)}</span
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-medium text-muted-foreground tabular-nums"
|
||||||
|
>{fmtTemp(tMin)}</span
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if anomaly != null}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
|
||||||
|
style="background:{anomalyColor(anomaly)}"
|
||||||
|
title="{fmtSigned(anomaly)} vs normal"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if finite(precip) && precip >= 0.1}
|
||||||
|
<span
|
||||||
|
class="mt-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400 tabular-nums"
|
||||||
|
>
|
||||||
|
{precip.toFixed(precip < 10 ? 1 : 0)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { formatZoned } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||||
|
|
||||||
|
import { CanvasChart, groupRange } from '$lib/charts';
|
||||||
|
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
|
||||||
|
import { VARIABLE_BY_KEY, buildPanelDef } from '../../week/[location]/variables';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: FetchedHourly;
|
||||||
|
units: WeatherUnits;
|
||||||
|
loading: boolean;
|
||||||
|
selectedDay: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, units, loading, selectedDay }: Props = $props();
|
||||||
|
|
||||||
|
const CHART_GROUP = 'historical-meteogram';
|
||||||
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
|
||||||
|
let downloadingPng = $state(false);
|
||||||
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
|
let liveCharts = $derived(chartComponents.filter((c): c is CanvasChart => c != null));
|
||||||
|
|
||||||
|
// Same customizable layout as the 7-day meteograms, so the two pages match.
|
||||||
|
let panels = $derived(
|
||||||
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
|
||||||
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
const targetDayStr = formatZoned(day, data.timezone, 'yyyy-MM-dd');
|
||||||
|
const idx = data.hourlyDates.findIndex(
|
||||||
|
(d) => formatZoned(d, data.timezone, 'yyyy-MM-dd') === targetDayStr
|
||||||
|
);
|
||||||
|
return idx === -1 ? null : data.timestamps[idx] / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft band marking the day currently open in the hourly table below.
|
||||||
|
let selectedDayHighlight = $derived.by(() => {
|
||||||
|
const start = dayStartSec(selectedDay);
|
||||||
|
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDaytime(tSec: number): boolean {
|
||||||
|
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pictograms = $derived.by((): { t: number; icon: string }[] => {
|
||||||
|
const codes = data.hourly.weather_code ?? [];
|
||||||
|
const out: { t: number; icon: string }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const code = codes[i];
|
||||||
|
if (code == null || !isFinite(code)) continue;
|
||||||
|
const t = timestampsSec[i];
|
||||||
|
out.push({ t, icon: getWeatherIconName(code, isDaytime(t)) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
|
||||||
|
const dirs = data.hourly.winddirection_10m ?? [];
|
||||||
|
const out: { t: number; deg: number }[] = [];
|
||||||
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
|
const d = dirs[i];
|
||||||
|
if (d == null || !isFinite(d)) continue;
|
||||||
|
out.push({ t: timestampsSec[i], deg: d });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
|
function resetZoom(): void {
|
||||||
|
liveCharts[0]?.resetRange();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenderPanel extends ChartPanel {
|
||||||
|
def: ReturnType<typeof buildPanelDef>;
|
||||||
|
title: string;
|
||||||
|
titleShort: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let renderPanels = $derived.by((): RenderPanel[] =>
|
||||||
|
panels.map((p) => {
|
||||||
|
const def = buildPanelDef(p.variables, data.hourly, units);
|
||||||
|
const title = def.series.map((s) => s.name).join(' · ');
|
||||||
|
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||||||
|
return { ...p, def, title, titleShort };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
|
||||||
|
let maxTopRows = $derived(
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function downloadPng(): Promise<void> {
|
||||||
|
if (liveCharts.length === 0 || downloadingPng) return;
|
||||||
|
downloadingPng = true;
|
||||||
|
try {
|
||||||
|
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||||||
|
await downloadChartsPng(items, 'historical-weather');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => (downloadingPng = false), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
Meteograms <span class="font-semibold text-muted-foreground">– full range</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||||
|
drag or
|
||||||
|
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||||
|
>Ctrl</kbd
|
||||||
|
>
|
||||||
|
+ scroll to zoom
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={liveCharts.length === 0 || downloadingPng}
|
||||||
|
onclick={downloadPng}
|
||||||
|
title="Download meteogram as PNG image"
|
||||||
|
>
|
||||||
|
{#if downloadingPng}Rendering…{:else}PNG{/if}
|
||||||
|
</button>
|
||||||
|
<!-- Kept last so toggling it wraps onto a new line instead of shifting
|
||||||
|
the other controls (no layout shift on mobile). -->
|
||||||
|
{#if zoomActive}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||||
|
onclick={resetZoom}
|
||||||
|
>
|
||||||
|
Reset zoom
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if renderPanels.length === 0}
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
No meteograms configured. Add variables from the 7-day forecast page.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
|
<div
|
||||||
|
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
|
||||||
|
? 'border-t border-border/50'
|
||||||
|
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
|
||||||
|
>
|
||||||
|
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
|
||||||
|
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||||
|
<span class="hidden lg:inline">{panel.title}</span>
|
||||||
|
<span class="lg:hidden">{panel.titleShort}</span>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<ChartContainer
|
||||||
|
{loading}
|
||||||
|
chartCount={1}
|
||||||
|
chartHeight={CHART_HEIGHT}
|
||||||
|
minWidth={520}
|
||||||
|
bleed={false}
|
||||||
|
>
|
||||||
|
<CanvasChart
|
||||||
|
bind:this={chartComponents[i]}
|
||||||
|
timestamps={timestampsSec}
|
||||||
|
timezone={data.timezone}
|
||||||
|
series={panel.def.series}
|
||||||
|
bands={data.daylightBands}
|
||||||
|
pictograms={panel.def.hasPictograms ? pictograms : []}
|
||||||
|
windArrows={panel.def.hasWindArrows ? windArrowMarks : []}
|
||||||
|
reserveRightAxis={anyRightAxis}
|
||||||
|
reserveTopRows={maxTopRows}
|
||||||
|
highlight={selectedDayHighlight}
|
||||||
|
unit={panel.def.unit}
|
||||||
|
unitRight={panel.def.unitRight}
|
||||||
|
yMin={panel.def.yMin}
|
||||||
|
zeroBaseLeft={panel.def.zeroBaseLeft}
|
||||||
|
yMinRight={panel.def.yMinRight}
|
||||||
|
yMaxRight={panel.def.yMaxRight}
|
||||||
|
showCredit={i === renderPanels.length - 1}
|
||||||
|
showLegend
|
||||||
|
height={CHART_HEIGHT}
|
||||||
|
group={CHART_GROUP}
|
||||||
|
/>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[
|
|
||||||
"kinshasa",
|
|
||||||
"shenzhen",
|
|
||||||
"shanghai",
|
|
||||||
"guangzhou",
|
|
||||||
"chengdu",
|
|
||||||
"beijing",
|
|
||||||
"mumbai",
|
|
||||||
"lagos",
|
|
||||||
"lahore",
|
|
||||||
"istanbul"
|
|
||||||
]
|
|
||||||
@@ -1,8 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Minimal page: no geolocation/state UI, just an embedded Open-Meteo map.
|
import { onMount } from 'svelte';
|
||||||
// const iframeSrc = 'https://maps.open-meteo.com/';
|
|
||||||
const iframeSrc =
|
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
|
||||||
'https://allow-cross-origin-loads.maps-5aj.pages.dev/?time=2026-02-21T1400#1.31/0/-0.1';
|
|
||||||
|
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||||
|
|
||||||
|
// Hash piping, both directions:
|
||||||
|
// - inbound: a #zoom/lat/lng(/bearing/pitch) hash on OUR url seeds the
|
||||||
|
// map, so positions can be bookmarked/shared via drizzli links
|
||||||
|
// - outbound: the (cross-origin) map posts its hash here on every moveend;
|
||||||
|
// we mirror it into our url with replaceState. hashOverride is only set
|
||||||
|
// once on mount, so these mirror updates never reload the iframe.
|
||||||
|
let hashOverride = $state<string | null>(null);
|
||||||
|
let iframeEl = $state<HTMLIFrameElement | null>(null);
|
||||||
|
let mapReady = $state(false);
|
||||||
|
|
||||||
|
const postToMap = (message: Record<string, unknown>) => {
|
||||||
|
iframeEl?.contentWindow?.postMessage(message, MAPS_ORIGIN);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local maps dev server (open-meteo/maps); production: https://maps.open-meteo.com
|
||||||
|
// Run drizzli on a different port so the map keeps 5173 to itself.
|
||||||
|
// const MAPS_ORIGIN = 'http://localhost:5173';
|
||||||
|
const MAPS_ORIGIN = 'https://maps.open-meteo.com';
|
||||||
|
|
||||||
|
const MAP_HASH_RE = /^#\d+(\.\d+)?\/-?\d+(\.\d+)?\/-?\d+(\.\d+)?/;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const initialHash = window.location.hash;
|
||||||
|
hashOverride = MAP_HASH_RE.test(initialHash) ? initialHash : null;
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.origin !== MAPS_ORIGIN) return;
|
||||||
|
const { type, hash, domains } = (event.data ?? {}) as {
|
||||||
|
type?: string;
|
||||||
|
hash?: string;
|
||||||
|
domains?: string[];
|
||||||
|
};
|
||||||
|
if (type === 'om-maps:hash') {
|
||||||
|
if (!hash || !MAP_HASH_RE.test(hash)) return;
|
||||||
|
history.replaceState(history.state, '', hash);
|
||||||
|
} else if (type === 'om-maps:ready' && Array.isArray(domains)) {
|
||||||
|
// The map is loaded and advertises which domains it can render;
|
||||||
|
// switch it to the selected model and our theme. The domain is
|
||||||
|
// omitted for best_match and models the map does not serve,
|
||||||
|
// keeping the map's own default. Re-fires on iframe reloads.
|
||||||
|
mapReady = true;
|
||||||
|
const domain = mapsDomainForModel($storedModel, new Set(domains));
|
||||||
|
postToMap({ type: 'om-maps:set', ...(domain && { domain }), theme: $storedTheme });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
// the embedded map understands maplibre's #zoom/lat/lng hash, so the iframe
|
||||||
|
// opens focused on the selected location (zoomed out to regional scale);
|
||||||
|
// picking a new location while on this page recenters the map
|
||||||
|
const iframeSrc = $derived(
|
||||||
|
`${MAPS_ORIGIN}/${
|
||||||
|
hashOverride ??
|
||||||
|
`#6/${$storedLocation.latitude.toFixed(3)}/${$storedLocation.longitude.toFixed(3)}`
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// forward theme switches live; the initial theme travels with the
|
||||||
|
// ready response above (the map ignores no-op updates)
|
||||||
|
$effect(() => {
|
||||||
|
const theme = $storedTheme;
|
||||||
|
if (!mapReady) return;
|
||||||
|
postToMap({ type: 'om-maps:set', theme });
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,18 +79,21 @@
|
|||||||
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<!-- Full-viewport map. No surrounding UI or location state. -->
|
<!-- Full-bleed map: the layout drops its padding for this route. The map
|
||||||
<div
|
follows our theme through the color-scheme declared on :root/.dark -->
|
||||||
style="position:relative; inset:0; margin:0; padding:0; height:100%; width:100%; background:#000;"
|
<div class="h-full w-full bg-background">
|
||||||
>
|
<!-- allow="cross-origin-isolated" delegates SharedArrayBuffer use to the
|
||||||
|
map; it only takes effect when this site itself is served with
|
||||||
|
COOP/COEP headers (see README, Deployment) -->
|
||||||
<iframe
|
<iframe
|
||||||
|
bind:this={iframeEl}
|
||||||
src={iframeSrc}
|
src={iframeSrc}
|
||||||
title="Open-Meteo Interactive Map"
|
title="Open-Meteo Interactive Map"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
|
allow="cross-origin-isolated"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
class="map-iframe"
|
class="block h-full w-full border-0"
|
||||||
style="border:0; width:100%; height:100%; display:block;"
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
||||||
></iframe>
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+430
-48
@@ -5,56 +5,310 @@ export const defaultParameters = {
|
|||||||
precipitation_unit: 'mm'
|
precipitation_unit: 'mm'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const models = [
|
export interface WeatherModel {
|
||||||
{ value: 'best_match', label: 'Best match' },
|
value: string;
|
||||||
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' },
|
label: string;
|
||||||
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' },
|
/** Native grid resolution, from open-meteo/weather-map-layer domains.ts */
|
||||||
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' },
|
resolution?: string;
|
||||||
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' },
|
/** Model-run cadence (model_interval), from domains.ts */
|
||||||
{ value: 'bom_access_global', label: 'BOM ACCESS Global' },
|
update?: string;
|
||||||
{ value: 'kma_seamless', label: 'KMA Seamless' },
|
}
|
||||||
{ value: 'kma_ldps', label: 'KMA LDPS' },
|
|
||||||
{ value: 'kma_gdps', label: 'KMA GDPS' },
|
export interface WeatherModelGroup {
|
||||||
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' },
|
value: string;
|
||||||
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' },
|
label: string;
|
||||||
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' },
|
models: WeatherModel[];
|
||||||
{ value: 'meteofrance_arome_france', label: 'Meteo-France AROME France' },
|
}
|
||||||
{ value: 'meteofrance_arome_france_hd', label: 'Meteo-France AROME France HD' },
|
|
||||||
{ value: 'knmi_seamless', label: 'KNMI Seamless' },
|
/**
|
||||||
{ value: 'knmi_harmonie_arome_europe', label: 'KNMI Harmonie Arome Europe' },
|
* Forecast models grouped by provider. Labels, grid resolutions and update
|
||||||
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' },
|
* cadences are synced from the open-meteo/weather-map-layer project
|
||||||
{ value: 'dmi_seamless', label: 'DMI Seamless' },
|
* (src/domains.ts: domainGroups + domainOptions grid/model_interval data).
|
||||||
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' },
|
*/
|
||||||
{ value: 'ukmo_seamless', label: 'UKMO Seamless' },
|
export const modelGroups: WeatherModelGroup[] = [
|
||||||
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' },
|
{
|
||||||
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' },
|
value: 'auto',
|
||||||
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' },
|
label: 'Automatic',
|
||||||
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' },
|
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
|
||||||
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' },
|
},
|
||||||
{ value: 'metno_nordic', label: 'MET Norway Nordic' },
|
{
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless' },
|
value: 'ecmwf',
|
||||||
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' },
|
label: 'ECMWF',
|
||||||
{ value: 'gem_regional', label: 'GEM Regional' },
|
models: [
|
||||||
{ value: 'gem_global', label: 'GEM Global' },
|
{ value: 'ecmwf_ifs', label: 'ECMWF IFS HRES', resolution: '9 km', update: 'every 6 h' },
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25°', resolution: '25 km', update: 'every 6 h' },
|
||||||
{ value: 'jma_seamless', label: 'JMA Seamless' },
|
{
|
||||||
{ value: 'jma_msm', label: 'JMA MSM' },
|
value: 'ecmwf_aifs025_single',
|
||||||
{ value: 'jma_gsm', label: 'JMA GSM' },
|
label: 'ECMWF AIFS 0.25° Single',
|
||||||
{ value: 'gfs_seamless', label: 'GFS Seamless' },
|
resolution: '25 km',
|
||||||
{ value: 'gfs_global', label: 'GFS Global' },
|
update: 'every 6 h'
|
||||||
{ value: 'gfs_hrrr', label: 'GFS HRRR' },
|
}
|
||||||
{ value: 'gfs_graphcast025', label: 'GFS Graphcast 0.25' },
|
]
|
||||||
{ value: 'ncep_nbm_conus', label: 'NCEP NBM CONUS' },
|
},
|
||||||
{ value: 'ncep_nam_conus', label: 'NCEP NAM CONUS' },
|
{
|
||||||
{ value: 'ncep_aigfs025', label: 'NCEP AIGFS 0.25' },
|
value: 'dwd',
|
||||||
{ value: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' },
|
label: 'DWD Germany',
|
||||||
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' },
|
models: [
|
||||||
{ value: 'icon_global', label: 'ICON Global' },
|
{
|
||||||
{ value: 'icon_eu', label: 'ICON EU' },
|
value: 'icon_seamless',
|
||||||
{ value: 'icon_d2', label: 'ICON-D2' },
|
label: 'DWD ICON Seamless',
|
||||||
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' }
|
resolution: '2-13 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{ value: 'icon_global', label: 'DWD ICON', resolution: '13 km', update: 'every 6 h' },
|
||||||
|
{ value: 'icon_eu', label: 'DWD ICON EU', resolution: '7 km', update: 'every 3 h' },
|
||||||
|
{ value: 'icon_d2', label: 'DWD ICON D2', resolution: '2 km', update: 'every 3 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep',
|
||||||
|
label: 'NOAA U.S.',
|
||||||
|
models: [
|
||||||
|
{ value: 'gfs_seamless', label: 'GFS Seamless', resolution: '3-25 km', update: 'every hour' },
|
||||||
|
{ value: 'gfs_global', label: 'GFS Global', resolution: '13 km', update: 'every 6 h' },
|
||||||
|
{ value: 'gfs_hrrr', label: 'GFS HRRR Conus', resolution: '3 km', update: 'every hour' },
|
||||||
|
{
|
||||||
|
value: 'gfs_graphcast025',
|
||||||
|
label: 'GFS GraphCast 0.25°',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_aigfs025',
|
||||||
|
label: 'GFS AIGFS 0.25°',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_hgefs025_ensemble_mean',
|
||||||
|
label: 'GFS HGEFS 0.25° Ensemble Mean',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_nbm_conus',
|
||||||
|
label: 'GFS NBM Conus',
|
||||||
|
resolution: '2.5 km',
|
||||||
|
update: 'every hour'
|
||||||
|
},
|
||||||
|
{ value: 'ncep_nam_conus', label: 'GFS NAM Conus', resolution: '12 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteofrance',
|
||||||
|
label: 'Météo-France',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'meteofrance_seamless',
|
||||||
|
label: 'MF Seamless',
|
||||||
|
resolution: '1-25 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteofrance_arpege_world',
|
||||||
|
label: 'MF ARPEGE World',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteofrance_arpege_europe',
|
||||||
|
label: 'MF ARPEGE Europe',
|
||||||
|
resolution: '10 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteofrance_arome_france',
|
||||||
|
label: 'MF AROME France',
|
||||||
|
resolution: '2.5 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteofrance_arome_france_hd',
|
||||||
|
label: 'MF AROME France HD',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ukmo',
|
||||||
|
label: 'UK Met Office',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ukmo_seamless',
|
||||||
|
label: 'UKMO Seamless',
|
||||||
|
resolution: '2-10 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ukmo_global_deterministic_10km',
|
||||||
|
label: 'UK Met Office 10km',
|
||||||
|
resolution: '10 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ukmo_uk_deterministic_2km',
|
||||||
|
label: 'UK Met Office 2km',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'knmi',
|
||||||
|
label: 'KNMI Netherlands',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'knmi_seamless',
|
||||||
|
label: 'KNMI Seamless',
|
||||||
|
resolution: '2-3 km',
|
||||||
|
update: 'every hour'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'knmi_harmonie_arome_europe',
|
||||||
|
label: 'KNMI Harmonie Arome Europe',
|
||||||
|
resolution: '5.5 km',
|
||||||
|
update: 'every hour'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'knmi_harmonie_arome_netherlands',
|
||||||
|
label: 'KNMI Harmonie Arome Netherlands',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every hour'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dmi',
|
||||||
|
label: 'DMI Denmark',
|
||||||
|
models: [
|
||||||
|
{ value: 'dmi_seamless', label: 'DMI Seamless', resolution: '2 km', update: 'every 3 h' },
|
||||||
|
{
|
||||||
|
value: 'dmi_harmonie_arome_europe',
|
||||||
|
label: 'DMI Harmonie Arome Europe',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'metno',
|
||||||
|
label: 'MET Norway',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'metno_seamless',
|
||||||
|
label: 'MET Norway Seamless',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{ value: 'metno_nordic', label: 'MET Norway Nordic', resolution: '1 km', update: 'every 3 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss',
|
||||||
|
label: 'MeteoSwiss',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_seamless',
|
||||||
|
label: 'MeteoSwiss ICON Seamless',
|
||||||
|
resolution: '1-2 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch1',
|
||||||
|
label: 'MeteoSwiss ICON CH1',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch2',
|
||||||
|
label: 'MeteoSwiss ICON CH2',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'kma',
|
||||||
|
label: 'KMA Korea',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'kma_seamless',
|
||||||
|
label: 'KMA Seamless',
|
||||||
|
resolution: '1.5-13 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
},
|
||||||
|
{ value: 'kma_ldps', label: 'KMA LDPS', resolution: '1.5 km', update: 'every 3 h' },
|
||||||
|
{ value: 'kma_gdps', label: 'KMA GDPS 12km', resolution: '13 km', update: 'every 3 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'jma',
|
||||||
|
label: 'JMA Japan',
|
||||||
|
models: [
|
||||||
|
{ value: 'jma_seamless', label: 'JMA Seamless', resolution: '5-55 km', update: 'every 3 h' },
|
||||||
|
{ value: 'jma_msm', label: 'JMA MSM', resolution: '5 km', update: 'every 3 h' },
|
||||||
|
{ value: 'jma_gsm', label: 'JMA GSM', resolution: '55 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'cma',
|
||||||
|
label: 'CMA China',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'cma_grapes_global',
|
||||||
|
label: 'CMA GRAPES Global',
|
||||||
|
resolution: '14 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'bom',
|
||||||
|
label: 'BOM Australia',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'bom_access_global',
|
||||||
|
label: 'BOM ACCESS Global',
|
||||||
|
resolution: '15 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'cmc_gem',
|
||||||
|
label: 'GEM Canada',
|
||||||
|
models: [
|
||||||
|
{ value: 'gem_seamless', label: 'GEM Seamless', resolution: '1-15 km', update: 'every 6 h' },
|
||||||
|
{ value: 'gem_global', label: 'GEM Global', resolution: '15 km', update: 'every 12 h' },
|
||||||
|
{ value: 'gem_regional', label: 'GEM Regional', resolution: '10 km', update: 'every 6 h' },
|
||||||
|
{
|
||||||
|
value: 'gem_hrdps_west',
|
||||||
|
label: 'GEM HRDPS West',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'italia_meteo',
|
||||||
|
label: 'ItaliaMeteo',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'italia_meteo_arpae_icon_2i',
|
||||||
|
label: 'IM ARPAE ICON 2i',
|
||||||
|
resolution: '2.5 km',
|
||||||
|
update: 'every 3 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const models: WeatherModel[] = modelGroups.flatMap((group) => group.models);
|
||||||
|
|
||||||
|
export const findModel = (value: string): WeatherModel | undefined =>
|
||||||
|
models.find((model) => model.value === value);
|
||||||
|
|
||||||
export const hourly = [
|
export const hourly = [
|
||||||
[
|
[
|
||||||
{ value: 'temperature_2m', label: 'Temperature 2m' },
|
{ value: 'temperature_2m', label: 'Temperature 2m' },
|
||||||
@@ -97,3 +351,131 @@ export const hourly = [
|
|||||||
{ value: 'temperature_180m', label: 'Temperature 180m' }
|
{ value: 'temperature_180m', label: 'Temperature 180m' }
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensemble models for the 14-day spread forecast, synced from the
|
||||||
|
* open-meteo/website project (src/routes/en/docs/ensemble-api/options.ts).
|
||||||
|
* Resolutions/update cadences from weather-map-layer domains.ts where known.
|
||||||
|
*/
|
||||||
|
export const ensembleModelGroups: WeatherModelGroup[] = [
|
||||||
|
{
|
||||||
|
value: 'dwd',
|
||||||
|
label: 'DWD Germany',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'icon_seamless_eps',
|
||||||
|
label: 'DWD ICON EPS Seamless',
|
||||||
|
resolution: '2-25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'icon_global_eps',
|
||||||
|
label: 'DWD ICON EPS Global',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
},
|
||||||
|
{ value: 'icon_eu_eps', label: 'DWD ICON EPS EU', resolution: '13 km', update: 'every 6 h' },
|
||||||
|
{ value: 'icon_d2_eps', label: 'DWD ICON EPS D2', resolution: '2 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep',
|
||||||
|
label: 'NOAA U.S.',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs_seamless',
|
||||||
|
label: 'GFS Ensemble Seamless',
|
||||||
|
resolution: '25-50 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs025',
|
||||||
|
label: 'GFS Ensemble 0.25°',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ncep_gefs05',
|
||||||
|
label: 'GFS Ensemble 0.5°',
|
||||||
|
resolution: '50 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{ value: 'ncep_aigefs025', label: 'AIGEFS 0.25°', resolution: '25 km', update: 'every 6 h' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ecmwf',
|
||||||
|
label: 'ECMWF',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ecmwf_ifs025_ensemble',
|
||||||
|
label: 'ECMWF IFS 0.25° Ensemble',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ecmwf_aifs025_ensemble',
|
||||||
|
label: 'ECMWF AIFS 0.25° Ensemble',
|
||||||
|
resolution: '25 km',
|
||||||
|
update: 'every 6 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'cmc_gem',
|
||||||
|
label: 'GEM Canada',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'gem_global_ensemble',
|
||||||
|
label: 'GEM Global Ensemble',
|
||||||
|
resolution: '50 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'bom',
|
||||||
|
label: 'BOM Australia',
|
||||||
|
models: [{ value: 'bom_access_global_ensemble', label: 'BOM ACCESS Global' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ukmo',
|
||||||
|
label: 'UK Met Office',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'ukmo_global_ensemble_20km',
|
||||||
|
label: 'UK MetOffice Global 20km',
|
||||||
|
resolution: '20 km'
|
||||||
|
},
|
||||||
|
{ value: 'ukmo_uk_ensemble_2km', label: 'UK MetOffice UK 2km', resolution: '2 km' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss',
|
||||||
|
label: 'MeteoSwiss',
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch1_ensemble',
|
||||||
|
label: 'MeteoSwiss ICON CH1',
|
||||||
|
resolution: '1 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'meteoswiss_icon_ch2_ensemble',
|
||||||
|
label: 'MeteoSwiss ICON CH2',
|
||||||
|
resolution: '2 km',
|
||||||
|
update: 'every 12 h'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'google',
|
||||||
|
label: 'Google',
|
||||||
|
models: [{ value: 'google_weathernext2_ensemble', label: 'Google WeatherNext 2 Ensemble' }]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ensembleModels: WeatherModel[] = ensembleModelGroups.flatMap((group) => group.models);
|
||||||
|
|
||||||
|
export const findEnsembleModel = (value: string): WeatherModel | undefined =>
|
||||||
|
ensembleModels.find((model) => model.value === value);
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
export default [
|
|
||||||
'#800080',
|
|
||||||
'#800083',
|
|
||||||
'#800087',
|
|
||||||
'#7f008a',
|
|
||||||
'#7f008d',
|
|
||||||
'#7e0090',
|
|
||||||
'#7d0094',
|
|
||||||
'#7c0097',
|
|
||||||
'#7a009a',
|
|
||||||
'#79009d',
|
|
||||||
'#7700a1',
|
|
||||||
'#7600a4',
|
|
||||||
'#7400a7',
|
|
||||||
'#7200aa',
|
|
||||||
'#6f00ae',
|
|
||||||
'#6d00b1',
|
|
||||||
'#6a00b4',
|
|
||||||
'#6700b7',
|
|
||||||
'#6400bb',
|
|
||||||
'#6100be',
|
|
||||||
'#5e00c1',
|
|
||||||
'#5b00c4',
|
|
||||||
'#5700c8',
|
|
||||||
'#5300cb',
|
|
||||||
'#4f00ce',
|
|
||||||
'#4b00d1',
|
|
||||||
'#4700d5',
|
|
||||||
'#4200d8',
|
|
||||||
'#3e00db',
|
|
||||||
'#3900de',
|
|
||||||
'#3400e2',
|
|
||||||
'#2f00e5',
|
|
||||||
'#2a00e8',
|
|
||||||
'#2400eb',
|
|
||||||
'#1f00ef',
|
|
||||||
'#1900f2',
|
|
||||||
'#1300f5',
|
|
||||||
'#0d00f8',
|
|
||||||
'#0600fc',
|
|
||||||
'#0000ff',
|
|
||||||
'#0000ff',
|
|
||||||
'#0021f7',
|
|
||||||
'#003fee',
|
|
||||||
'#005ce6',
|
|
||||||
'#0076dd',
|
|
||||||
'#008ed5',
|
|
||||||
'#00a3cc',
|
|
||||||
'#00b7c4',
|
|
||||||
'#00bbaf',
|
|
||||||
'#00b38f',
|
|
||||||
'#00aa72',
|
|
||||||
'#00a256',
|
|
||||||
'#00993d',
|
|
||||||
'#009127',
|
|
||||||
'#008812',
|
|
||||||
'#008000',
|
|
||||||
'#008000',
|
|
||||||
'#118c00',
|
|
||||||
'#259700',
|
|
||||||
'#3ca300',
|
|
||||||
'#56ae00',
|
|
||||||
'#72ba00',
|
|
||||||
'#92c500',
|
|
||||||
'#b4d100',
|
|
||||||
'#d9dc00',
|
|
||||||
'#e8cf00',
|
|
||||||
'#f3bb00',
|
|
||||||
'#ffa500',
|
|
||||||
'#ffa500',
|
|
||||||
'#ff9800',
|
|
||||||
'#ff8c00',
|
|
||||||
'#ff7f00',
|
|
||||||
'#ff7200',
|
|
||||||
'#ff6600',
|
|
||||||
'#ff5900',
|
|
||||||
'#ff4c00',
|
|
||||||
'#ff3f00',
|
|
||||||
'#ff3300',
|
|
||||||
'#ff2600',
|
|
||||||
'#ff1900',
|
|
||||||
'#ff0d00',
|
|
||||||
'#ff0000',
|
|
||||||
'#ff0000',
|
|
||||||
'#f8000f',
|
|
||||||
'#f0001c',
|
|
||||||
'#e90029',
|
|
||||||
'#e10035',
|
|
||||||
'#da0040',
|
|
||||||
'#d2004a',
|
|
||||||
'#cb0053',
|
|
||||||
'#c3005c',
|
|
||||||
'#bc0063',
|
|
||||||
'#b4006a',
|
|
||||||
'#ad0070',
|
|
||||||
'#a50075',
|
|
||||||
'#9e0079',
|
|
||||||
'#96007c',
|
|
||||||
'#8f007e',
|
|
||||||
'#870080',
|
|
||||||
'#800080'
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Temperature color scale ported from the open-meteo/weather-map-layer
|
||||||
|
* project (src/utils/color-scales.ts) so tables and maps share the same
|
||||||
|
* color language. Values between breakpoints are linearly interpolated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RGBA = [number, number, number, number];
|
||||||
|
|
||||||
|
export interface BreakpointScale {
|
||||||
|
unit: string;
|
||||||
|
breakpoints: number[];
|
||||||
|
colors: RGBA[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const temperatureScale: BreakpointScale = {
|
||||||
|
unit: '°C',
|
||||||
|
breakpoints: [
|
||||||
|
-80, -65, -50, -40, -32, -28, -24, -20, -17.5, -15, -12.5, -10, -8, -6, -4, -2, 0, 2, 4, 6, 8,
|
||||||
|
10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50
|
||||||
|
],
|
||||||
|
colors: [
|
||||||
|
[74, 13, 0, 1],
|
||||||
|
[130, 1, 29, 1],
|
||||||
|
[185, 4, 114, 1],
|
||||||
|
[221, 6, 193, 1],
|
||||||
|
[207, 6, 241, 1],
|
||||||
|
[163, 5, 243, 1],
|
||||||
|
[118, 4, 246, 1],
|
||||||
|
[71, 3, 249, 1],
|
||||||
|
[41, 2, 250, 1],
|
||||||
|
[11, 1, 252, 1],
|
||||||
|
[1, 22, 253, 1],
|
||||||
|
[0, 52, 255, 1],
|
||||||
|
[35, 99, 251, 1],
|
||||||
|
[69, 139, 247, 1],
|
||||||
|
[102, 171, 245, 1],
|
||||||
|
[134, 197, 245, 1],
|
||||||
|
[114, 232, 165, 1],
|
||||||
|
[78, 232, 133, 1],
|
||||||
|
[40, 233, 96, 1],
|
||||||
|
[17, 220, 61, 1],
|
||||||
|
[9, 191, 36, 1],
|
||||||
|
[4, 160, 15, 1],
|
||||||
|
[0, 128, 0, 1],
|
||||||
|
[40, 160, 0, 1],
|
||||||
|
[96, 192, 0, 1],
|
||||||
|
[167, 223, 0, 1],
|
||||||
|
[255, 255, 0, 1],
|
||||||
|
[255, 237, 0, 1],
|
||||||
|
[255, 219, 0, 1],
|
||||||
|
[255, 201, 0, 1],
|
||||||
|
[255, 183, 0, 1],
|
||||||
|
[255, 165, 0, 1],
|
||||||
|
[255, 138, 0, 1],
|
||||||
|
[255, 110, 0, 1],
|
||||||
|
[255, 83, 0, 1],
|
||||||
|
[255, 55, 0, 1],
|
||||||
|
[255, 27, 0, 1],
|
||||||
|
[255, 0, 0, 1],
|
||||||
|
[228, 0, 10, 1],
|
||||||
|
[201, 0, 18, 1],
|
||||||
|
[174, 0, 23, 1],
|
||||||
|
[147, 0, 26, 1]
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||||
|
|
||||||
|
/** Linearly interpolated color for `value` on a breakpoint scale. */
|
||||||
|
export const sampleScale = (breakpoints: number[], colors: RGBA[], value: number): RGBA => {
|
||||||
|
if (!Number.isFinite(value) || value <= breakpoints[0]) return colors[0];
|
||||||
|
const last = breakpoints.length - 1;
|
||||||
|
if (value >= breakpoints[last]) return colors[last];
|
||||||
|
let i = 0;
|
||||||
|
while (value > breakpoints[i + 1]) i++;
|
||||||
|
const t = (value - breakpoints[i]) / (breakpoints[i + 1] - breakpoints[i]);
|
||||||
|
const [r1, g1, b1, a1] = colors[i];
|
||||||
|
const [r2, g2, b2, a2] = colors[i + 1];
|
||||||
|
return [
|
||||||
|
Math.round(lerp(r1, r2, t)),
|
||||||
|
Math.round(lerp(g1, g2, t)),
|
||||||
|
Math.round(lerp(b1, b2, t)),
|
||||||
|
lerp(a1, a2, t)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rgbaCss = ([r, g, b, a]: RGBA): string =>
|
||||||
|
`rgba(${r}, ${g}, ${b}, ${Math.round(a * 1000) / 1000})`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether text over `color` should be white, after alpha-compositing the
|
||||||
|
* color onto the page background (light or dark).
|
||||||
|
*/
|
||||||
|
export const needsWhiteText = ([r, g, b, a]: RGBA, dark = false): boolean => {
|
||||||
|
const base = dark ? 26 : 255;
|
||||||
|
const cr = r * a + base * (1 - a);
|
||||||
|
const cg = g * a + base * (1 - a);
|
||||||
|
const cb = b * a + base * (1 - a);
|
||||||
|
return cr * 0.299 + cg * 0.587 + cb * 0.114 <= 150;
|
||||||
|
};
|
||||||
@@ -1,55 +1,13 @@
|
|||||||
import colorScaleHex from './color-scale-hex';
|
import { type RGBA, needsWhiteText, rgbaCss, sampleScale, temperatureScale } from './color-scales';
|
||||||
|
|
||||||
const componentFromStr = (numStr: string, percent: number) => {
|
const toCelsius = (temperature: number, unit: string): number =>
|
||||||
const num = Math.max(0, parseInt(numStr, 10));
|
unit === 'celsius' ? temperature : ((temperature - 32) * 5) / 9;
|
||||||
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rgbToHex = (rgb: string): string => {
|
export const getTempColor = (temperature: number, unit = 'celsius'): RGBA =>
|
||||||
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
sampleScale(temperatureScale.breakpoints, temperatureScale.colors, toCelsius(temperature, unit));
|
||||||
let result,
|
|
||||||
r,
|
|
||||||
g,
|
|
||||||
b,
|
|
||||||
hex = '';
|
|
||||||
if ((result = rgbRegex.exec(rgb))) {
|
|
||||||
r = componentFromStr(result[1], Number(result[2]));
|
|
||||||
g = componentFromStr(result[3], Number(result[4]));
|
|
||||||
b = componentFromStr(result[5], Number(result[6]));
|
|
||||||
|
|
||||||
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
export const getColor = (temperature: number, unit = 'celsius'): string =>
|
||||||
}
|
rgbaCss(getTempColor(temperature, unit));
|
||||||
if (!rgb) {
|
|
||||||
return '355522';
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hexToRgb = (hex: string): [number, number, number] => {
|
|
||||||
const h = hex.replace('#', '');
|
|
||||||
return [
|
|
||||||
parseInt(h.substring(0, 2), 16),
|
|
||||||
parseInt(h.substring(2, 4), 16),
|
|
||||||
parseInt(h.substring(4, 6), 16)
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getColor = (temperature: number, unit = 'celsius'): string => {
|
|
||||||
if (unit !== 'celsius') {
|
|
||||||
temperature = Math.round(((temperature - 32) * 5) / 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
if (temperature <= -40) {
|
|
||||||
index = 0;
|
|
||||||
} else if (temperature >= 60) {
|
|
||||||
index = colorScaleHex.length - 1;
|
|
||||||
} else {
|
|
||||||
index = Math.round(temperature) + 45;
|
|
||||||
}
|
|
||||||
|
|
||||||
return colorScaleHex[index];
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface TempStyle {
|
export interface TempStyle {
|
||||||
bg: string;
|
bg: string;
|
||||||
@@ -57,24 +15,6 @@ export interface TempStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getTempStyle = (temp: number, unit: string): TempStyle => {
|
export const getTempStyle = (temp: number, unit: string): TempStyle => {
|
||||||
const bg = getColor(temp, unit);
|
const rgba = getTempColor(temp, unit);
|
||||||
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black';
|
return { bg: rgbaCss(rgba), fg: needsWhiteText(rgba) ? 'white' : 'black' };
|
||||||
return { bg, fg };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const textWhite = (
|
|
||||||
[r, g, b, a]: [number, number, number, number] | [number, number, number],
|
|
||||||
dark?: boolean,
|
|
||||||
globalOpacity?: number
|
|
||||||
): boolean => {
|
|
||||||
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
|
|
||||||
if (alpha < 0.65) {
|
|
||||||
if (dark) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// check luminance
|
|
||||||
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ const weatherCodes: Record<number, string> = {
|
|||||||
51: 'sprinkle',
|
51: 'sprinkle',
|
||||||
52: 'rain',
|
52: 'rain',
|
||||||
53: 'rain',
|
53: 'rain',
|
||||||
54: 'snowflake-cold',
|
54: 'sprinkle',
|
||||||
55: 'snowflake-cold',
|
55: 'rain',
|
||||||
56: 'snowflake-cold',
|
56: 'rain-mix',
|
||||||
57: 'sprinkle',
|
57: 'sprinkle',
|
||||||
58: 'rain',
|
58: 'rain',
|
||||||
60: 'sprinkle',
|
60: 'sprinkle',
|
||||||
@@ -56,11 +56,11 @@ const weatherCodes: Record<number, string> = {
|
|||||||
71: 'snow',
|
71: 'snow',
|
||||||
72: 'snow',
|
72: 'snow',
|
||||||
73: 'snow',
|
73: 'snow',
|
||||||
74: 'snowflake-cold',
|
74: 'snow',
|
||||||
75: 'snowflake-cold',
|
75: 'snow',
|
||||||
76: 'snowflake-cold',
|
76: 'snow',
|
||||||
77: 'snow',
|
77: 'snow',
|
||||||
78: 'snowflake-cold',
|
78: 'snow',
|
||||||
80: 'rain',
|
80: 'rain',
|
||||||
81: 'sprinkle',
|
81: 'sprinkle',
|
||||||
82: 'rain',
|
82: 'rain',
|
||||||
@@ -80,10 +80,13 @@ const weatherCodes: Record<number, string> = {
|
|||||||
99: 'tornado'
|
99: 'tornado'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These conditions ship only as a single neutral glyph (no day/night variant).
|
||||||
|
const NEUTRAL_ICONS = new Set(['snowflake-cold', 'strong-wind', 'dust', 'tornado']);
|
||||||
|
|
||||||
export function getWeatherIconName(code: number, daytime: boolean): string {
|
export function getWeatherIconName(code: number, daytime: boolean): string {
|
||||||
const prefix = daytime ? 'day' : 'night';
|
|
||||||
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
||||||
return `wi-${prefix}-${name}`;
|
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
|
||||||
|
return `wi-${daytime ? 'day' : 'night'}-${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default weatherCodes;
|
export default weatherCodes;
|
||||||
|
|||||||
@@ -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/week/[location]', { location: buildLocationRoute(get(storedLocation)) }),
|
||||||
|
{
|
||||||
|
replaceState: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
const locationRoute = buildLocationRoute(location);
|
|
||||||
throw redirect(303, '/weather/week/' + locationRoute);
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,71 +1,151 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { type Component, onMount } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import {
|
||||||
|
storedChartLayout,
|
||||||
|
storedLocation,
|
||||||
|
storedModel,
|
||||||
|
storedUnits,
|
||||||
|
storedVariablePrefs
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { ChartContainer } from '$lib/components/charts';
|
||||||
|
|
||||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
import DailyCards from './DailyCards.svelte';
|
import DailyCards from './DailyCards.svelte';
|
||||||
|
import DailyStripSticky from './DailyStripSticky.svelte';
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
import HourlyTable from './HourlyTable.svelte';
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
import ModelSelector from './ModelSelector.svelte';
|
||||||
|
import VariableSidebar from './VariableSidebar.svelte';
|
||||||
|
import { neededHourlyApiVars } from './variables';
|
||||||
|
|
||||||
import type { GeoLocation } from '$lib/stores/settings';
|
import type { PageData } from './$types';
|
||||||
import type { FetchedDaily, FetchedHourly } from './types';
|
import type { FetchedDaily, FetchedHourly } from './types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
latitude: [$storedLocation.latitude],
|
|
||||||
longitude: [$storedLocation.longitude],
|
|
||||||
models: ['best_match'],
|
models: ['best_match'],
|
||||||
...defaultParameters
|
...defaultParameters
|
||||||
});
|
});
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
// units live in a persisted store; mirror them into params so a change
|
||||||
storedLocation.subscribe((value) => {
|
// re-runs the fetch effect below (which reads params.*_unit)
|
||||||
location = value;
|
$effect(() => {
|
||||||
|
params.temperature_unit = $storedUnits.temperature_unit;
|
||||||
|
params.wind_speed_unit = $storedUnits.wind_speed_unit;
|
||||||
|
params.precipitation_unit = $storedUnits.precipitation_unit;
|
||||||
|
});
|
||||||
|
|
||||||
|
let variableSidebarOpen = $state(false);
|
||||||
|
|
||||||
|
// Number of meteogram panels: reserves the chart area height before data
|
||||||
|
// arrives (no layout shift)
|
||||||
|
let enabledChartCount = $derived($storedChartLayout.filter((p) => p.variables.length > 0).length);
|
||||||
|
|
||||||
|
// Request only the hourly variables the table rows and meteograms actually
|
||||||
|
// show, so unused variables are never fetched.
|
||||||
|
let hourlyVars = $derived(
|
||||||
|
neededHourlyApiVars(
|
||||||
|
$storedVariablePrefs.table,
|
||||||
|
$storedChartLayout.flatMap((p) => p.variables)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// the URL is the source of truth: location comes from the load function,
|
||||||
|
// which is also correct on hydrated prerendered pages. The persisted store
|
||||||
|
// only mirrors it so the header and bare /weather/* redirects follow along.
|
||||||
|
let location = $derived(data.location);
|
||||||
|
$effect(() => {
|
||||||
|
storedLocation.set(data.location);
|
||||||
});
|
});
|
||||||
|
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
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
|
||||||
|
let pastDays = $state(0);
|
||||||
|
|
||||||
const selectedDay = new SvelteDate();
|
const selectedDay = new SvelteDate();
|
||||||
|
|
||||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||||
|
|
||||||
let meteogramCharts: MeteogramCharts | undefined = $state();
|
// Charts intentionally keep their current range: they show the full week
|
||||||
|
// unless the user narrows it via the range presets or Ctrl+scroll.
|
||||||
const switchDay = (date: Date) => {
|
const switchDay = (date: Date) => {
|
||||||
selectedDay.setTime(date.getTime());
|
selectedDay.setTime(date.getTime());
|
||||||
meteogramCharts?.scrollToDay(date);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// preselect the persisted model (client-only so prerendered HTML stays stable)
|
||||||
|
params.models = [get(storedModel)];
|
||||||
mounted = true;
|
mounted = true;
|
||||||
|
|
||||||
|
// load the map bundle only when its section is about to enter the viewport
|
||||||
|
if (!mapSection) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) {
|
||||||
|
io.disconnect();
|
||||||
|
import('$lib/maps/WeatherMap.svelte').then((m) => (WeatherMap = m.default));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '300px' }
|
||||||
|
);
|
||||||
|
io.observe(mapSection);
|
||||||
|
return () => io.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const loc = location;
|
const loc = location;
|
||||||
const modelList = params.models;
|
const modelList = params.models;
|
||||||
|
const requestVars = hourlyVars;
|
||||||
|
|
||||||
if (!mounted || !loc || !modelList?.length) return;
|
if (!mounted || !loc || !modelList?.length) return;
|
||||||
|
|
||||||
const loadData = async () => {
|
// versioned so a slow stale response can never overwrite a newer one
|
||||||
|
const version = ++requestVersion;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
|
||||||
const result: WeekForecastResult = await fetchWeekForecast({
|
fetchWeekForecast({
|
||||||
latitude: loc.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: loc.longitude!,
|
longitude: loc.longitude!,
|
||||||
model: modelList[0],
|
model: modelList[0],
|
||||||
|
hourlyVariables: requestVars,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
|
||||||
forecast_days: 7,
|
forecast_days: forecastDays,
|
||||||
past_days: 0,
|
past_days: pastDays,
|
||||||
timezone: loc.timezone
|
timezone: loc.timezone
|
||||||
});
|
})
|
||||||
|
.then((result: WeekForecastResult) => {
|
||||||
|
if (version !== requestVersion) return;
|
||||||
|
|
||||||
fetchedHourly = {
|
fetchedHourly = {
|
||||||
hourly: result.hourly,
|
hourly: result.hourly,
|
||||||
@@ -73,7 +153,7 @@
|
|||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
timestamps: result.hourlyTimestamps,
|
timestamps: result.hourlyTimestamps,
|
||||||
hourlyDates: result.hourlyDates,
|
hourlyDates: result.hourlyDates,
|
||||||
markAreas: result.markAreas
|
daylightBands: result.daylightBands
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchedDaily = {
|
fetchedDaily = {
|
||||||
@@ -83,21 +163,101 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
};
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
loadData();
|
if (version !== requestVersion) return;
|
||||||
|
loadError = err instanceof Error ? err.message : String(err);
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Weather | Open-Meteo.com</title>
|
<title>Drizz.li | Weather</title>
|
||||||
<link rel="canonical" href="https://open-meteo.com/weather" />
|
<link rel="canonical" href="https://drizz.li/weather/week" />
|
||||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="week-page">
|
<div class="week-page">
|
||||||
<div class="weather-content" style="min-height: 50vh">
|
<div class="weather-content" style="min-height: 50vh">
|
||||||
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
<!-- Page hero: prominent location + weather model selection -->
|
||||||
|
<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"
|
||||||
|
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
|
||||||
|
>7-day forecast
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||||
|
<ModelSelector
|
||||||
|
selectedModel={params.models?.[0] ?? 'best_match'}
|
||||||
|
onModelChange={(model) => {
|
||||||
|
params.models = [model];
|
||||||
|
storedModel.set(model);
|
||||||
|
// a new model may not support the extended / past range
|
||||||
|
forecastDays = 7;
|
||||||
|
pastDays = 0;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
Failed to load weather data: {loadError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Desktop: the full day cards -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<DailyCards
|
||||||
|
daily={fetchedDaily}
|
||||||
|
{selectedDay}
|
||||||
|
units={params}
|
||||||
|
onSelectDay={switchDay}
|
||||||
|
canExtend={forecastDays < 15}
|
||||||
|
onExtend={() => (forecastDays = 15)}
|
||||||
|
canExtendPast={pastDays < 3}
|
||||||
|
onExtendPast={() => (pastDays = 3)}
|
||||||
|
/>
|
||||||
|
</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}
|
{#if fetchedHourly && fetchedDaily}
|
||||||
<HourlyTable
|
<HourlyTable
|
||||||
@@ -106,24 +266,56 @@
|
|||||||
{selectedDay}
|
{selectedDay}
|
||||||
units={params}
|
units={params}
|
||||||
locationName={location.name ?? ''}
|
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}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if fetchedHourly}
|
{#if fetchedHourly}
|
||||||
<MeteogramCharts
|
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
|
||||||
bind:this={meteogramCharts}
|
{:else}
|
||||||
data={fetchedHourly}
|
<!-- reserve the exact chart area height before the first fetch resolves -->
|
||||||
{selectedDay}
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
units={params}
|
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||||
{loading}
|
</section>
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ModelSelector
|
<!-- Animated weather maps for the shown forecast window. The wrapper is
|
||||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
always present so it can be observed; the browser-only map bundle is
|
||||||
onModelChange={(model) => {
|
fetched only once it scrolls near the viewport, then each map follows
|
||||||
params.models = [model];
|
the selected model / location. -->
|
||||||
}}
|
<div bind:this={mapSection}>
|
||||||
/>
|
<section class="mt-8">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold tracking-tight">Precipitation map</h2>
|
||||||
|
<div
|
||||||
|
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
|
||||||
|
>
|
||||||
|
{#if WeatherMap}
|
||||||
|
<WeatherMap variable="precipitation" title="Precipitation" accumulation />
|
||||||
|
{:else}
|
||||||
|
<div class="h-full w-full animate-pulse bg-muted"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold tracking-tight">Wind map</h2>
|
||||||
|
<div
|
||||||
|
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
|
||||||
|
>
|
||||||
|
{#if WeatherMap}
|
||||||
|
<WeatherMap variable="wind_u_component_10m" title="Wind" arrows />
|
||||||
|
{:else}
|
||||||
|
<div class="h-full w-full animate-pulse bg-muted"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { type ChartPanel, defaultChartLayout, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { CHART_VARIABLES, VARIABLE_BY_KEY } from './variables';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
// Variables not placed in any panel form the "available" pool.
|
||||||
|
let usedKeys = $derived(new Set($storedChartLayout.flatMap((p) => p.variables)));
|
||||||
|
let availableVars = $derived(CHART_VARIABLES.filter((v) => !usedKeys.has(v.key)));
|
||||||
|
|
||||||
|
// ─── Drag state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let dragKey = $state<string | null>(null);
|
||||||
|
let dragLabel = $state('');
|
||||||
|
let dragColor = $state('');
|
||||||
|
let dragPos = $state({ x: 0, y: 0 });
|
||||||
|
let dropZone = $state<string | null>(null); // panel id or 'pool'
|
||||||
|
let dropIndex = $state(0);
|
||||||
|
let pointerStart: { x: number; y: number } | null = null;
|
||||||
|
let started = $state(false);
|
||||||
|
|
||||||
|
function beginDrag(e: PointerEvent, key: string): void {
|
||||||
|
const def = VARIABLE_BY_KEY.get(key);
|
||||||
|
if (!def) return;
|
||||||
|
pointerStart = { x: e.clientX, y: e.clientY };
|
||||||
|
started = false;
|
||||||
|
dragKey = key;
|
||||||
|
dragLabel = def.label;
|
||||||
|
dragColor = def.color;
|
||||||
|
dragPos = { x: e.clientX, y: e.clientY };
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragMove(e: PointerEvent): void {
|
||||||
|
if (dragKey === null || !pointerStart) return;
|
||||||
|
if (!started) {
|
||||||
|
const dx = Math.abs(e.clientX - pointerStart.x);
|
||||||
|
const dy = Math.abs(e.clientY - pointerStart.y);
|
||||||
|
if (dx < 5 && dy < 5) return;
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
dragPos = { x: e.clientX, y: e.clientY };
|
||||||
|
|
||||||
|
const under = document.elementFromPoint(e.clientX, e.clientY);
|
||||||
|
const zoneEl = under?.closest('[data-zone]') as HTMLElement | null;
|
||||||
|
if (!zoneEl) {
|
||||||
|
dropZone = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dropZone = zoneEl.dataset.zone ?? null;
|
||||||
|
|
||||||
|
// Insert before the chip whose centre is to the right of the pointer.
|
||||||
|
const chips = [...zoneEl.querySelectorAll('[data-chip]')] as HTMLElement[];
|
||||||
|
let idx = chips.length;
|
||||||
|
for (let i = 0; i < chips.length; i++) {
|
||||||
|
const r = chips[i].getBoundingClientRect();
|
||||||
|
if (e.clientY < r.top || (e.clientY <= r.bottom && e.clientX < r.left + r.width / 2)) {
|
||||||
|
idx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dropIndex = idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(e: PointerEvent): void {
|
||||||
|
if (dragKey === null) return;
|
||||||
|
const key = dragKey;
|
||||||
|
const zone = started ? dropZone : null;
|
||||||
|
(e.currentTarget as HTMLElement)?.releasePointerCapture?.(e.pointerId);
|
||||||
|
dragKey = null;
|
||||||
|
pointerStart = null;
|
||||||
|
if (zone) moveVar(key, zone, dropIndex);
|
||||||
|
dropZone = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveVar(key: string, toZone: string, toIndex: number): void {
|
||||||
|
const layout: ChartPanel[] = $storedChartLayout.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
variables: p.variables.filter((k) => k !== key)
|
||||||
|
}));
|
||||||
|
if (toZone !== 'pool') {
|
||||||
|
const panel = layout.find((p) => p.id === toZone);
|
||||||
|
if (panel) panel.variables.splice(Math.min(toIndex, panel.variables.length), 0, key);
|
||||||
|
}
|
||||||
|
storedChartLayout.set(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVar(key: string): void {
|
||||||
|
moveVar(key, 'pool', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPanel(): void {
|
||||||
|
const maxN = $storedChartLayout.reduce((m, p) => {
|
||||||
|
const n = parseInt(p.id.replace(/\D/g, ''), 10);
|
||||||
|
return Number.isFinite(n) ? Math.max(m, n) : m;
|
||||||
|
}, 0);
|
||||||
|
storedChartLayout.set([...$storedChartLayout, { id: `panel-${maxN + 1}`, variables: [] }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePanel(id: string): void {
|
||||||
|
storedChartLayout.set($storedChartLayout.filter((p) => p.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout(): void {
|
||||||
|
storedChartLayout.set(structuredClone(defaultChartLayout));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open && dragKey === null) onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-stretch justify-center md:items-center md:p-6">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/40"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
onclick={() => dragKey === null && onClose()}
|
||||||
|
onkeydown={onClose}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative flex w-full max-w-3xl flex-col overflow-hidden bg-card shadow-2xl md:rounded-2xl md:border md:border-border"
|
||||||
|
transition:fly={{ y: 20, duration: 200 }}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-base font-bold">Customize meteograms</h2>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Drag variables between charts to build your own layout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
{#each $storedChartLayout as panel, i (panel.id)}
|
||||||
|
<div
|
||||||
|
data-zone={panel.id}
|
||||||
|
class="rounded-xl border-2 border-dashed p-3 transition-colors {dropZone === panel.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border'}"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
|
||||||
|
>Chart {i + 1}</span
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||||
|
onclick={() => deletePanel(panel.id)}
|
||||||
|
aria-label="Delete chart {i + 1}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 7h12M9 7V5h6v2m-1 0v12H10V7M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-h-9 flex-wrap gap-2">
|
||||||
|
{#each panel.variables as key (key)}
|
||||||
|
{@const def = VARIABLE_BY_KEY.get(key)}
|
||||||
|
{#if def}
|
||||||
|
<div
|
||||||
|
data-chip
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Drag {def.label}"
|
||||||
|
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||||
|
key
|
||||||
|
? 'opacity-30'
|
||||||
|
: ''}"
|
||||||
|
onpointerdown={(e) => beginDrag(e, key)}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style:background-color={def.color}
|
||||||
|
></span>
|
||||||
|
<span class="font-medium">{def.label}</span>
|
||||||
|
<button
|
||||||
|
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
onpointerdown={(e) => e.stopPropagation()}
|
||||||
|
onclick={() => removeVar(key)}
|
||||||
|
aria-label="Remove {def.label}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#if panel.variables.length === 0}
|
||||||
|
<span class="self-center text-xs text-muted-foreground italic"
|
||||||
|
>Drop variables here</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
onclick={addPanel}
|
||||||
|
>
|
||||||
|
+ Add chart
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||||
|
Available variables
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
data-zone="pool"
|
||||||
|
class="flex min-h-12 flex-wrap gap-2 rounded-xl border-2 border-dashed p-3 transition-colors {dropZone ===
|
||||||
|
'pool'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border'}"
|
||||||
|
>
|
||||||
|
{#each availableVars as def (def.key)}
|
||||||
|
<div
|
||||||
|
data-chip
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Drag {def.label}"
|
||||||
|
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||||
|
def.key
|
||||||
|
? 'opacity-30'
|
||||||
|
: ''}"
|
||||||
|
onpointerdown={(e) => beginDrag(e, def.key)}
|
||||||
|
onpointermove={onDragMove}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={endDrag}
|
||||||
|
>
|
||||||
|
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={def.color}
|
||||||
|
></span>
|
||||||
|
<span class="font-medium">{def.label}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if availableVars.length === 0}
|
||||||
|
<span class="self-center text-xs text-muted-foreground italic"
|
||||||
|
>All variables are in use</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-border px-5 py-3">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||||
|
onclick={resetLayout}
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating drag preview -->
|
||||||
|
{#if dragKey !== null && started}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed z-60 flex items-center gap-1.5 rounded-lg border border-primary bg-card px-2.5 py-1.5 text-sm font-medium shadow-xl"
|
||||||
|
style:left="{dragPos.x + 8}px"
|
||||||
|
style:top="{dragPos.y + 8}px"
|
||||||
|
>
|
||||||
|
<span class="h-2.5 w-2.5 shrink-0 rounded-full" style:background-color={dragColor}></span>
|
||||||
|
{dragLabel}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,9 +12,57 @@
|
|||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
units: WeatherUnits;
|
units: WeatherUnits;
|
||||||
onSelectDay: (date: Date, index: number) => void;
|
onSelectDay: (date: Date, index: number) => void;
|
||||||
|
/** Offer a button after the last day to load the model's longer range */
|
||||||
|
canExtend?: boolean;
|
||||||
|
onExtend?: () => void;
|
||||||
|
/** Offer a button before the first day to load recent past days */
|
||||||
|
canExtendPast?: boolean;
|
||||||
|
onExtendPast?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
let {
|
||||||
|
daily,
|
||||||
|
selectedDay,
|
||||||
|
units,
|
||||||
|
onSelectDay,
|
||||||
|
canExtend = false,
|
||||||
|
onExtend,
|
||||||
|
canExtendPast = false,
|
||||||
|
onExtendPast
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// The "past days" button sits before the first card but starts scrolled out
|
||||||
|
// of view — the user reveals it by scrolling left. Re-hide only when the
|
||||||
|
// dataset (location) changes, not on every re-render.
|
||||||
|
let scrollEl = $state<HTMLDivElement>();
|
||||||
|
let cardsWrapEl = $state<HTMLDivElement>();
|
||||||
|
let hiddenForRef: FetchedDaily | null = null;
|
||||||
|
|
||||||
|
// Show the scrollbar only briefly while actively scrolling (a constant thin
|
||||||
|
// gutter is reserved so revealing the thumb never shifts layout).
|
||||||
|
let scrolling = $state(false);
|
||||||
|
let scrollHideTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
function onScroll() {
|
||||||
|
scrolling = true;
|
||||||
|
clearTimeout(scrollHideTimer);
|
||||||
|
scrollHideTimer = setTimeout(() => (scrolling = false), 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const d = daily;
|
||||||
|
if (!d || !canExtendPast || !scrollEl || !cardsWrapEl || hiddenForRef === d) return;
|
||||||
|
hiddenForRef = d;
|
||||||
|
const el = scrollEl;
|
||||||
|
const wrap = cardsWrapEl;
|
||||||
|
// 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. The scroll's pl-3 keeps the lifted/scaled card from being clipped,
|
||||||
|
// so subtract it here to land the card on the content edge.
|
||||||
|
el.scrollLeft += wrap.getBoundingClientRect().left - el.getBoundingClientRect().left - 12;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function getDaylightSeconds(index: number): number {
|
function getDaylightSeconds(index: number): number {
|
||||||
if (!daily) return 0;
|
if (!daily) return 0;
|
||||||
@@ -34,14 +82,90 @@
|
|||||||
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
|
||||||
if (ratio >= 0.7) return '#f59e0b';
|
if (ratio >= 0.7) return '#f59e0b';
|
||||||
if (ratio >= 0.45) return '#fbbf24';
|
if (ratio >= 0.45) return '#fbbf24';
|
||||||
if (ratio >= 0.2) return '#fcd34d';
|
if (ratio >= 0.1) return '#fcd34d';
|
||||||
return '#d1d5db';
|
return '#d1d5db';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── "Is this metric worth highlighting?" thresholds ────────────────────────
|
||||||
|
// Below these, the sun / precip / wind bits are greyed out so a card at a
|
||||||
|
// glance only emphasises what's actually notable that day.
|
||||||
|
|
||||||
|
function sunIsSignificant(sunshineSeconds: number | null, daylightSeconds: number): boolean {
|
||||||
|
if (daylightSeconds <= 0) return false;
|
||||||
|
return (sunshineSeconds ?? 0) / daylightSeconds >= 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function precipIsSignificant(sum: number | null, unit: string): boolean {
|
||||||
|
const min = unit === 'mm' ? 0.1 : 0.005; // anything above a trace
|
||||||
|
return (sum ?? 0) >= min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windIsSignificant(speed: number | null, gust: number | null, unit: string): boolean {
|
||||||
|
// separate bars: sustained wind ~ a light breeze (~12 km/h), gusts a bit
|
||||||
|
// higher (~22 km/h). If EITHER is met, the whole wind readout is coloured.
|
||||||
|
const windMin = unit === 'ms' ? 3 : unit === 'mph' ? 7 : unit === 'kn' ? 6 : 12;
|
||||||
|
const gustMin = unit === 'ms' ? 6 : unit === 'mph' ? 14 : unit === 'kn' ? 12 : 22;
|
||||||
|
const s = speed != null && !isNaN(speed) ? speed : -Infinity;
|
||||||
|
const g = gust != null && !isNaN(gust) ? gust : -Infinity;
|
||||||
|
return s >= windMin || g >= gustMin;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
<!-- Shared filter: erodes the filled weather glyphs slightly so their
|
||||||
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
lines read a touch thinner at large sizes (radius = how much to shave) -->
|
||||||
|
<svg aria-hidden="true" width="0" height="0" class="absolute">
|
||||||
|
<defs>
|
||||||
|
<filter id="thin-day-icon" x="-10%" y="-10%" width="120%" height="120%">
|
||||||
|
<feMorphology operator="erode" radius="0.45" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="-mx-3 mb-1 flex min-h-47.5 items-stretch gap-2 px-3 md:mb-6 md:min-h-65"
|
||||||
|
>
|
||||||
|
<!-- A horizontally-scrolling rail (past button + day cards) plus a "load more"
|
||||||
|
card pinned to the right so it's always visible on every screen. The
|
||||||
|
matching top/bottom padding gives a lifted/scaled card room so it's never
|
||||||
|
clipped, while the first card still lines up with the page content edge. -->
|
||||||
|
<div
|
||||||
|
bind:this={scrollEl}
|
||||||
|
class="day-scroll -ml-3 flex min-w-0 flex-1 gap-2 overflow-x-auto pt-3 pb-3 pl-3 md:pt-5 md:pb-11"
|
||||||
|
class:scrolling
|
||||||
|
onscroll={onScroll}
|
||||||
|
>
|
||||||
{#if daily}
|
{#if daily}
|
||||||
|
{#if canExtendPast && onExtendPast}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||||
|
onclick={onExtendPast}
|
||||||
|
aria-label="Load recent past days"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||||
|
Past<br />3 days
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<!-- the cards fill at least the viewport so the row overflows past the
|
||||||
|
"past days" button, letting it scroll out of view even on wide
|
||||||
|
screens -->
|
||||||
|
<div bind:this={cardsWrapEl} class="cards-fill flex gap-2">
|
||||||
{#each daily.dailyDates as time, index (index)}
|
{#each daily.dailyDates as time, index (index)}
|
||||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
@@ -57,101 +181,142 @@
|
|||||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||||
{@const unit = String(units.temperature_unit)}
|
{@const unit = String(units.temperature_unit)}
|
||||||
{@const maxStyle = getTempStyle(tempMax, unit)}
|
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||||
{@const minStyle = getTempStyle(tempMin, unit)}
|
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
|
||||||
{#if tempMax != null && !isNaN(tempMax)}
|
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
||||||
|
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
|
||||||
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||||
<button
|
<button
|
||||||
class="group flex min-w-[108px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
|
||||||
{selected
|
{selected
|
||||||
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
|
||||||
: 'border-transparent bg-card hover:bg-accent'}"
|
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
|
||||||
|
aria-pressed={selected}
|
||||||
onclick={() => onSelectDay(time, index)}
|
onclick={() => onSelectDay(time, index)}
|
||||||
>
|
>
|
||||||
<!-- Day label -->
|
<!-- Day label -->
|
||||||
<span class="text-sm font-bold tracking-wide">
|
<span
|
||||||
|
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
|
||||||
|
>
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span
|
||||||
|
class="-mt-1 text-[11px] {selected
|
||||||
|
? 'font-medium text-primary/80'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>
|
||||||
{getRelativeDayLabel(time, daily.timezone)}
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Weather icon -->
|
<!-- Weather icon: large day with a night badge in the corner -->
|
||||||
<div
|
<div class="relative my-1 px-3 -ml-2.5">
|
||||||
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
<svg
|
||||||
style="background: {sunColor}22"
|
class="day-icon fill-foreground"
|
||||||
|
width="100px"
|
||||||
|
height="100px"
|
||||||
|
style="filter: url(#thin-day-icon)"
|
||||||
>
|
>
|
||||||
<svg class="fill-foreground" width="48px" height="48px">
|
|
||||||
<use
|
<use
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||||
wCode as keyof typeof weatherCodes
|
></use>
|
||||||
] ?? 'clear'}.svg#Layer_1"
|
</svg>
|
||||||
|
<svg
|
||||||
|
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
|
||||||
|
width="42px"
|
||||||
|
height="42px"
|
||||||
|
>
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
wCode,
|
||||||
|
false
|
||||||
|
)}.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Temperature max/min -->
|
<!-- Temperature max/min -->
|
||||||
<div class="flex w-full flex-col">
|
<div class="flex items-baseline gap-1.5">
|
||||||
<div
|
<span
|
||||||
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
|
||||||
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
||||||
>
|
>
|
||||||
{tempMax.toFixed(0)}°
|
{tempMax.toFixed(0)}°
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
|
||||||
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
|
||||||
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
|
||||||
>
|
|
||||||
{tempMin.toFixed(0)}°
|
{tempMin.toFixed(0)}°
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Details section -->
|
<!-- Details -->
|
||||||
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
|
||||||
<!-- Sunshine bar -->
|
<!-- Sunshine -->
|
||||||
<div class="flex w-full items-center gap-1 px-1">
|
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
|
||||||
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
|
||||||
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all"
|
class="h-full rounded-full transition-all"
|
||||||
style="width: {sunPct}%; background-color: {sunColor}"
|
style="width: {sunPct}%; background-color: {sunColor}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] font-medium text-muted-foreground">
|
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||||
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Precipitation -->
|
<!-- Precipitation + wind -->
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
<div
|
||||||
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-0.5 {lowPrecip
|
||||||
|
? 'text-muted-foreground/50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="shrink-0 {lowPrecip
|
||||||
|
? 'fill-muted-foreground/40'
|
||||||
|
: 'fill-foreground/70'}"
|
||||||
|
width="23px"
|
||||||
|
height="23px"
|
||||||
|
>
|
||||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
|
||||||
{Number(precipSum ?? 0).toFixed(
|
{Number(precipSum ?? 0).toFixed(
|
||||||
precipSum >= 10 ? 0 : 1
|
precipSum >= 10 ? 0 : 1
|
||||||
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span
|
||||||
|
class="inline-flex items-center gap-0.5 {lowWind
|
||||||
<!-- Wind with direction -->
|
? 'text-muted-foreground/50'
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
: ''}"
|
||||||
|
>
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
<div
|
<span
|
||||||
class="inline-flex shrink-0"
|
class="inline-flex shrink-0 -mr-2"
|
||||||
style="transform: {getWindArrowRotation(windDir)}"
|
style="transform: {getWindArrowRotation(windDir)}"
|
||||||
>
|
>
|
||||||
<svg class="fill-foreground" width="20px" height="20px">
|
<svg
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
|
||||||
|
width="40px"
|
||||||
|
height="40px"
|
||||||
|
>
|
||||||
|
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
|
||||||
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
<svg
|
||||||
|
class="shrink-0 -mr-2 {lowWind
|
||||||
|
? 'fill-muted-foreground/40'
|
||||||
|
: 'fill-foreground/70'}"
|
||||||
|
width="40px"
|
||||||
|
height="40px"
|
||||||
|
>
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
<span>
|
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
|
||||||
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
|
||||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
>-{gustMax?.toFixed(0) ?? '-'}</span
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
@@ -160,19 +325,112 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Mobile: the "load more" card is the last item in the SAME flex row
|
||||||
|
as the cards (reached by scrolling); keeping it in this container
|
||||||
|
avoids the zoom/flex mis-position seen when it was a sibling. On
|
||||||
|
md+ it's hidden here and the pinned sibling below is shown. -->
|
||||||
|
{#if canExtend && onExtend}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
|
||||||
|
onclick={onExtend}
|
||||||
|
aria-label="Load the longer-range forecast"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||||
|
Load<br />15 days
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: "load more" card pinned to the right of the scroll rail (a
|
||||||
|
sibling, not inside the scroll) so it's always visible and full-height. -->
|
||||||
|
{#if daily && canExtend && onExtend}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||||
|
onclick={onExtend}
|
||||||
|
aria-label="Load the longer-range forecast"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||||
|
/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@media (max-width: 768px) {
|
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
|
||||||
button {
|
thumb invisible until the user is actively scrolling. */
|
||||||
min-width: 92px !important;
|
.day-scroll {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
/* Soft fade at the right edge (only) so cards dissolve into the page
|
||||||
|
margin instead of being hard-cut as they scroll off. */
|
||||||
|
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
|
||||||
|
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
|
||||||
|
}
|
||||||
|
.day-scroll.scrolling {
|
||||||
|
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
|
||||||
|
}
|
||||||
|
.day-scroll::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.day-scroll::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.day-scroll.scrolling::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklab, var(--color-border) 85%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
button :global(svg[width='48px']) {
|
/* The cards group fills the viewport so the row overflows past the side
|
||||||
width: 40px;
|
buttons. On md+ we also reserve room so the "load more" button stays in
|
||||||
height: 40px;
|
view; on mobile it keeps its full width after the cards (reached by
|
||||||
|
scrolling) and is never clipped. */
|
||||||
|
.cards-fill {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cards-fill {
|
||||||
|
min-width: calc(100% - var(--fill-reserve, 0rem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.day-card {
|
||||||
|
zoom: 0.72;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
|
||||||
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { getTempStyle } from '../../utils/colors';
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
import { type FetchedDaily, type WeatherUnits } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
daily: FetchedDaily | null;
|
||||||
|
selectedDay: Date;
|
||||||
|
units: WeatherUnits;
|
||||||
|
onSelectDay: (date: Date, index: number) => void;
|
||||||
|
canExtend?: boolean;
|
||||||
|
onExtend?: () => void;
|
||||||
|
canExtendPast?: boolean;
|
||||||
|
onExtendPast?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
daily,
|
||||||
|
selectedDay,
|
||||||
|
units,
|
||||||
|
onSelectDay,
|
||||||
|
canExtend = false,
|
||||||
|
onExtend,
|
||||||
|
canExtendPast = false,
|
||||||
|
onExtendPast
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── Scroll-driven collapse (full → compact) ────────────────────────────────
|
||||||
|
// 0 = full cards (at the top of the page), 1 = compact square strip (stuck).
|
||||||
|
// The progress sentinel sits ABOVE the sticky strip, so the strip shrinking
|
||||||
|
// (which reflows the table BELOW it) never feeds back into the measurement.
|
||||||
|
let progress = $state(0);
|
||||||
|
let sentinelEl = $state<HTMLDivElement>();
|
||||||
|
let stripScrollEl = $state<HTMLDivElement>();
|
||||||
|
let daysWrapEl = $state<HTMLDivElement>();
|
||||||
|
// Resolved in onMount (client only) so this never touches `window` during the
|
||||||
|
// prerender of city pages.
|
||||||
|
let scrollParent: HTMLElement | Window | null = $state(null);
|
||||||
|
|
||||||
|
const clamp = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
|
||||||
|
|
||||||
|
function findScrollParent(el: HTMLElement | null): HTMLElement | Window {
|
||||||
|
let node = el?.parentElement ?? null;
|
||||||
|
while (node) {
|
||||||
|
const oy = getComputedStyle(node).overflowY;
|
||||||
|
if (oy === 'auto' || oy === 'scroll') return node;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance (px) over which the collapse plays out once the strip sticks.
|
||||||
|
const RANGE = 120;
|
||||||
|
let ticking = false;
|
||||||
|
|
||||||
|
function measure() {
|
||||||
|
ticking = false;
|
||||||
|
if (!sentinelEl || !scrollParent) return;
|
||||||
|
const topRef = scrollParent instanceof Window ? 0 : scrollParent.getBoundingClientRect().top;
|
||||||
|
const top = sentinelEl.getBoundingClientRect().top - topRef;
|
||||||
|
progress = clamp(-top / RANGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
scrollParent = findScrollParent(sentinelEl ?? null);
|
||||||
|
const target: EventTarget = scrollParent;
|
||||||
|
target.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll, { passive: true });
|
||||||
|
measure();
|
||||||
|
return () => {
|
||||||
|
target.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure once data (and therefore the strip height) changes.
|
||||||
|
$effect(() => {
|
||||||
|
void daily;
|
||||||
|
tick().then(measure);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scrolled so the "Past" button sits just off the left edge (revealed by
|
||||||
|
// scrolling left), with the first day flush to the content edge — same as the
|
||||||
|
// desktop cards. Runs once per dataset.
|
||||||
|
let scrolledForRef: FetchedDaily | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const d = daily;
|
||||||
|
if (!d || !canExtendPast || !stripScrollEl || !daysWrapEl || scrolledForRef === d) return;
|
||||||
|
scrolledForRef = d;
|
||||||
|
const scroll = stripScrollEl;
|
||||||
|
const wrap = daysWrapEl;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// 12px = the strip's px-3 left padding, so the first day lands on the edge
|
||||||
|
scroll.scrollLeft +=
|
||||||
|
wrap.getBoundingClientRect().left - scroll.getBoundingClientRect().left - 12;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// All progress-driven sizing lives in CSS (via the single `--p` custom property
|
||||||
|
// set on the strip), so a scroll frame writes ONE value instead of re-patching
|
||||||
|
// height/padding/opacity inline styles on every cell.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- progress sentinel: 0-height marker just above the sticky strip -->
|
||||||
|
<div bind:this={sentinelEl} aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="daystrip sticky -top-3 z-30 -mx-3 md:hidden" style="--p:{progress}">
|
||||||
|
<div class="flex gap-1.5 overflow-x-auto px-3 py-2" bind:this={stripScrollEl}>
|
||||||
|
{#if daily}
|
||||||
|
{#if canExtendPast && onExtendPast}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||||
|
onclick={onExtendPast}
|
||||||
|
aria-label="Load recent past days"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 6l-6 6 6 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">Past</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-1.5" bind:this={daysWrapEl}>
|
||||||
|
{#each daily.dailyDates as time, index (index)}
|
||||||
|
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||||
|
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||||
|
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||||
|
{@const wCode = daily.daily.weather_code[index]}
|
||||||
|
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||||
|
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||||
|
{@const maxStyle = getTempStyle(tempMax, String(units.temperature_unit))}
|
||||||
|
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-cell flex shrink-0 cursor-pointer flex-col items-center justify-start gap-0.5 rounded-xl border px-1 pb-1.5 {selected
|
||||||
|
? 'border-primary bg-primary/10 ring-1 ring-primary/50'
|
||||||
|
: 'border-border/60 bg-card'}"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onclick={() => onSelectDay(time, index)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-semibold tracking-wide {selected ? 'text-primary' : ''}"
|
||||||
|
>
|
||||||
|
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="rel-label overflow-hidden text-[9px] leading-none text-muted-foreground">
|
||||||
|
{getRelativeDayLabel(time, daily.timezone)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<svg class="day-icon fill-foreground">
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
<!-- night companion icon, present in the full view, fades as it collapses -->
|
||||||
|
<svg
|
||||||
|
class="night-badge absolute -right-1 -bottom-0.5 rounded-full bg-card fill-foreground/60 p-px ring-1 ring-border/60"
|
||||||
|
>
|
||||||
|
<use
|
||||||
|
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||||
|
wCode,
|
||||||
|
false
|
||||||
|
)}.svg#Layer_1"
|
||||||
|
></use>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-1 leading-none">
|
||||||
|
<span
|
||||||
|
class="rounded-md px-1.5 py-0.5 text-[12px] font-extrabold tabular-nums"
|
||||||
|
style="background-color:{maxStyle.bg};color:{maxStyle.fg}"
|
||||||
|
>
|
||||||
|
{tempMax.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] font-semibold tabular-nums text-muted-foreground">
|
||||||
|
{tempMin.toFixed(0)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="detail-row flex w-full flex-col items-center gap-0.5 overflow-hidden text-[10px] tabular-nums text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<svg class="fill-sky-500" width="13" height="13">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{Number(precipSum ?? 0).toFixed(precipSum >= 10 ? 0 : 1)}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<svg class="fill-muted-foreground" width="13" height="13">
|
||||||
|
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{windMax?.toFixed(0) ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if canExtend && onExtend}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="strip-side flex shrink-0 flex-col items-center justify-center rounded-xl border border-dashed border-border/70 text-muted-foreground"
|
||||||
|
onclick={onExtend}
|
||||||
|
aria-label="Load the longer-range forecast"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5v14m-7-7h14" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[9px] leading-tight font-semibold">15d</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Everything below is derived from a single custom property `--p` (0 = full
|
||||||
|
cards, 1 = compact strip) set on `.daystrip` once per scroll frame. The
|
||||||
|
browser resolves the calc()s natively, so a scroll frame is one property
|
||||||
|
write instead of dozens of inline-style patches across every cell. */
|
||||||
|
.daystrip {
|
||||||
|
/* tunables */
|
||||||
|
--cell-w: 64px;
|
||||||
|
--cell-h-full: 134px;
|
||||||
|
--cell-h-min: 64px;
|
||||||
|
--icon-full: 40px;
|
||||||
|
--icon-min: 20px;
|
||||||
|
--pt-full: 6px;
|
||||||
|
--pt-min: 1px;
|
||||||
|
|
||||||
|
/* progress-derived (--k is the "fullness": 1 when full, 0 when compact) */
|
||||||
|
--k: calc(1 - var(--p));
|
||||||
|
--cell-h: calc(var(--cell-h-min) + (var(--cell-h-full) - var(--cell-h-min)) * var(--k));
|
||||||
|
--icon: calc(var(--icon-min) + (var(--icon-full) - var(--icon-min)) * var(--k));
|
||||||
|
--pt: calc(var(--pt-min) + (var(--pt-full) - var(--pt-min)) * var(--k));
|
||||||
|
--rel: clamp(0, calc(1 - var(--p) * 2), 1);
|
||||||
|
--detail: clamp(0, calc(1 - var(--p) * 1.6), 1);
|
||||||
|
/* bar background/divider fade in once it starts collapsing */
|
||||||
|
--chrome: clamp(0, calc(var(--p) / 0.35), 1);
|
||||||
|
|
||||||
|
/* Opaque-background fade only — no backdrop-filter blur or animated
|
||||||
|
box-shadow (both are very expensive to repaint every scroll frame on
|
||||||
|
mobile). Promote to its own compositor layer so repaints stay isolated. */
|
||||||
|
background: color-mix(
|
||||||
|
in oklab,
|
||||||
|
var(--color-background) calc(var(--chrome) * 100%),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid
|
||||||
|
color-mix(in oklab, var(--color-border) calc(var(--chrome) * 100%), transparent);
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-cell,
|
||||||
|
.strip-side {
|
||||||
|
width: var(--cell-w);
|
||||||
|
height: var(--cell-h);
|
||||||
|
/* size tracks scroll exactly (no transition); only the tap highlight eases */
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background-color 0.15s;
|
||||||
|
}
|
||||||
|
.strip-cell {
|
||||||
|
padding-top: var(--pt);
|
||||||
|
}
|
||||||
|
.day-icon {
|
||||||
|
width: var(--icon);
|
||||||
|
height: var(--icon);
|
||||||
|
}
|
||||||
|
.night-badge {
|
||||||
|
width: calc(var(--icon) * 0.44);
|
||||||
|
height: calc(var(--icon) * 0.44);
|
||||||
|
opacity: var(--rel);
|
||||||
|
}
|
||||||
|
.rel-label {
|
||||||
|
opacity: var(--rel);
|
||||||
|
max-height: calc(14px * var(--rel));
|
||||||
|
}
|
||||||
|
.detail-row {
|
||||||
|
opacity: var(--detail);
|
||||||
|
max-height: calc(42px * var(--detail));
|
||||||
|
}
|
||||||
|
|
||||||
|
.daystrip :global(.overflow-x-auto) {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultVariablePrefs,
|
||||||
|
storedHourlyInterval,
|
||||||
|
storedVariablePrefs
|
||||||
|
} from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { groupHover, setGroupHover } from '$lib/charts';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
import {
|
import {
|
||||||
@@ -19,11 +29,43 @@
|
|||||||
selectedDay: Date;
|
selectedDay: Date;
|
||||||
units: WeatherUnits;
|
units: WeatherUnits;
|
||||||
locationName: string;
|
locationName: string;
|
||||||
|
/** Opens the variable-customization sidebar (button lives in the header). */
|
||||||
|
onCustomize?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, daily, selectedDay, units, locationName }: Props = $props();
|
let { data, daily, selectedDay, units, locationName, onCustomize }: Props = $props();
|
||||||
|
|
||||||
let hourlyInterval = $state<1 | 3>(3);
|
// Must match MeteogramCharts' CHART_GROUP so hovering the time row drives the
|
||||||
|
// meteogram crosshairs.
|
||||||
|
const METEOGRAM_GROUP = 'week-meteogram';
|
||||||
|
|
||||||
|
// Scrubbing the time row moves the shared meteogram cursor to the hovered
|
||||||
|
// time (interpolated across the row so it feels continuous), and clears it on
|
||||||
|
// leave.
|
||||||
|
function hoverTimeRow(e: MouseEvent) {
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || cellData.length === 0) return;
|
||||||
|
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
|
||||||
|
const first = cellData[0].date.getTime();
|
||||||
|
const last = cellData[cellData.length - 1].date.getTime() + stepMs;
|
||||||
|
setGroupHover(METEOGRAM_GROUP, (first + frac * (last - first)) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimeRowHover() {
|
||||||
|
setGroupHover(METEOGRAM_GROUP, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// persisted 1h / 3h preference
|
||||||
|
let hourlyInterval = $derived($storedHourlyInterval);
|
||||||
|
|
||||||
|
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||||
|
// from older stored prefs default to visible)
|
||||||
|
let showRow = $derived(
|
||||||
|
(key: string): boolean =>
|
||||||
|
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
|
||||||
|
);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tempUnit = $derived(getTempUnit(units));
|
const tempUnit = $derived(getTempUnit(units));
|
||||||
@@ -118,6 +160,67 @@
|
|||||||
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
|
||||||
|
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
|
||||||
|
function hexRgb(hex: string): [number, number, number] {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const n =
|
||||||
|
h.length === 3
|
||||||
|
? h
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('')
|
||||||
|
: h;
|
||||||
|
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
|
||||||
|
}
|
||||||
|
function mixRgb(
|
||||||
|
a: [number, number, number],
|
||||||
|
b: [number, number, number],
|
||||||
|
f: number
|
||||||
|
): [number, number, number] {
|
||||||
|
return [
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * f),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * f),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * f)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pressure: 940 hPa blue → 1010 white → 1060 red.
|
||||||
|
const PRESSURE_LOW = hexRgb('#4444ff');
|
||||||
|
const PRESSURE_MID = hexRgb('#ffffff');
|
||||||
|
const PRESSURE_HIGH = hexRgb('#ff4444');
|
||||||
|
function getPressureBg(hpa: number | null): string {
|
||||||
|
if (hpa == null || isNaN(hpa)) return 'transparent';
|
||||||
|
const v = Math.max(940, Math.min(1060, hpa));
|
||||||
|
const c =
|
||||||
|
v <= 1010
|
||||||
|
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
|
||||||
|
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
|
||||||
|
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
|
||||||
|
// opacity tracks the value so low/night hours stay faint instead of tinting
|
||||||
|
// the whole row.
|
||||||
|
const UV_STOPS = [
|
||||||
|
'#009392',
|
||||||
|
'#39b185',
|
||||||
|
'#9ccb86',
|
||||||
|
'#e9e29c',
|
||||||
|
'#eeb479',
|
||||||
|
'#e88471',
|
||||||
|
'#cf597e'
|
||||||
|
].map(hexRgb);
|
||||||
|
function getUvBg(uv: number | null): string {
|
||||||
|
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
|
||||||
|
const v = Math.min(12, uv);
|
||||||
|
const p = (v / 12) * (UV_STOPS.length - 1);
|
||||||
|
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
|
||||||
|
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
|
||||||
|
const alpha = 0.18 + (v / 12) * 0.5;
|
||||||
|
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
|
||||||
@@ -159,6 +262,101 @@
|
|||||||
let sunsetPercent = $derived(
|
let sunsetPercent = $derived(
|
||||||
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── "Now" indicator ─────────────────────────────────────────────────────
|
||||||
|
// Full-height vertical line across the table, positioned at the current
|
||||||
|
// time within the selected day (only when the selected day is today).
|
||||||
|
let isTodaySelected = $derived(
|
||||||
|
cellData.length > 0 && isSameDayInZone(today, selectedDay, data.timezone)
|
||||||
|
);
|
||||||
|
let nowPercent = $derived(isTodaySelected ? timeToFraction(today) * 100 : null);
|
||||||
|
|
||||||
|
// Width of the row-header column, measured so the now-line can be
|
||||||
|
// positioned relative to the data columns only.
|
||||||
|
let headerColWidth = $state(0);
|
||||||
|
let tableWidth = $state(0);
|
||||||
|
let nowLeftPx = $derived(
|
||||||
|
nowPercent != null && tableWidth > 0
|
||||||
|
? headerColWidth + (nowPercent / 100) * (tableWidth - headerColWidth)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Auto-scroll the (overflowing) table on day / interval change ────────────
|
||||||
|
// • Day change → today lands on the current-hour cell, other days on 06:00.
|
||||||
|
// • Interval change (3h ↔ 1h) → keep whatever cell is currently on the left.
|
||||||
|
let tableScrollEl = $state<HTMLDivElement>();
|
||||||
|
let autoScrolledDay = -1;
|
||||||
|
let lastInterval = 0;
|
||||||
|
let viewLeftHour = 6; // zoned hour at the left edge (plain, non-reactive)
|
||||||
|
|
||||||
|
function colWidthPx(): number {
|
||||||
|
return (tableWidth - headerColWidth) / cellData.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToIdx(idx: number) {
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollWidth <= el.clientWidth + 4) {
|
||||||
|
el.scrollLeft = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.scrollLeft = idx <= 0 ? 0 : Math.max(0, idx * colWidthPx());
|
||||||
|
}
|
||||||
|
|
||||||
|
// track the left-most visible cell's hour so an interval switch can restore it
|
||||||
|
function onTableScroll() {
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el || cellData.length === 0 || tableWidth === 0) return;
|
||||||
|
const idx = Math.min(
|
||||||
|
cellData.length - 1,
|
||||||
|
Math.max(0, Math.round(el.scrollLeft / colWidthPx()))
|
||||||
|
);
|
||||||
|
viewLeftHour = getZonedHour(cellData[idx].date, data.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const day = selectedDay.getTime();
|
||||||
|
const interval = is3h ? 3 : 1;
|
||||||
|
const el = tableScrollEl;
|
||||||
|
if (!el || cellData.length === 0 || tableWidth === 0 || headerColWidth === 0) return;
|
||||||
|
const dayChanged = autoScrolledDay !== day;
|
||||||
|
const intervalChanged = lastInterval !== interval;
|
||||||
|
if (!dayChanged && !intervalChanged) return;
|
||||||
|
autoScrolledDay = day;
|
||||||
|
lastInterval = interval;
|
||||||
|
|
||||||
|
let targetIdx: number;
|
||||||
|
if (dayChanged) {
|
||||||
|
// today → the cell whose block contains "now"; otherwise → 06:00
|
||||||
|
const nowMs = today.getTime();
|
||||||
|
const stepMs = interval * 3600 * 1000;
|
||||||
|
const nowIdx = cellData.findIndex(
|
||||||
|
(c) => nowMs >= c.date.getTime() && nowMs < c.date.getTime() + stepMs
|
||||||
|
);
|
||||||
|
targetIdx =
|
||||||
|
nowIdx >= 0 ? nowIdx : cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= 6);
|
||||||
|
viewLeftHour = targetIdx >= 0 ? getZonedHour(cellData[targetIdx].date, data.timezone) : 6;
|
||||||
|
} else {
|
||||||
|
// interval change only: keep the same cell on the left
|
||||||
|
targetIdx = cellData.findIndex((c) => getZonedHour(c.date, data.timezone) >= viewLeftHour);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => scrollToIdx(targetIdx));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Chart-hover mirror ─────────────────────────────────────────────────────
|
||||||
|
// When the shared meteogram is hovered, highlight the matching table column
|
||||||
|
// (only if the hovered time falls on the day the table is currently showing).
|
||||||
|
let chartHoverTime = $derived(groupHover(METEOGRAM_GROUP)); // epoch seconds, or null
|
||||||
|
let hoveredCol = $derived.by((): number => {
|
||||||
|
if (chartHoverTime == null || cellData.length === 0) return -1;
|
||||||
|
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
|
||||||
|
const tMs = chartHoverTime * 1000;
|
||||||
|
for (let i = 0; i < cellData.length; i++) {
|
||||||
|
const start = cellData[i].date.getTime();
|
||||||
|
if (tMs >= start && tMs < start + stepMs) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||||
@@ -169,130 +367,210 @@
|
|||||||
|
|
||||||
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
|
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
|
||||||
<th class="hdr" scope="row">
|
<th class="hdr" scope="row">
|
||||||
<div class="flex flex-col items-center leading-tight">
|
<div class="flex flex-col items-center gap-0.5 leading-tight">
|
||||||
{#if iconName}
|
{#if iconName}
|
||||||
{@render weatherIcon(iconName)}
|
{@render weatherIcon(iconName, 18)}
|
||||||
{/if}
|
{/if}
|
||||||
{#if label}
|
{#if label}
|
||||||
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
|
<span class="text-[11px] font-semibold">{label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if unit}
|
{#if unit}
|
||||||
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
|
<span class="text-[10px] font-medium text-muted-foreground">{unit}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 class="text-lg font-bold">
|
|
||||||
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
|
||||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
|
||||||
<span class="select-none text-muted-foreground">3h</span>
|
|
||||||
<button
|
|
||||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
|
||||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
|
||||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
|
||||||
title="Toggle between 1-hour and 3-hour intervals"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-[3px] size-[18px] rounded-full bg-white shadow-sm transition-[left] duration-200
|
|
||||||
{hourlyInterval === 1 ? 'left-[22px]' : 'left-[3px]'}"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
<span class="select-none text-muted-foreground">1h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if cellData.length > 0}
|
{#if cellData.length > 0}
|
||||||
{@const hourly = data.hourly}
|
{@const hourly = data.hourly}
|
||||||
{@const iconPx = is3h ? 40 : 26}
|
{@const iconPx = is3h ? 38 : 33}
|
||||||
<div class="overflow-hidden rounded-lg border border-border">
|
<!-- Full-bleed to the viewport edges on mobile (main has p-3 = 0.75rem);
|
||||||
|
a contained rounded card on md+ -->
|
||||||
|
<section
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
class="-mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
|
||||||
|
>
|
||||||
|
<!-- Card toolbar -->
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/40 px-4 py-2.5"
|
||||||
|
>
|
||||||
|
<h3 class="text-base font-bold">
|
||||||
|
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
||||||
|
<span class="font-semibold text-muted-foreground">– hourly</span>
|
||||||
|
<span
|
||||||
|
class="ms-2 rounded-full bg-muted px-2 py-0.5 align-middle text-[10px] font-semibold text-muted-foreground"
|
||||||
|
>
|
||||||
|
{timezoneLabel}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if onCustomize}
|
||||||
|
<button
|
||||||
|
class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
onclick={onCustomize}
|
||||||
|
aria-label="Customize variables"
|
||||||
|
>
|
||||||
|
<!-- sliders icon -->
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.75"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Variables</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Hourly interval"
|
||||||
|
>
|
||||||
|
{#each [3, 1] as interval (interval)}
|
||||||
|
<button
|
||||||
|
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
|
||||||
|
interval
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
aria-pressed={hourlyInterval === interval}
|
||||||
|
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
|
||||||
|
>
|
||||||
|
{interval}h
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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} onscroll={onTableScroll}>
|
||||||
|
<div
|
||||||
|
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
|
||||||
|
bind:clientWidth={tableWidth}
|
||||||
|
>
|
||||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
||||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col class="w-14 md:w-16" />
|
<col class="w-16 md:w-20" />
|
||||||
{#each cellData as _ (_.idx)}
|
{#each cellData as _ (_.idx)}
|
||||||
<col />
|
<col />
|
||||||
{/each}
|
{/each}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<tbody>
|
<tbody>
|
||||||
<!-- Time + Daylight bar (merged) -->
|
<!-- Time + Daylight bar (merged) -->
|
||||||
<tr class="!border-t-0">
|
<tr class="row">
|
||||||
<th class="hdr" scope="row">
|
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
|
||||||
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
|
<span class="text-[10px] font-semibold text-muted-foreground">Time</span>
|
||||||
</th>
|
</th>
|
||||||
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
|
<td
|
||||||
|
colspan={cellData.length}
|
||||||
|
class="relative h-12 cursor-default overflow-visible p-0"
|
||||||
|
onmousemove={hoverTimeRow}
|
||||||
|
onmouseleave={clearTimeRowHover}
|
||||||
|
>
|
||||||
<!-- Daylight background -->
|
<!-- Daylight background -->
|
||||||
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/40"
|
||||||
style="width:{sunrisePercent}%"
|
style="width:{sunrisePercent}%"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
|
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/10"
|
||||||
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/40"
|
||||||
style="width:{100 - sunsetPercent}%"
|
style="width:{100 - sunsetPercent}%"
|
||||||
></div>
|
></div>
|
||||||
<!-- Sunrise marker + label -->
|
<!-- Sunrise marker + label -->
|
||||||
<div class="absolute inset-y-0 w-px bg-amber-500/70" style="left:{sunrisePercent}%">
|
<div
|
||||||
|
class="absolute inset-y-0 w-px bg-amber-500/70"
|
||||||
|
style="left:{sunrisePercent}%"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
|
class="absolute bottom-0.5 left-1 text-[10px] leading-none font-semibold whitespace-nowrap text-amber-700 dark:text-amber-300"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="fill-foreground inline-block"
|
class="inline-block fill-foreground"
|
||||||
width="12px"
|
width="12px"
|
||||||
height="12px"
|
height="12px"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
<use
|
||||||
|
class="stroke-2"
|
||||||
|
xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
|
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Sunset marker + label -->
|
<!-- Sunset marker + label -->
|
||||||
<div class="absolute inset-y-0 w-px bg-indigo-400/70" style="left:{sunsetPercent}%">
|
<div
|
||||||
|
class="absolute inset-y-0 w-px bg-indigo-400/70"
|
||||||
|
style="left:{sunsetPercent}%"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="absolute bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
|
class="absolute right-1 bottom-0.5 inline-flex items-center gap-1 text-[10px] leading-none font-semibold whitespace-nowrap text-indigo-600 dark:text-indigo-300"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="fill-foreground inline-block"
|
class="inline-block fill-foreground"
|
||||||
width="12px"
|
width="12px"
|
||||||
height="12px"
|
height="12px"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
<use
|
||||||
|
class="stroke-2"
|
||||||
|
xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
||||||
></use>
|
></use>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- "Now" label, aligned with the sunrise/sunset labels along the bottom -->
|
||||||
|
{#if isTodaySelected && nowPercent != null}
|
||||||
|
<span
|
||||||
|
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
||||||
|
style="left:{nowPercent}%"
|
||||||
|
>
|
||||||
|
Now
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
<!-- Hour labels -->
|
<!-- Hour labels -->
|
||||||
{#each cellData as cell, i (cell.idx)}
|
{#each cellData as cell, i (cell.idx)}
|
||||||
{@const leftPct = (i / cellData.length) * 100}
|
{@const leftPct = (i / cellData.length) * 100}
|
||||||
{@const widthPct = 100 / cellData.length}
|
{@const widthPct = 100 / cellData.length}
|
||||||
<span
|
<span
|
||||||
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm
|
class="absolute top-0 flex items-start pl-1 font-bold {is3h
|
||||||
{cell.isNow ? 'text-destructive' : ''}"
|
? 'pt-2 text-sm'
|
||||||
|
: 'pt-2.5'}
|
||||||
|
{cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
|
||||||
style="left:{leftPct}%;width:{widthPct}%"
|
style="left:{leftPct}%;width:{widthPct}%"
|
||||||
>
|
>
|
||||||
{#if is3h}
|
{#if is3h}
|
||||||
{formatZoned(cell.date, data.timezone, 'HH')}
|
<span class="inline-flex items-baseline gap-0.5">
|
||||||
|
<span>{formatZoned(cell.date, data.timezone, 'HH')}</span>
|
||||||
|
<sup
|
||||||
|
class="align-baseline translate-y-px text-[10px] leading-none font-semibold {cell.isNow
|
||||||
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: 'text-muted-foreground'}">00</sup
|
||||||
|
>
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="inline-flex items-baseline gap-1">
|
<span class="inline-flex items-baseline gap-1">
|
||||||
<span class="text-[11px] font-semibold"
|
<span class="text-[11px] font-semibold"
|
||||||
>{formatZoned(cell.date, data.timezone, 'HH')}</span
|
>{formatZoned(cell.date, data.timezone, 'HH')}</span
|
||||||
>
|
>
|
||||||
<sup
|
<sup
|
||||||
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
class="inline-block -translate-x-0.5 translate-y-[0.16rem] align-baseline text-[9px] leading-none font-semibold {cell.isNow
|
||||||
>00</sup
|
? 'text-red-500 dark:text-red-400'
|
||||||
|
: 'text-muted-foreground'}">00</sup
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -302,110 +580,197 @@
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Weather Icons -->
|
<!-- Weather Icons -->
|
||||||
<tr>
|
{#if showRow('icons')}
|
||||||
|
<tr class="row">
|
||||||
{@render rowHeader('wi-day-cloudy')}
|
{@render rowHeader('wi-day-cloudy')}
|
||||||
{#each cellData as cell, i (cell.idx)}
|
{#each cellData as cell, i (cell.idx)}
|
||||||
{@const wCode = hourly.weather_code[cell.idx]}
|
{@const wCode = hourly.weather_code[cell.idx]}
|
||||||
<td
|
<td
|
||||||
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
|
class="cell h-12 leading-0"
|
||||||
class:now={cell.isNow}
|
|
||||||
class:icon-day={cell.isDaytime}
|
class:icon-day={cell.isDaytime}
|
||||||
class:icon-night={!cell.isDaytime}
|
class:icon-night={!cell.isDaytime}
|
||||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
||||||
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
class:icon-dusk={cell.isDaytime &&
|
||||||
|
cellData[i + 1] &&
|
||||||
|
!cellData[i + 1].isDaytime}
|
||||||
>
|
>
|
||||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Temperature -->
|
<!-- Temperature -->
|
||||||
<tr>
|
{#if showRow('temperature')}
|
||||||
{@render rowHeader('wi-thermometer', tempUnit)}
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
{@const temp = hourly.temperature_2m[cell.idx]}
|
||||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
||||||
<td
|
<td
|
||||||
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
|
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||||
class:now={cell.isNow}
|
|
||||||
style="background-color:{style.bg};color:{style.fg}"
|
style="background-color:{style.bg};color:{style.fg}"
|
||||||
>
|
>
|
||||||
{formatTemp(temp)}
|
{formatTemp(temp)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Feels Like -->
|
<!-- Feels Like (short row) -->
|
||||||
<tr>
|
{#if showRow('feels')}
|
||||||
|
<tr class="row">
|
||||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||||
<td
|
<td
|
||||||
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
|
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||||
class:now={cell.isNow}
|
|
||||||
>
|
>
|
||||||
{formatTemp(temp)}
|
{formatTemp(temp)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dew Point (short row; off by default) -->
|
||||||
|
{#if showRow('dew_point')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const temp = hourly.dew_point_2m?.[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
|
||||||
|
>
|
||||||
|
{formatTemp(temp)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Wind -->
|
<!-- Wind -->
|
||||||
<tr>
|
{#if showRow('wind')}
|
||||||
{@render rowHeader('wi-strong-wind', windUnit)}
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
{@const wind = hourly.windspeed_10m[cell.idx]}
|
||||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
||||||
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
|
<td class="relative cell h-12 align-middle leading-none">
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
{#if windDir != null && !isNaN(windDir)}
|
||||||
<span
|
<span
|
||||||
class="inline-block leading-[0]"
|
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
|
||||||
style="transform:{getWindArrowRotation(windDir)}"
|
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
|
||||||
>
|
>
|
||||||
{@render weatherIcon('wi-direction-down', 24)}
|
{@render weatherIcon('wi-direction-down', 40)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
|
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||||
{formatValue(wind)}
|
{formatValue(wind)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Humidity -->
|
<!-- Wind Gusts (off by default) -->
|
||||||
<tr>
|
{#if showRow('gusts')}
|
||||||
{@render rowHeader('wi-humidity', '%')}
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell h-12 font-semibold text-foreground/80 {is3h
|
||||||
|
? 'text-sm'
|
||||||
|
: 'text-xs'}"
|
||||||
|
>
|
||||||
|
{formatValue(v)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Humidity (short row) -->
|
||||||
|
{#if showRow('humidity')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||||
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
|
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
|
||||||
{formatValue(hum)}
|
{formatValue(hum)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Cloud Cover -->
|
<!-- Cloud Cover -->
|
||||||
<tr>
|
{#if showRow('clouds')}
|
||||||
{@render rowHeader('wi-cloud', '%')}
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-cloud', '%', 'Clouds')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
{@const cloud = hourly.cloud_cover[cell.idx]}
|
||||||
<td
|
<td
|
||||||
class="cell"
|
class="cell h-12"
|
||||||
class:now={cell.isNow}
|
|
||||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||||
>
|
>
|
||||||
{formatValue(cloud)}
|
{formatValue(cloud)}
|
||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Pressure (off by default) -->
|
||||||
|
{#if showRow('pressure')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.pressure_msl?.[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
|
||||||
|
style="background:{getPressureBg(v ?? null)}"
|
||||||
|
>
|
||||||
|
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- UV Index (off by default) -->
|
||||||
|
{#if showRow('uv')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.uv_index?.[cell.idx]}
|
||||||
|
<td
|
||||||
|
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
|
||||||
|
style="background:{getUvBg(v ?? null)}"
|
||||||
|
>
|
||||||
|
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Visibility (off by default) -->
|
||||||
|
{#if showRow('visibility')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-fog', 'km', 'Visibility')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.visibility?.[cell.idx]}
|
||||||
|
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||||
|
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Precipitation -->
|
<!-- Precipitation -->
|
||||||
<tr>
|
{#if showRow('precipitation')}
|
||||||
{@render rowHeader('wi-raindrop', precipUnit)}
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
|
||||||
{#each cellData as cell (cell.idx)}
|
{#each cellData as cell (cell.idx)}
|
||||||
{@const precip = hourly.precipitation[cell.idx]}
|
{@const precip = hourly.precipitation[cell.idx]}
|
||||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
{@const prob = hourly.precipitation_probability[cell.idx]}
|
||||||
<td
|
<td
|
||||||
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
|
class="cell precip-cell h-12"
|
||||||
class:now={cell.isNow}
|
|
||||||
style="background:{getPrecipProbBg(prob ?? 0)}"
|
style="background:{getPrecipProbBg(prob ?? 0)}"
|
||||||
title={formatPrecipTooltip(precip, prob)}
|
title={formatPrecipTooltip(precip, prob)}
|
||||||
>
|
>
|
||||||
@@ -418,62 +783,93 @@
|
|||||||
</td>
|
</td>
|
||||||
{/each}
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Snowfall (off by default) -->
|
||||||
|
{#if showRow('snowfall')}
|
||||||
|
<tr class="row">
|
||||||
|
{@render rowHeader('wi-snow', 'cm', 'Snow')}
|
||||||
|
{#each cellData as cell (cell.idx)}
|
||||||
|
{@const v = hourly.snowfall?.[cell.idx]}
|
||||||
|
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
|
||||||
|
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
|
||||||
|
{#if hoveredCol >= 0 && tableWidth > 0}
|
||||||
|
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
|
||||||
|
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- "Now" column highlight + exact-time line -->
|
||||||
|
{#if nowLeftPx != null}
|
||||||
|
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
|
||||||
|
{@const nowIdx = cellData.findIndex((c) => c.isNow)}
|
||||||
|
{#if nowIdx >= 0}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 z-10 border-x border-red-500/30 bg-red-500/5"
|
||||||
|
style="left:{headerColWidth + nowIdx * colWidth}px;width:{colWidth}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<!-- full-height current-time line (its "Now" label lives in the time row) -->
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-y-0 z-10 w-0.5 -translate-x-1/2 bg-red-500/75"
|
||||||
|
style="left:{nowLeftPx}px"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
tr {
|
/* ── Uniform grid ───────────────────────────────────────────── */
|
||||||
border-top: 1px solid hsl(var(--border));
|
.row + .row {
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base cell ──────────────────────────────────────────── */
|
|
||||||
.cell {
|
.cell {
|
||||||
padding: 6px 2px;
|
padding: 2px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
font-variant-numeric: tabular-nums;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell:last-child {
|
.cell + .cell {
|
||||||
border-right: none;
|
border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell.now {
|
/* ── Row header ─────────────────────────────────────────────── */
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Row header ─────────────────────────────────────────── */
|
|
||||||
.hdr {
|
.hdr {
|
||||||
padding: 4px;
|
/* sticky + opaque background: stays readable while the table is
|
||||||
|
scrolled sideways (z above the "now" overlay, which is z-10) */
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 4px 2px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: hsl(var(--background));
|
background: color-mix(in oklab, var(--color-muted) 45%, var(--color-card));
|
||||||
border-right: 2px solid hsl(var(--border));
|
border-right: 1px solid var(--color-border);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Precipitation ──────────────────────────────────────── */
|
/* ── Precipitation ──────────────────────────────────────────── */
|
||||||
.precip-cell {
|
.precip-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-cell:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-cell.now {
|
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.precip-bar {
|
.precip-bar {
|
||||||
@@ -500,10 +896,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.dark) .precip-label {
|
:global(.dark) .precip-label {
|
||||||
color: rgba(120, 180, 255, 0.95);
|
color: rgba(140, 190, 255, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hdr {
|
.hdr {
|
||||||
padding: 3px 2px;
|
padding: 3px 2px;
|
||||||
@@ -511,7 +907,6 @@
|
|||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 4px 1px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
|
|
||||||
import { getColor } from '../../utils/colors';
|
import { CanvasChart, groupRange } from '$lib/charts';
|
||||||
import {
|
|
||||||
type FetchedHourly,
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
type WeatherUnits,
|
import ChartCustomizer from './ChartCustomizer.svelte';
|
||||||
getPrecipUnit,
|
import { type FetchedHourly, type WeatherUnits } from './types';
|
||||||
getTempUnit,
|
import { VARIABLE_BY_KEY, buildPanelDef } from './variables';
|
||||||
getWindDirectionLabel,
|
|
||||||
getWindUnit
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: FetchedHourly;
|
data: FetchedHourly;
|
||||||
@@ -30,589 +25,321 @@
|
|||||||
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
|
||||||
|
|
||||||
const CHART_GROUP = 'week-meteogram';
|
const CHART_GROUP = 'week-meteogram';
|
||||||
const MS_PER_DAY = 24 * 3600 * 1000;
|
const SECONDS_PER_DAY = 24 * 3600;
|
||||||
|
const CHART_HEIGHT = 300;
|
||||||
|
|
||||||
let showCharts = $state(false);
|
let customizerOpen = $state(false);
|
||||||
let chartComponents: EChart[] = $state([]);
|
let downloadingPng = $state(false);
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
|
|
||||||
export function scrollToDay(day: Date): void {
|
async function downloadPng(): Promise<void> {
|
||||||
if (chartInstances.length === 0 || !data) return;
|
if (liveCharts.length === 0 || downloadingPng) return;
|
||||||
|
downloadingPng = true;
|
||||||
|
try {
|
||||||
|
// pair each chart with its panel title so the export is labelled
|
||||||
|
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
|
||||||
|
await downloadChartsPng(items, 'week-forecast');
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => (downloadingPng = false), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charts persist across data refetches; entries are null while unmounted.
|
||||||
|
let chartComponents: (CanvasChart | null)[] = $state([]);
|
||||||
|
let liveCharts = $derived(chartComponents.filter((chart): chart is CanvasChart => chart != null));
|
||||||
|
|
||||||
|
// Only render panels that hold at least one known variable.
|
||||||
|
let panels = $derived(
|
||||||
|
$storedChartLayout.filter((p) => p.variables.some((k) => VARIABLE_BY_KEY.has(k)))
|
||||||
|
);
|
||||||
|
|
||||||
|
// When an extended range runs past the model's horizon the service pads with
|
||||||
|
// zeros; trim the axis to the last hour that actually has data so the charts
|
||||||
|
// cut off instead of flat-lining to zero.
|
||||||
|
let validLength = $derived.by((): number => {
|
||||||
|
const temp = data.hourly.temperature_2m ?? [];
|
||||||
|
const n = data.timestamps.length;
|
||||||
|
if (temp.length === 0) return n;
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (temp[i] != null && !isNaN(temp[i]) && temp[i] !== 0) last = i + 1;
|
||||||
|
}
|
||||||
|
return last || n;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
|
||||||
|
let timestampsSec = $derived(data.timestamps.slice(0, validLength).map((t) => t / 1000));
|
||||||
|
|
||||||
|
function dayStartSec(day: Date): number | null {
|
||||||
|
if (!data) return null;
|
||||||
const tz = data.timezone;
|
const tz = data.timezone;
|
||||||
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
|
||||||
const firstHourIdx = data.hourlyDates.findIndex(
|
const firstHourIdx = data.hourlyDates.findIndex(
|
||||||
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
|
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
|
||||||
);
|
);
|
||||||
|
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 1000;
|
||||||
if (firstHourIdx === -1) return;
|
|
||||||
|
|
||||||
const dayStart = data.timestamps[firstHourIdx];
|
|
||||||
const dayEnd = dayStart + MS_PER_DAY;
|
|
||||||
const timestamps = data.timestamps;
|
|
||||||
const rangeStart = timestamps[0];
|
|
||||||
const rangeEnd = timestamps[timestamps.length - 1];
|
|
||||||
const totalRange = rangeEnd - rangeStart;
|
|
||||||
|
|
||||||
if (totalRange <= 0) return;
|
|
||||||
|
|
||||||
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
|
||||||
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
|
||||||
|
|
||||||
for (const chart of chartInstances) {
|
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: startPct, end: endPct });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRangeDays(from: Date, days: number): void {
|
||||||
|
const start = dayStartSec(from);
|
||||||
|
if (start == null || liveCharts.length === 0) return;
|
||||||
|
// Charts share the group, so setting the range on one syncs all of them
|
||||||
|
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetZoom(): void {
|
function resetZoom(): void {
|
||||||
for (const chart of chartInstances) {
|
liveCharts[0]?.resetRange();
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onResetZoom?.();
|
onResetZoom?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
const now = new Date();
|
||||||
chart.group = CHART_GROUP;
|
// "Selected day" is redundant while today is the selected day, so hide it then
|
||||||
chartInstances = [...chartInstances, chart];
|
let rangePresets = $derived.by(() => {
|
||||||
if (chartInstances.length === 3) {
|
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
|
||||||
echarts.connect(CHART_GROUP);
|
return [
|
||||||
requestAnimationFrame(() => scrollToDay(selectedDay));
|
{ 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) },
|
||||||
$effect(() => {
|
{ label: 'All', apply: () => resetZoom() }
|
||||||
// Reset chart instances when data changes
|
];
|
||||||
if (data) {
|
|
||||||
chartInstances = [];
|
|
||||||
chartComponents = [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
// Soft band drawn on every chart marking the currently selected day
|
||||||
if (!data) return;
|
let selectedDayHighlight = $derived.by(() => {
|
||||||
|
const start = dayStartSec(selectedDay);
|
||||||
const { hourly, timestamps, markAreas } = data;
|
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
|
||||||
const colors = getThemeColors();
|
|
||||||
const tempUnit = getTempUnit(units);
|
|
||||||
const precipUnit = getPrecipUnit(units);
|
|
||||||
const windUnit = getWindUnit(units);
|
|
||||||
|
|
||||||
const temps = hourly.temperature_2m;
|
|
||||||
const precip = hourly.precipitation;
|
|
||||||
const precipProb = hourly.precipitation_probability;
|
|
||||||
const cloudCov = hourly.cloud_cover;
|
|
||||||
const windSpeed = hourly.windspeed_10m;
|
|
||||||
const humidity = hourly.relative_humidity_2m;
|
|
||||||
|
|
||||||
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
|
||||||
const minTemp = Math.min(...validTemps);
|
|
||||||
const maxTemp = Math.max(...validTemps);
|
|
||||||
|
|
||||||
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
|
|
||||||
const annotations = (): Array<Record<string, unknown>> => {
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
const dl = buildDaylightSeries({ markAreas });
|
|
||||||
if (dl) series.push(dl);
|
|
||||||
return series;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
|
||||||
type: 'time',
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLine: { lineStyle: { color: colors.axisLine } },
|
|
||||||
axisLabel: {
|
|
||||||
color: colors.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
show: showLabel,
|
|
||||||
formatter: (value: number) => formatZoned(new Date(value), data.timezone, 'HH:mm')
|
|
||||||
},
|
|
||||||
axisTick: { lineStyle: { color: colors.axisLine } }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const insideZoom = (): Record<string, unknown> => ({
|
// ─── Pictograms (weather icons across the top) ──────────────────────────────
|
||||||
type: 'inside',
|
|
||||||
xAxisIndex: 0,
|
function isDaytime(tSec: number): boolean {
|
||||||
filterMode: 'none',
|
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
|
||||||
zoomOnMouseWheel: true,
|
}
|
||||||
moveOnMouseMove: true,
|
|
||||||
moveOnMouseWheel: false
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sliderZoom = (): Record<string, unknown> => ({
|
// Wind-direction arrows for panels showing wind (deg = direction from North).
|
||||||
type: 'slider',
|
let windArrowMarks = $derived.by((): { t: number; deg: number }[] => {
|
||||||
xAxisIndex: 0,
|
const dirs = data.hourly.winddirection_10m ?? [];
|
||||||
filterMode: 'none',
|
const out: { t: number; deg: number }[] = [];
|
||||||
height: 20,
|
for (let i = 0; i < timestampsSec.length; i++) {
|
||||||
bottom: 4,
|
const d = dirs[i];
|
||||||
borderColor: colors.axisLine,
|
if (d == null || !isFinite(d)) continue;
|
||||||
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
out.push({ t: timestampsSec[i], deg: d });
|
||||||
handleStyle: { color: colors.text },
|
|
||||||
textStyle: { color: colors.text, fontSize: 10 },
|
|
||||||
dataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: colors.splitLine }
|
|
||||||
},
|
|
||||||
selectedDataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tooltipBase = (
|
// True while the shared group is zoomed in (not the full range).
|
||||||
formatter: (
|
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
|
||||||
params: Array<{
|
|
||||||
axisValue: number;
|
|
||||||
seriesName: string;
|
|
||||||
marker: string;
|
|
||||||
value: number | number[] | null;
|
|
||||||
}>
|
|
||||||
) => string
|
|
||||||
): Record<string, unknown> => ({
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
color: colors.text,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
formatter
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (ts: number): string => {
|
// ─── Panel definitions ──────────────────────────────────────────────────────
|
||||||
const date = new Date(ts);
|
|
||||||
const dateStr = formatZoned(date, data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
return `<b>${dateStr}</b><br/>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
interface RenderPanel extends ChartPanel {
|
||||||
|
def: ReturnType<typeof buildPanelDef>;
|
||||||
|
title: string;
|
||||||
|
titleShort: string;
|
||||||
|
}
|
||||||
|
|
||||||
const tempOption: Record<string, unknown> = {
|
let renderPanels = $derived.by((): RenderPanel[] =>
|
||||||
title: {
|
panels.map((p) => {
|
||||||
text: 'Temperature & Cloud Cover',
|
const def = buildPanelDef(p.variables, data.hourly, units);
|
||||||
left: 'left',
|
const title = def.series.map((s) => s.name).join(' · ');
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
const titleShort = def.series.map((s) => s.shortName ?? s.name).join(' · ');
|
||||||
},
|
return { ...p, def, title, titleShort };
|
||||||
tooltip: tooltipBase((params) => {
|
})
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Temperature')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
|
||||||
else if (name === 'Cloud Cover')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Temperature', 'Cloud Cover']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: tempUnit,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{ type: 'value', min: 0, max: 250, inverse: true, show: false }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Temperature',
|
|
||||||
type: 'line',
|
|
||||||
data: tempData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 3, color: '#ef6c00' },
|
|
||||||
itemStyle: { color: '#ef6c00' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 0.5,
|
|
||||||
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
},
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cloud Cover',
|
|
||||||
type: 'line',
|
|
||||||
data: cloudData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
lineStyle: { width: 0 },
|
|
||||||
itemStyle: { color: colors.text },
|
|
||||||
areaStyle: { color: 'rgba(150, 150, 150, 0.25)', origin: 'start' },
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
const precipOption: Record<string, unknown> = {
|
|
||||||
title: {
|
|
||||||
text: 'Precipitation & Probability',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
||||||
},
|
|
||||||
tooltip: tooltipBase((params) => {
|
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Precipitation')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
|
||||||
else if (name === 'Precip. Probability')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Precipitation', 'Precip. Probability']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: precipUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Precipitation',
|
|
||||||
type: 'bar',
|
|
||||||
data: precipData,
|
|
||||||
barMaxWidth: 8,
|
|
||||||
itemStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
|
||||||
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Precip. Probability',
|
|
||||||
type: 'line',
|
|
||||||
data: precipProbData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
|
||||||
itemStyle: { color: '#5c6bc0' },
|
|
||||||
yAxisIndex: 1,
|
|
||||||
z: 4
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
const windOption: Record<string, unknown> = {
|
|
||||||
title: {
|
|
||||||
text: 'Wind Speed & Humidity',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
||||||
},
|
|
||||||
tooltip: tooltipBase((params) => {
|
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Wind Speed') {
|
|
||||||
const idx = timestamps.indexOf(
|
|
||||||
(params[0] as Record<string, unknown>).axisValue as number
|
|
||||||
);
|
);
|
||||||
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
|
||||||
if (windDir != null && !isNaN(windDir)) html += ` (${getWindDirectionLabel(windDir)})`;
|
|
||||||
html += '<br/>';
|
|
||||||
} else if (name === 'Humidity') {
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 28,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Wind Speed', 'Humidity']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
|
||||||
dataZoom: [insideZoom(), sliderZoom()],
|
|
||||||
xAxis: timeXAxis(true),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: windUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Wind Speed',
|
|
||||||
type: 'line',
|
|
||||||
data: windData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, color: '#26a69a' },
|
|
||||||
itemStyle: { color: '#26a69a' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
|
||||||
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Humidity',
|
|
||||||
type: 'line',
|
|
||||||
data: humidityData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
|
||||||
itemStyle: { color: '#8d6e63' },
|
|
||||||
yAxisIndex: 1,
|
|
||||||
z: 4
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
graphic: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
right: 10,
|
|
||||||
bottom: 30,
|
|
||||||
style: {
|
|
||||||
text: 'Open-Meteo.com',
|
|
||||||
fontSize: 10,
|
|
||||||
fill: colors.text,
|
|
||||||
opacity: 0.4
|
|
||||||
},
|
|
||||||
cursor: 'pointer'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
chartOptions = [tempOption, precipOption, windOption];
|
// Uniform sizing across every panel: reserve the right-axis gutter and the
|
||||||
});
|
// tallest icon-row count so all meteograms share one plot rectangle.
|
||||||
|
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))
|
||||||
|
)
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="charts-toggle-section">
|
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||||
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<span>Detailed Meteogram Charts</span>
|
<h3 class="text-lg font-bold">
|
||||||
|
Meteograms
|
||||||
|
<span class="font-semibold text-muted-foreground">
|
||||||
|
– {formatZoned(selectedDay, data.timezone, 'EEEE')}{getRelativeDayLabel(
|
||||||
|
selectedDay,
|
||||||
|
data.timezone
|
||||||
|
) !== formatZoned(selectedDay, data.timezone, 'EEEE')
|
||||||
|
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
||||||
|
: ''}
|
||||||
|
</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>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||||
|
role="group"
|
||||||
|
aria-label="Chart time range"
|
||||||
|
>
|
||||||
|
{#each rangePresets as preset (preset.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||||
|
onclick={preset.apply}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
onclick={() => (customizerOpen = true)}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
class="toggle-chevron {showCharts ? 'open' : ''}"
|
class="h-3.5 w-3.5"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16M8 4v4m8 2v4M6 16v4" />
|
||||||
|
</svg>
|
||||||
|
Customize
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={liveCharts.length === 0 || downloadingPng}
|
||||||
|
onclick={downloadPng}
|
||||||
|
title="Download meteogram as PNG image"
|
||||||
|
>
|
||||||
|
{#if downloadingPng}
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
</svg>
|
</svg>
|
||||||
|
{/if}
|
||||||
|
PNG
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<!-- Kept last so toggling it wraps onto a new line instead of shifting
|
||||||
|
the other controls (no layout shift on mobile). -->
|
||||||
{#if showCharts}
|
{#if zoomActive}
|
||||||
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
<button
|
||||||
<div class="charts-header">
|
type="button"
|
||||||
<h3 class="charts-title">
|
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"
|
||||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
onclick={resetZoom}
|
||||||
<small>
|
>
|
||||||
{getRelativeDayLabel(selectedDay, data.timezone) !==
|
<svg
|
||||||
formatZoned(selectedDay, data.timezone, 'EEEE')
|
class="h-3.5 w-3.5"
|
||||||
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
fill="none"
|
||||||
: ''}
|
stroke="currentColor"
|
||||||
</small>
|
viewBox="0 0 24 24"
|
||||||
</h3>
|
stroke-width="2"
|
||||||
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
>
|
||||||
Show All
|
<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>
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
{#if renderPanels.length === 0}
|
||||||
{#each chartOptions as option, i (i)}
|
<div
|
||||||
<EChart
|
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||||
{option}
|
>
|
||||||
height={i === 2 ? '320px' : '300px'}
|
No meteograms configured, <button
|
||||||
onChartReady={handleChartReady}
|
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
onclick={() => (customizerOpen = true)}>add some variables</button
|
||||||
|
>.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- one full-bleed card until lg / contained card on lg+, graphs stacked
|
||||||
|
tightly so they read as one fluent meteogram -->
|
||||||
|
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||||
|
{#each renderPanels as panel, i (panel.id)}
|
||||||
|
<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]}
|
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}
|
||||||
/>
|
/>
|
||||||
{/each}
|
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
|
||||||
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<style>
|
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
|
||||||
.charts-toggle-section {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-toggle-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--muted) / 0.5);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: var(--radius, 0.375rem);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-toggle-btn:hover {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-chevron {
|
|
||||||
transition: transform 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-chevron.open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailed-charts {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-reset-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--muted) / 0.5);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: var(--radius, 0.375rem);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color 150ms ease,
|
|
||||||
background-color 150ms ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-reset-btn:hover {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--muted));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,45 +1,108 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
|
||||||
import { models } from '../../options';
|
import { type WeatherModelGroup, modelGroups } from '../../options';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
|
/** Model list to offer; defaults to the deterministic forecast models */
|
||||||
|
groups?: WeatherModelGroup[];
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { selectedModel, onModelChange }: Props = $props();
|
let {
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
groups = modelGroups,
|
||||||
|
label = 'Weather model'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let modelLabel = $derived(
|
let model = $derived(
|
||||||
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
|
groups.flatMap((group) => group.models).find((mo) => mo.value === selectedModel)
|
||||||
);
|
);
|
||||||
|
let modelLabel = $derived(model?.label ?? selectedModel);
|
||||||
|
let modelMeta = $derived.by(() => {
|
||||||
|
if (model?.resolution && model.resolution !== 'varies') {
|
||||||
|
return model.update ? `${model.resolution} · updated ${model.update}` : model.resolution;
|
||||||
|
}
|
||||||
|
return selectedModel === 'best_match'
|
||||||
|
? 'Automatically picks the best model for this location'
|
||||||
|
: '';
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-6 flex gap-6 md:mt-12">
|
<Select.Root
|
||||||
<div class="relative w-1/2">
|
|
||||||
<Select.Root
|
|
||||||
name="model_selection"
|
name="model_selection"
|
||||||
type="single"
|
type="single"
|
||||||
value={selectedModel}
|
value={selectedModel}
|
||||||
onValueChange={(val) => {
|
onValueChange={(val) => {
|
||||||
if (val) onModelChange(val);
|
if (val) onModelChange(val);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select.Trigger
|
<Select.Trigger
|
||||||
aria-label="Forecast model selection"
|
aria-label="{label} selection"
|
||||||
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
|
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"
|
||||||
>
|
>
|
||||||
{modelLabel}
|
<div
|
||||||
|
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-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"
|
||||||
|
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
|
||||||
|
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm"
|
||||||
|
>{modelLabel}</span
|
||||||
|
>
|
||||||
|
{#if modelMeta}
|
||||||
|
<!-- 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}
|
||||||
|
</div>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content preventScroll={false} class="border-border">
|
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
|
||||||
{#each models as mo (mo.value)}
|
{#each groups as group (group.value)}
|
||||||
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
<Select.Group>
|
||||||
|
<Select.GroupHeading
|
||||||
|
class="text-[10.5px] font-bold tracking-wider text-primary/80 uppercase"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</Select.GroupHeading>
|
||||||
|
{#each group.models as mo (mo.value)}
|
||||||
|
<Select.Item class="cursor-pointer" value={mo.value} label={mo.label}>
|
||||||
|
<!-- div, not span: the item base styles force flex row on spans -->
|
||||||
|
<div class="flex w-full flex-col items-start gap-0 leading-tight">
|
||||||
|
<span class="font-medium">{mo.label}</span>
|
||||||
|
{#if mo.resolution && mo.resolution !== 'varies'}
|
||||||
|
<span class="text-[11px] text-muted-foreground">
|
||||||
|
{mo.resolution}{mo.update ? ` · updated ${mo.update}` : ''}
|
||||||
|
</span>
|
||||||
|
{:else if mo.value === 'best_match'}
|
||||||
|
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
|
</Select.Root>
|
||||||
Weather model
|
|
||||||
</Label>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { formatZoned } from '$lib/utils/date';
|
|
||||||
|
|
||||||
import type { FetchedDaily } from './types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
daily: FetchedDaily | null;
|
|
||||||
dayIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { daily, dayIndex }: Props = $props();
|
|
||||||
|
|
||||||
let sunrise = $derived.by(() => {
|
|
||||||
if (!daily) return null;
|
|
||||||
const ts = daily.daily.sunrise[dayIndex];
|
|
||||||
return ts ? new Date(ts * 1000) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
let sunset = $derived.by(() => {
|
|
||||||
if (!daily) return null;
|
|
||||||
const ts = daily.daily.sunset[dayIndex];
|
|
||||||
return ts ? new Date(ts * 1000) : null;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if daily && sunrise && sunset}
|
|
||||||
<div class="sun-info">
|
|
||||||
<div class="sun-item">
|
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<span>{formatZoned(sunrise, daily.timezone, 'HH:mm')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sun-item">
|
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<span>{formatZoned(sunset, daily.timezone, 'HH:mm')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sun-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sun-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { defaultVariablePrefs, storedVariablePrefs } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onClose }: Props = $props();
|
||||||
|
|
||||||
|
const tableVariables = [
|
||||||
|
{ key: 'icons', label: 'Weather icons' },
|
||||||
|
{ key: 'temperature', label: 'Temperature' },
|
||||||
|
{ key: 'feels', label: 'Feels like' },
|
||||||
|
{ key: 'dew_point', label: 'Dew point' },
|
||||||
|
{ key: 'wind', label: 'Wind' },
|
||||||
|
{ key: 'gusts', label: 'Wind gusts' },
|
||||||
|
{ key: 'humidity', label: 'Humidity' },
|
||||||
|
{ key: 'clouds', label: 'Cloud cover' },
|
||||||
|
{ key: 'pressure', label: 'Pressure' },
|
||||||
|
{ key: 'uv', label: 'UV index' },
|
||||||
|
{ key: 'visibility', label: 'Visibility' },
|
||||||
|
{ key: 'precipitation', label: 'Precipitation' },
|
||||||
|
{ key: 'snowfall', label: 'Snowfall' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function toggle(section: 'table' | 'charts', key: string) {
|
||||||
|
storedVariablePrefs.update((prefs) => {
|
||||||
|
const current = { ...defaultVariablePrefs[section], ...prefs[section] };
|
||||||
|
return { ...prefs, [section]: { ...current, [key]: !(current[key] ?? true) } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDefaults() {
|
||||||
|
storedVariablePrefs.set(structuredClone(defaultVariablePrefs));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open) onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/30"
|
||||||
|
transition:fade={{ duration: 150 }}
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={onClose}
|
||||||
|
role="presentation"
|
||||||
|
></div>
|
||||||
|
<aside
|
||||||
|
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
|
||||||
|
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
|
||||||
|
aria-label="Variable selection"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
|
||||||
|
>
|
||||||
|
<h2 class="text-base font-bold">Variables</h2>
|
||||||
|
<button
|
||||||
|
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label="Close variable selection"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4.5 w-4.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
|
||||||
|
<section>
|
||||||
|
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||||
|
Hourly table
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each tableVariables as variable (variable.key)}
|
||||||
|
<div class="flex items-center gap-2.5 rounded-md px-1 py-1 hover:bg-muted/60">
|
||||||
|
<Checkbox
|
||||||
|
id="table_var_{variable.key}"
|
||||||
|
class="cursor-pointer"
|
||||||
|
checked={$storedVariablePrefs.table?.[variable.key] ?? true}
|
||||||
|
onCheckedChange={() => toggle('table', variable.key)}
|
||||||
|
/>
|
||||||
|
<Label class="flex-1 cursor-pointer text-sm" for="table_var_{variable.key}">
|
||||||
|
{variable.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
|
||||||
|
button above the charts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-border px-5 py-3">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||||
|
onclick={resetDefaults}
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
export interface WeatherUnits {
|
export interface WeatherUnits {
|
||||||
temperature_unit: string;
|
temperature_unit: string;
|
||||||
@@ -12,7 +12,7 @@ export interface FetchedHourly {
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
hourlyDates: Date[];
|
hourlyDates: Date[];
|
||||||
markAreas: MarkArea[];
|
daylightBands: DaylightBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchedDaily {
|
export interface FetchedDaily {
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/**
|
||||||
|
* Registry of every variable that can be plotted on the customizable
|
||||||
|
* meteograms. Each entry carries the metadata needed to build a chart series
|
||||||
|
* (data field, render style, colour, unit family) so the panels can be
|
||||||
|
* assembled dynamically from a user-defined layout.
|
||||||
|
*/
|
||||||
|
import { defaultVariablePrefs } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { getColor } from '../../utils/colors';
|
||||||
|
import {
|
||||||
|
type WeatherUnits,
|
||||||
|
getPrecipUnit,
|
||||||
|
getTempUnit,
|
||||||
|
getWindDirectionLabel,
|
||||||
|
getWindUnit
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
import type { ChartSeries } from '$lib/charts';
|
||||||
|
import type { WeekHourlyData } from '$lib/services/weather';
|
||||||
|
|
||||||
|
/** Families of variables that share a y-axis and unit. */
|
||||||
|
export type UnitKind =
|
||||||
|
'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy';
|
||||||
|
|
||||||
|
export interface ChartVariableDef {
|
||||||
|
/** Stable id used in the persisted layout */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Short label for the tooltip / legend */
|
||||||
|
short: string;
|
||||||
|
/** Data array on the hourly response */
|
||||||
|
field: keyof WeekHourlyData;
|
||||||
|
/** Open-Meteo API variable name (defaults to `field` when identical) */
|
||||||
|
api?: string;
|
||||||
|
type: 'line' | 'bar';
|
||||||
|
kind: UnitKind;
|
||||||
|
color: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
fill?: boolean;
|
||||||
|
fillOpacity?: number;
|
||||||
|
/** Area fill coloured by the value scale, fading out below the minimum */
|
||||||
|
gradientFill?: boolean;
|
||||||
|
width?: number;
|
||||||
|
/** Stroke the line coloured by the temperature scale */
|
||||||
|
colorScale?: boolean;
|
||||||
|
/** Draw the line in the theme foreground (black/white), while colorScale still
|
||||||
|
* drives the gradient fill */
|
||||||
|
foregroundLine?: boolean;
|
||||||
|
/** Draw a contrasting halo under the line */
|
||||||
|
outline?: boolean;
|
||||||
|
/** Annotate local minima / maxima with their value */
|
||||||
|
extrema?: boolean;
|
||||||
|
/** Draw weather-code pictograms across the top of the chart */
|
||||||
|
pictograms?: boolean;
|
||||||
|
/** Draw wind-direction arrows across the top of the chart */
|
||||||
|
windArrows?: boolean;
|
||||||
|
/** Marker-only variable (icons / arrows): contributes no plotted series */
|
||||||
|
marker?: boolean;
|
||||||
|
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
|
||||||
|
cloudBand?: boolean;
|
||||||
|
/** Transform raw values before plotting (e.g. m → km) */
|
||||||
|
transform?: (v: number) => number;
|
||||||
|
/** Preset range to use when this variable lands on a shared right axis */
|
||||||
|
rightPreset?: { min: number; max: number; invert?: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||||
|
{
|
||||||
|
key: 'temperature',
|
||||||
|
label: 'Temperature',
|
||||||
|
short: 'Temp',
|
||||||
|
field: 'temperature_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#ef6c00',
|
||||||
|
width: 5.6,
|
||||||
|
colorScale: true,
|
||||||
|
foregroundLine: true,
|
||||||
|
gradientFill: true,
|
||||||
|
extrema: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'weather_icons',
|
||||||
|
label: 'Weather icons',
|
||||||
|
short: 'Icons',
|
||||||
|
field: 'weather_code',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#94a3b8',
|
||||||
|
pictograms: true,
|
||||||
|
marker: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apparent_temperature',
|
||||||
|
label: 'Apparent Temp',
|
||||||
|
short: 'Feels',
|
||||||
|
field: 'apparent_temperature',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#c2410c',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dew_point',
|
||||||
|
label: 'Dew Point',
|
||||||
|
short: 'Dew',
|
||||||
|
field: 'dew_point_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'temp',
|
||||||
|
color: '#0e7490',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover',
|
||||||
|
label: 'Cloud Cover',
|
||||||
|
short: 'Cloud',
|
||||||
|
field: 'cloud_cover',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: 'rgb(150, 155, 165)',
|
||||||
|
cloudBand: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_low',
|
||||||
|
label: 'Cloud Cover Low',
|
||||||
|
short: 'Low',
|
||||||
|
field: 'cloud_cover_low',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_mid',
|
||||||
|
label: 'Cloud Cover Mid',
|
||||||
|
short: 'Mid',
|
||||||
|
field: 'cloud_cover_mid',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#64748b',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cloud_cover_high',
|
||||||
|
label: 'Cloud Cover High',
|
||||||
|
short: 'High',
|
||||||
|
field: 'cloud_cover_high',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#cbd5e1',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation',
|
||||||
|
label: 'Precipitation',
|
||||||
|
short: 'Precip',
|
||||||
|
field: 'precipitation',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(30, 136, 229, 0.8)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'precipitation_probability',
|
||||||
|
label: 'Precip. Probability',
|
||||||
|
short: 'PoP',
|
||||||
|
field: 'precipitation_probability',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#5c6bc0',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rain',
|
||||||
|
label: 'Rain',
|
||||||
|
short: 'Rain',
|
||||||
|
field: 'rain',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(37, 99, 235, 0.75)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'showers',
|
||||||
|
label: 'Showers',
|
||||||
|
short: 'Shwr',
|
||||||
|
field: 'showers',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'precip',
|
||||||
|
color: 'rgba(6, 182, 212, 0.75)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'snowfall',
|
||||||
|
label: 'Snowfall',
|
||||||
|
short: 'Snow',
|
||||||
|
field: 'snowfall',
|
||||||
|
type: 'bar',
|
||||||
|
kind: 'snow',
|
||||||
|
color: 'rgba(147, 197, 253, 0.9)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind',
|
||||||
|
label: 'Wind Speed',
|
||||||
|
short: 'Wind',
|
||||||
|
field: 'windspeed_10m',
|
||||||
|
api: 'wind_speed_10m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'wind',
|
||||||
|
color: '#26a69a',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind_direction',
|
||||||
|
label: 'Wind direction',
|
||||||
|
short: 'Dir',
|
||||||
|
field: 'winddirection_10m',
|
||||||
|
api: 'wind_direction_10m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'wind',
|
||||||
|
color: '#14b8a6',
|
||||||
|
windArrows: true,
|
||||||
|
marker: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'wind_gusts',
|
||||||
|
label: 'Wind Gusts',
|
||||||
|
short: 'Gusts',
|
||||||
|
field: 'wind_gusts_10m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'wind',
|
||||||
|
color: '#0d9488',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'humidity',
|
||||||
|
label: 'Humidity',
|
||||||
|
short: 'RH',
|
||||||
|
field: 'relative_humidity_2m',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'percent',
|
||||||
|
color: '#8d6e63',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pressure_msl',
|
||||||
|
label: 'Pressure (MSL)',
|
||||||
|
short: 'MSLP',
|
||||||
|
field: 'pressure_msl',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'pressure',
|
||||||
|
color: '#7c3aed',
|
||||||
|
width: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'surface_pressure',
|
||||||
|
label: 'Surface Pressure',
|
||||||
|
short: 'Psfc',
|
||||||
|
field: 'surface_pressure',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'pressure',
|
||||||
|
color: '#a855f7',
|
||||||
|
width: 2,
|
||||||
|
dashed: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'uv_index',
|
||||||
|
label: 'UV Index',
|
||||||
|
short: 'UV',
|
||||||
|
field: 'uv_index',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'uv',
|
||||||
|
color: '#eab308',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'visibility',
|
||||||
|
label: 'Visibility',
|
||||||
|
short: 'Vis',
|
||||||
|
field: 'visibility',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'distance',
|
||||||
|
color: '#0891b2',
|
||||||
|
width: 2,
|
||||||
|
transform: (v) => v / 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cape',
|
||||||
|
label: 'CAPE',
|
||||||
|
short: 'CAPE',
|
||||||
|
field: 'cape',
|
||||||
|
type: 'line',
|
||||||
|
kind: 'energy',
|
||||||
|
color: '#dc2626',
|
||||||
|
width: 2,
|
||||||
|
fill: true,
|
||||||
|
fillOpacity: 0.12
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VARIABLE_BY_KEY: Map<string, ChartVariableDef> = new Map(
|
||||||
|
CHART_VARIABLES.map((v) => [v.key, v])
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Open-Meteo API variable name for a registry entry. */
|
||||||
|
export function apiNameOf(def: ChartVariableDef): string {
|
||||||
|
return def.api ?? def.field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of API hourly variables needed to render the current table rows and
|
||||||
|
* chart layout, so the fetch requests only what is actually shown.
|
||||||
|
*/
|
||||||
|
export function neededHourlyApiVars(
|
||||||
|
tablePrefs: Record<string, boolean> | undefined,
|
||||||
|
layoutKeys: string[]
|
||||||
|
): string[] {
|
||||||
|
const on = (key: string): boolean => tablePrefs?.[key] ?? defaultVariablePrefs.table[key] ?? true;
|
||||||
|
const s = new Set<string>();
|
||||||
|
|
||||||
|
// Hourly table rows
|
||||||
|
if (on('icons')) s.add('weather_code');
|
||||||
|
if (on('temperature')) s.add('temperature_2m');
|
||||||
|
if (on('feels')) s.add('apparent_temperature');
|
||||||
|
if (on('dew_point')) s.add('dew_point_2m');
|
||||||
|
if (on('wind')) {
|
||||||
|
s.add('wind_speed_10m');
|
||||||
|
s.add('wind_direction_10m');
|
||||||
|
}
|
||||||
|
if (on('gusts')) s.add('wind_gusts_10m');
|
||||||
|
if (on('humidity')) s.add('relative_humidity_2m');
|
||||||
|
if (on('clouds')) s.add('cloud_cover');
|
||||||
|
if (on('pressure')) s.add('pressure_msl');
|
||||||
|
if (on('uv')) s.add('uv_index');
|
||||||
|
if (on('visibility')) s.add('visibility');
|
||||||
|
if (on('precipitation')) {
|
||||||
|
s.add('precipitation');
|
||||||
|
s.add('precipitation_probability');
|
||||||
|
}
|
||||||
|
if (on('snowfall')) s.add('snowfall');
|
||||||
|
|
||||||
|
// Meteogram variables
|
||||||
|
for (const key of layoutKeys) {
|
||||||
|
const def = VARIABLE_BY_KEY.get(key);
|
||||||
|
if (!def) continue;
|
||||||
|
s.add(apiNameOf(def));
|
||||||
|
if (def.pictograms) s.add('weather_code');
|
||||||
|
if (def.windArrows || def.key === 'wind') s.add('wind_direction_10m');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...s];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unit label for a variable family, honouring the user's unit settings. */
|
||||||
|
export function unitForKind(kind: UnitKind, units: WeatherUnits): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'temp':
|
||||||
|
return getTempUnit(units);
|
||||||
|
case 'precip':
|
||||||
|
return getPrecipUnit(units);
|
||||||
|
case 'snow':
|
||||||
|
return 'cm';
|
||||||
|
case 'wind':
|
||||||
|
return getWindUnit(units);
|
||||||
|
case 'percent':
|
||||||
|
return '%';
|
||||||
|
case 'pressure':
|
||||||
|
return 'hPa';
|
||||||
|
case 'uv':
|
||||||
|
return '';
|
||||||
|
case 'distance':
|
||||||
|
return 'km';
|
||||||
|
case 'energy':
|
||||||
|
return 'J/kg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Families whose axis should always start at zero. */
|
||||||
|
export function isZeroBased(kind: UnitKind): boolean {
|
||||||
|
return kind !== 'temp' && kind !== 'pressure';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decimal places for on-chart extrema labels (kept coarse, like the cards). */
|
||||||
|
export function decimalsForKind(kind: UnitKind): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'precip':
|
||||||
|
case 'snow':
|
||||||
|
case 'uv':
|
||||||
|
case 'distance':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decimal places for the hover tooltip — finer than the cards / extrema labels
|
||||||
|
* so the meteogram reveals more detail. Percentages stay whole numbers.
|
||||||
|
*/
|
||||||
|
export function tooltipDecimalsForKind(kind: UnitKind): number {
|
||||||
|
switch (kind) {
|
||||||
|
case 'percent':
|
||||||
|
case 'energy':
|
||||||
|
return 0;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelDef {
|
||||||
|
series: ChartSeries[];
|
||||||
|
unit: string;
|
||||||
|
unitRight?: string;
|
||||||
|
yMin?: number;
|
||||||
|
yMinRight?: number;
|
||||||
|
yMaxRight?: number;
|
||||||
|
/** Whether the left axis should include zero (false for pressure) */
|
||||||
|
zeroBaseLeft: boolean;
|
||||||
|
hasPictograms: boolean;
|
||||||
|
hasWindArrows: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a chart definition for one panel: turns its ordered variable keys into
|
||||||
|
* series and works out the left / right axis units. The first variable's family
|
||||||
|
* owns the left axis; the first differing family gets the right axis.
|
||||||
|
*/
|
||||||
|
export function buildPanelDef(
|
||||||
|
variableKeys: string[],
|
||||||
|
hourly: WeekHourlyData,
|
||||||
|
units: WeatherUnits
|
||||||
|
): PanelDef {
|
||||||
|
const allDefs = variableKeys
|
||||||
|
.map((k) => VARIABLE_BY_KEY.get(k))
|
||||||
|
.filter((d): d is ChartVariableDef => d != null);
|
||||||
|
|
||||||
|
// Marker-only variables (weather icons) render no series, just a top row.
|
||||||
|
const defs = allDefs.filter((d) => !d.marker);
|
||||||
|
|
||||||
|
// Cloud-band variables float above the plot and don't claim an axis.
|
||||||
|
const axisDefs = defs.filter((d) => !d.cloudBand);
|
||||||
|
const kinds: UnitKind[] = [];
|
||||||
|
for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind);
|
||||||
|
const leftKind = kinds[0];
|
||||||
|
const rightKind = kinds.find((k) => k !== leftKind);
|
||||||
|
|
||||||
|
const series: ChartSeries[] = defs.map((d) => {
|
||||||
|
const raw = (hourly[d.field] as number[]) ?? [];
|
||||||
|
const data: (number | null)[] = d.transform
|
||||||
|
? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v)))
|
||||||
|
: raw;
|
||||||
|
const kindUnit = unitForKind(d.kind, units);
|
||||||
|
const dec = decimalsForKind(d.kind);
|
||||||
|
const tipDec = tooltipDecimalsForKind(d.kind);
|
||||||
|
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: d.label,
|
||||||
|
shortName: d.short,
|
||||||
|
type: d.type,
|
||||||
|
color: d.color,
|
||||||
|
data,
|
||||||
|
width: d.width,
|
||||||
|
fill: d.fill,
|
||||||
|
fillOpacity: d.fillOpacity,
|
||||||
|
gradientFill: d.gradientFill,
|
||||||
|
dashed: d.dashed,
|
||||||
|
axis,
|
||||||
|
cloudBand: d.cloudBand,
|
||||||
|
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
|
||||||
|
foregroundLine: d.foregroundLine,
|
||||||
|
outline: d.outline,
|
||||||
|
labelExtrema: d.extrema,
|
||||||
|
labelFormat: d.extrema
|
||||||
|
? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`)
|
||||||
|
: undefined,
|
||||||
|
format:
|
||||||
|
d.key === 'wind'
|
||||||
|
? (v: number, i: number) => {
|
||||||
|
const dir = hourly.winddirection_10m?.[i];
|
||||||
|
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||||
|
return `${v.toFixed(tipDec)} ${kindUnit}${dl}`;
|
||||||
|
}
|
||||||
|
: (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}`
|
||||||
|
} satisfies ChartSeries;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightZero = rightKind ? isZeroBased(rightKind) : false;
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||||
|
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||||
|
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||||
|
// 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),
|
||||||
|
hasWindArrows: allDefs.some((d) => d.windArrows)
|
||||||
|
};
|
||||||
|
}
|
||||||
+13
-1
@@ -9,9 +9,21 @@ const config = {
|
|||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter(),
|
// fallback: the SPA shell served for routes that were not prerendered
|
||||||
|
// (unlisted cities, GPS coordinate routes); the universal load then
|
||||||
|
// resolves the location client-side. Most static hosts serve 404.html
|
||||||
|
// for unknown paths automatically.
|
||||||
|
adapter: adapter({ fallback: '404.html' }),
|
||||||
// Pregenerate city pages to improve SEO during static build
|
// Pregenerate city pages to improve SEO during static build
|
||||||
prerender: {
|
prerender: {
|
||||||
|
// dynamic per-location routes (14-day, compare, unlisted cities) are
|
||||||
|
// served by the SPA fallback instead of being prerendered
|
||||||
|
handleUnseenRoutes: 'ignore',
|
||||||
|
// a transient geocoding failure for one city should skip that page
|
||||||
|
// (the fallback still serves it), not abort the whole build
|
||||||
|
handleHttpError: ({ path, message }) => {
|
||||||
|
console.warn(`prerender skipped ${path}: ${message}`);
|
||||||
|
},
|
||||||
entries: (() => {
|
entries: (() => {
|
||||||
try {
|
try {
|
||||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ const viteServerConfig = (): Plugin => ({
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
|
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
|
||||||
|
|
||||||
|
// @openmeteo/weather-map-layer is linked from ../../open-meteo/weather-map-layer
|
||||||
|
// (a file: dependency), so allow Vite to serve files from the shared Projects
|
||||||
|
// root and keep a single maplibre-gl instance across drizzli and the layer.
|
||||||
|
server: {
|
||||||
|
fs: { allow: ['../..'] }
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['maplibre-gl']
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
// these ship WASM and must not be pre-bundled by esbuild
|
||||||
|
exclude: ['@openmeteo/file-reader', '@openmeteo/file-format-wasm']
|
||||||
|
},
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user