Author SHA1 Message Date
Vincent van der Wal 47725127b6 rename readme 2026-07-19 14:23:53 +02:00
Vincent van der Wal d1d86184d6 rename header 2026-03-04 15:16:47 +01:00
terraputix f90fcfb6cb load from cloudflare pages 2026-02-21 14:27:17 +01:00
terraputix f232d9bd0e embed maps via iframe on localhost 2026-02-21 14:17:02 +01:00
terraputix 4ed89d83a1 pregenerate location names and fix location forwarding 2026-02-20 23:08:04 +01:00
terraputix 840bafded6 feat: model comparison picto timeline 2026-02-16 11:46:53 +01:00
fredericandterraputix 9946cd5851 feat: local time (#7)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#7
2026-02-16 11:41:34 +01:00
fredericandterraputix 381ffaf3f6 feat: move nav bar to the side (#6)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#6
2026-02-16 01:23:52 +01:00
fredericandterraputix 7a6cca5dad feat: canvas to echarts (#5)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#5
2026-02-16 00:33:58 +01:00
fredericandterraputix 2fe8cf92e1 feat: migrate to apache echarts (#4)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#4
2026-02-16 00:23:08 +01:00
fredericandterraputix 6872cac00e fix: type errors (#3)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#3
2026-02-15 15:53:54 +01:00
terraputix be0f7f822d update Readme 2026-02-15 15:29:56 +01:00
terraputix 3955fe5686 chore: move ui utils to utils/ui 2026-02-15 15:29:11 +01:00
fredericandterraputix 2a8a8b9746 fix: upgrade ui (#2)
Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/open-meteo-blue#2
2026-02-15 15:09:49 +01:00
terraputix 1e434e0a84 initial cleanup 2026-01-07 23:45:10 +01:00
terraputix 9b39255e64 initial commit 2026-01-07 22:24:02 +01:00
87 changed files with 5509 additions and 11534 deletions
-3
View File
@@ -23,6 +23,3 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
AGENTS.md
# Local agent/editor config
.claude/
View File
+2 -60
View File
@@ -1,4 +1,4 @@
# Drizz.li
# OMbrella
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/)
- **Language**: [TypeScript](https://www.typescriptlang.org/)
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
- **Visualization**: custom canvas charts (`src/lib/charts`)
- **Visualization**: [Apache Echarts](https://echarts.apache.org)
## Developing
@@ -33,61 +33,3 @@ npm run build
```
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;
}
}
```
+1763 -1423
View File
File diff suppressed because it is too large Load Diff
+33 -35
View File
@@ -1,5 +1,5 @@
{
"name": "drizzli",
"name": "open-meteo-weather",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -17,45 +17,43 @@
"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": {
"@eslint/compat": "^2.1.0",
"@eslint/js": "^10.0.1",
"@internationalized/date": "^3.12.2",
"@lucide/svelte": "^1.25.0",
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1",
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/vite": "^4.3.3",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^26",
"@vitest/browser-playwright": "^4.1.10",
"bits-ui": "^2.18.1",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/node": "^24",
"@vitest/browser-playwright": "^4.0.15",
"bits-ui": "^2.15.4",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"date-fns-tz": "^3.2.0",
"eslint": "^10.7.0",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"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",
"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.6.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.3",
"tailwindcss": "^4.1.17",
"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"
"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",
"echarts": "^6.0.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3"
}
}
-14
View File
@@ -3,20 +3,6 @@
<head>
<meta charset="utf-8" />
<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%
</head>
<body data-sveltekit-preload-data="hover">
+7
View File
@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+1 -46
View File
@@ -1,46 +1 @@
<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>
<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>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
/**
* 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] }));
}
-106
View File
@@ -1,106 +0,0 @@
/**
* 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 '';
}
-20
View File
@@ -1,20 +0,0 @@
/**
* 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 -50
View File
@@ -11,13 +11,12 @@
Usage:
<ChartContainer loading={!chartsReady} chartCount={3}>
{#each charts as chart}
<CanvasChart {...chart} />
<EChart option={chart.option} />
{/each}
</ChartContainer>
-->
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -31,10 +30,6 @@
chartHeight?: number;
/** Extra vertical padding in pixels added to the total min-height (default: 2) */
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 */
class?: string;
/** Slot content (charts go here) */
@@ -46,8 +41,6 @@
chartCount = 1,
chartHeight = 300,
extraPadding = 2,
minWidth = 560,
bleed = true,
class: className = '',
children
}: Props = $props();
@@ -57,14 +50,16 @@
let minHeight = $derived(chartHeight * chartCount + extraPadding);
</script>
<div class="chart-bleed" class:no-bleed={!bleed}>
<div
class="chart-container relative {className}"
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 }}>
<div
class="chart-content"
in:fade={{ duration: 300 }}
out:fade={{ duration: 300 }}
>
{#if children}
{@render children()}
{/if}
@@ -72,7 +67,7 @@
<!-- Loading overlay -->
<div
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-background transition-opacity duration-300"
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
class:pointer-events-none={!loading}
class:opacity-0={!loading}
class:opacity-100={loading}
@@ -97,50 +92,16 @@
</div>
</div>
</div>
</div>
<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 {
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 {
/* Negative horizontal margin on mobile to use full width, reset on md+ */
margin-left: -1.5rem;
margin-right: -1.5rem;
}
.chart-bleed.no-bleed {
@media (min-width: 768px) {
.chart-container {
margin-left: 0;
margin-right: 0;
}
+67 -21
View File
@@ -3,6 +3,7 @@
Provides a toolbar row with:
- Download full meteogram as PNG button
- Download full meteogram as SVG button
- Slot for additional custom controls (e.g. legend toggle)
When multiple charts are provided, they are stitched into a single
@@ -10,7 +11,7 @@
Usage:
<ChartToolbar
charts={chartComponents}
charts={chartInstances}
fileName="model-comparison"
>
{#snippet controls()}
@@ -18,22 +19,22 @@
{/snippet}
</ChartToolbar>
-->
<script module lang="ts">
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import { downloadMeteogram } from '$lib/utils/echarts/download';
import type { ExportFormat } from '$lib/utils/echarts/download';
import type * as echarts from 'echarts';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<ExportableChart | undefined | null>;
/** Array of ECharts instances available for download */
charts?: echarts.ECharts[];
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Pixel ratio for PNG exports (default: 2) */
pixelRatio?: number;
/** Optional CSS class for the outer container */
class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */
@@ -42,29 +43,33 @@
let {
charts = [],
fileName = 'drizzli-chart',
fileName = 'open-meteo-chart',
pixelRatio = 2,
class: className = '',
controls
}: Props = $props();
// ─── State ──────────────────────────────────────────────────────────────────
let downloading = $state(false);
let downloadingFormat: ExportFormat | null = $state(null);
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.some((chart) => chart != null));
let hasCharts = $derived(charts.length > 0);
// ─── Download ───────────────────────────────────────────────────────────────
// ─── Handlers ───────────────────────────────────────────────────────────────
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 {
await downloadChartsPng(charts, fileName);
await new Promise((resolve) => setTimeout(resolve, 50));
downloadMeteogram(charts, { fileName, format, pixelRatio });
} finally {
setTimeout(() => {
downloading = false;
downloadingFormat = null;
}, 500);
}
}
@@ -80,16 +85,17 @@
{/if}
</div>
<!-- Right side: Download button -->
<!-- Right side: Download buttons -->
<div class="flex flex-wrap items-center gap-2">
<!-- Download as PNG -->
<button
type="button"
class="toolbar-btn"
disabled={!hasCharts || downloading}
onclick={handleDownload}
disabled={!hasCharts || downloadingFormat !== null}
onclick={() => handleDownload('png')}
title="Download meteogram as PNG image"
>
{#if downloading}
{#if downloadingFormat === 'png'}
<svg
class="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
@@ -120,6 +126,46 @@
{/if}
<span>PNG</span>
</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>
+165
View File
@@ -0,0 +1,165 @@
<!--
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>
@@ -1,80 +0,0 @@
/**
* 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`);
}
+164
View File
@@ -0,0 +1,164 @@
/* ═══════════════════════════════════════════════════════════════════════════════
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;
}
}
+2 -2
View File
@@ -4,9 +4,9 @@
* Re-exports all chart-related Svelte components from a single entry point.
*
* Usage:
* import { ChartContainer, ChartToolbar } from '$lib/components/charts';
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
*/
export { default as EChart } from './EChart.svelte';
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
@@ -1,12 +1,7 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, tick } from 'svelte';
import {
type GeoLocation,
locationKey,
storedFavoriteLocations,
storedRecentLocations
} from '$lib/stores/settings';
import { type GeoLocation } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
@@ -35,31 +30,11 @@
};
const selectLocation = (location: GeoLocation) => {
addRecent(location);
searchQuery = '';
closePopover();
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() {
await tick();
searchInputEl?.focus();
@@ -89,19 +64,17 @@
return {
results: [
{
// coordinate-only location: id 0 + COORD makes
// buildLocationRoute emit a "52.52N13.41E" route
id: 0,
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000),
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude: latitude,
longitude: longitude,
elevation: position.coords.altitude ?? 0,
feature_code: 'COORD',
elevation: position.coords.altitude ?? NaN,
feature_code: '',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
timezone: '',
population: undefined,
postcodes: undefined,
country_id: undefined,
@@ -126,61 +99,12 @@
})();
</script>
{#snippet locationRow(location: GeoLocation)}
{@const fav = favKeys.has(locationKey(location))}
<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
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"
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"
>
<svg
class="h-4 w-4 shrink-0 text-primary"
class="h-3.5 w-3.5 shrink-0 opacity-50"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -252,35 +176,8 @@
</div>
</div>
{:then results}
{#if results.results && results.results.length === 0}
{#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
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
>
@@ -301,14 +198,7 @@
Start typing to search or use GPS to detect your position
</span>
</div>
{/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}
{:else}
<Alert.Root
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
>
@@ -316,10 +206,54 @@
No locations found for "{searchQuery}". Try a different term.
</Alert.Description>
</Alert.Root>
{:else}
{/if}
{:else if !results.results}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
</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}
{:catch error}
<Alert.Root variant="destructive">
+10 -89
View File
@@ -5,14 +5,11 @@
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { type GeoLocation, type Theme, storedLocation, storedTheme } from '$lib/stores/settings';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import LocationSearch from '$lib/components/location/location-search.svelte';
import UnitSelector from '$lib/components/unit-selector.svelte';
import PremiumBadge from '$lib/paywall/PremiumBadge.svelte';
interface Props {
onMenuToggle?: () => void;
@@ -26,19 +23,6 @@
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) {
storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation);
@@ -48,8 +32,6 @@
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) {
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/historical')) {
goto(resolve('/weather/historical/[location]', { location: locationRoute }));
} else {
goto(resolve('/weather/week/[location]', { location: locationRoute }));
}
@@ -61,7 +43,7 @@
>
<!-- Mobile menu toggle -->
<button
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"
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"
onclick={onMenuToggle}
aria-label="Toggle menu"
>
@@ -72,31 +54,28 @@
<!-- Current location display -->
{#if location}
<div
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
>
<div class="hidden items-center gap-2 sm:flex">
<img
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
class="h-5 w-5 rounded-full"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country}
/>
<!-- full location (desktop); the page hero carries it on smaller screens -->
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
<span class="text-sm font-medium text-foreground">
{location.name}
</span>
{#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1},&nbsp;
{/if}{location.country ?? ''}
<span class="text-xs text-muted-foreground">
{#if location.admin1}{location.admin1},{/if}
{location.country}
</span>
{/if}
</span>
</div>
{/if}
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Location search: primary way to switch places, so keep it loud -->
<!-- Location search -->
<div class="w-full max-w-sm md:max-w-md">
<LocationSearch
label="Search location..."
@@ -105,64 +84,6 @@
}}
/>
</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>
<style>
@@ -30,12 +30,6 @@
'M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z'
]
},
{
title: 'Historical',
url: '/weather/historical' as const,
// clock with a counter-clockwise arrow (history)
iconPaths: ['M12 8v4l3 2', 'M3.5 9a9 9 0 1 0 2.2-3.6L3 8m0-4.5V8h4.5']
},
{
title: 'Maps',
url: '/weather/maps' as const,
@@ -58,45 +52,57 @@
class:w-55={!collapsed}
class:w-14={collapsed}
>
<!-- Sidebar header: same height as the topbar so the borders align; the
home link fills the entire row, padding included -->
<div class="flex h-14 shrink-0 items-stretch border-b border-sidebar-border">
<!-- Sidebar header -->
<div class="flex items-center border-b border-sidebar-border px-3 py-4">
{#if !collapsed}
<a
href={resolve('/weather/week')}
class="flex flex-1 items-center gap-2.5 transition-colors hover:bg-sidebar-accent {collapsed
? 'justify-center'
: 'px-4'}"
class="flex items-center gap-2.5 px-1"
onclick={onMobileClose}
aria-label="Drizzli home"
>
<div
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
class="h-5 w-5"
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
stroke-width="2"
>
<!-- 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
fill="currentColor"
stroke="none"
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"
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"
/>
<path stroke-linecap="round" d="M12 14v5a1.9 1.9 0 0 1-3.8 0" />
</svg>
</div>
{#if !collapsed}
<span class="text-sm font-bold tracking-tight whitespace-nowrap text-sidebar-foreground">
Drizz.li
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
Open-Meteo
</span>
{/if}
</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}
</div>
<!-- Navigation links -->
@@ -131,28 +137,23 @@
{/each}
</nav>
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle}
<!-- Collapse toggle -->
<div class="border-t border-sidebar-border px-2 py-3">
<button
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"
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"
onclick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<svg
class="h-4.5 w-4.5 transition-transform duration-200"
class:rotate-180={collapsed}
class:rotate-180={!collapsed}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
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>
</div>
{#if !collapsed}
@@ -160,7 +161,6 @@
{/if}
</button>
</div>
{/if}
</aside>
<style>
+1 -1
View File
@@ -6,7 +6,7 @@
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 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",
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",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
@@ -22,26 +22,6 @@
children: Snippet;
showCloseButton?: boolean;
} = $props();
// Keep the (fixed, layout-viewport-centered) dialog centered within the
// VISUAL viewport, so the mobile keyboard shifts it up instead of covering it.
let kbShift = $state(0);
$effect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const visualCenter = vv.offsetTop + vv.height / 2;
// negative when the keyboard eats the bottom → moves the dialog up
kbShift = Math.min(0, visualCenter - window.innerHeight / 2);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
});
</script>
<DialogPortal {...portalProps}>
@@ -53,7 +33,6 @@
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
style="margin-top:{kbShift}px;transition:margin-top 0.18s ease"
{...restProps}
>
{@render children?.()}
-102
View File
@@ -1,102 +0,0 @@
<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>
-723
View File
@@ -1,723 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import LoaderIcon from '@lucide/svelte/icons/loader-circle';
import PauseIcon from '@lucide/svelte/icons/pause';
import PlayIcon from '@lucide/svelte/icons/play';
import { updateCurrentBounds } from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { FrameAnimator, type FrameChannel } from '$lib/maps/frame-animator';
import {
clockPlaying,
clockScrub,
clockSpan,
clockSpeed,
sharedCamera
} from '$lib/maps/map-sync';
import {
type ResolvedSource,
omSourceUrl,
registerOmProtocol,
resolveSource
} from '$lib/maps/om';
import { modelGroups } from '../../routes/weather/options';
interface Props {
/** om-file variable to render, e.g. `precipitation`, `wind_u_component_10m`. */
variable: string;
/** Human label for the status chip / errors, e.g. "Precipitation", "Wind". */
title: string;
/**
* Accumulation variable (e.g. precipitation): its first timestep is the
* analysis time with no accumulation window, so skip it. Leave off for
* instantaneous variables like wind.
*/
accumulation?: boolean;
/** Overlay animated wind-arrow vectors on top of the raster field. */
arrows?: boolean;
}
let { variable, title, accumulation = false, arrows = false }: Props = $props();
const STYLE_LIGHT = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps.json';
const STYLE_DARK = 'https://map-assets.open-meteo.com/styles/minimal-planet-maps-dark.json';
const DAY_MS = 86_400_000;
// ~8 frames loading at once ≈ 50 concurrent tile requests (a frame is a few
// tiles) - the requested preload parallelism.
const PRELOAD_MAX_FRAMES = 8;
// Playback speeds: ms of minimum wall-time per forecast frame.
const SPEEDS = [
{ label: '0.5×', ms: 900 },
{ label: '1×', ms: 450 },
{ label: '2×', ms: 220 }
];
// Loop / preload span presets (Infinity = the complete model run).
const SPANS = [
{ label: '2½ d', days: 2.5 },
{ label: '5 d', days: 5 },
{ label: 'Full run', days: Infinity }
];
const modelLabel = (id: string): string => {
for (const g of modelGroups) {
for (const m of g.models) if (m.value === id) return m.label;
}
return id;
};
let mapEl: HTMLDivElement;
let map: maplibregl.Map | undefined;
let animator: FrameAnimator | undefined;
let marker: maplibregl.Marker | undefined;
let syncingCamera = false;
// resolved source (full run) + windowed timeline
let sourceBase: ResolvedSource | null = null;
let fullTimes: Date[] = [];
let source = $state<ResolvedSource | null>(null);
let index = $state(0);
let loading = $state(true);
let firstFrameLoading = $state(true);
let error = $state<string | null>(null);
/** Per-frame load state, aligned with `times`. */
let loaded = $state<boolean[]>([]);
// dark mode mirrors the root layout's `.dark` class on <html>
let dark = $state(false);
// preload covers the whole windowed timeline (the loop == the window)
let loopStart = 0;
let loopEnd = 0;
let loadController: AbortController | undefined;
let lastKey = '';
let appliedSpan = get(clockSpan);
let appliedScrubEpoch = get(clockScrub).epoch;
const times = $derived(source?.times ?? []);
const currentTime = $derived(times[index]);
const loadedCount = $derived(loaded.reduce((n, v) => n + (v ? 1 : 0), 0));
// ---- helpers -------------------------------------------------------------
const closestIndexToNow = (list: Date[]): number => {
const now = Date.now();
let best = 0;
let bestDelta = Infinity;
for (let i = 0; i < list.length; i++) {
const delta = Math.abs(list[i].getTime() - now);
if (delta < bestDelta) {
bestDelta = delta;
best = i;
}
}
return best;
};
/** Keep steps within `spanDays` days from today 00:00 (Infinity = all). */
const windowTimes = (all: Date[], spanDays: number): Date[] => {
if (!isFinite(spanDays) || spanDays <= 0) return all;
const dayStart = new SvelteDate();
dayStart.setHours(0, 0, 0, 0);
const cutoff = dayStart.getTime() + spanDays * DAY_MS;
const windowed = all.filter((t) => t.getTime() >= dayStart.getTime() && t.getTime() <= cutoff);
return windowed.length ? windowed : all;
};
const buildUrl = (i: number): string => {
const s = source;
if (!s) return '';
return omSourceUrl(s.domain, s.modelRun, s.times[i], variable, dark, arrows);
};
const firstSymbolLayer = (m: maplibregl.Map): string | undefined =>
m.getStyle()?.layers?.find((l) => l.type === 'symbol')?.id;
// ---- animator / channels -------------------------------------------------
const makeChannels = (): FrameChannel[] => {
const raster: FrameChannel = {
key: 'raster',
sourceSpec: (url) => ({ type: 'raster', url, maxzoom: 14 }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'raster',
source: sourceId,
paint: {
'raster-opacity': 0,
'raster-opacity-transition': { duration: 0, delay: 0 },
'raster-fade-duration': 0
}
},
before
),
opacityProp: 'raster-opacity',
peakOpacity: dark ? 0.85 : 0.9
};
if (!arrows) return [raster];
const arrowLayer: FrameChannel = {
key: 'arrows',
sourceSpec: (url) => ({ type: 'vector', url }),
addLayer: (m, sourceId, layerId, before) =>
m.addLayer(
{
id: layerId,
type: 'line',
source: sourceId,
'source-layer': 'wind-arrows',
layout: { 'line-cap': 'round' },
paint: {
'line-opacity': 0,
'line-opacity-transition': { duration: 0, delay: 0 },
'line-color': dark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.72)',
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 1.2, 9, 1.8]
}
},
before
),
opacityProp: 'line-opacity',
peakOpacity: 1
};
return [raster, arrowLayer];
};
const makeAnimator = (m: maplibregl.Map): void => {
animator?.destroy();
animator = new FrameAnimator({
map: m,
urlForFrame: buildUrl,
channels: makeChannels(),
beforeLayer: firstSymbolLayer(m),
opacity: dark ? 0.85 : 0.9,
onShow: (i) => {
index = i;
if (i < loaded.length) loaded[i] = true;
},
onReady: (i) => {
if (i < loaded.length) loaded[i] = true;
},
onFirstFrame: () => {
firstFrameLoading = false;
}
});
};
/** Chronological preload so the current day fills front-to-back, no gap. */
const preloadOrder = (): number[] => {
const order: number[] = [];
for (let i = loopStart; i <= loopEnd; i++) order.push(i);
return order;
};
const startAnimation = (m: maplibregl.Map): void => {
if (!source) return;
makeAnimator(m);
animator!.setFrameCount(source.times.length);
animator!.setLoopRange(loopStart, loopEnd);
const speed = get(clockSpeed);
animator!.setSpeed(speed);
const initial = closestIndexToNow(source.times);
index = initial;
if (get(clockPlaying)) animator!.play(speed, initial);
else animator!.scrubTo(initial);
animator!.preload(preloadOrder(), PRELOAD_MAX_FRAMES);
};
// ---- windowing / (re)load ------------------------------------------------
const applyWindow = (): void => {
if (!sourceBase || !map) return;
const list = windowTimes(fullTimes, get(clockSpan));
source = { ...sourceBase, times: list };
loopStart = 0;
loopEnd = Math.max(0, list.length - 1);
loaded = new Array(list.length).fill(false);
firstFrameLoading = true;
startAnimation(map);
};
const load = async (model: string): Promise<void> => {
loadController?.abort();
const controller = new AbortController();
loadController = controller;
loading = true;
error = null;
const resolved = await resolveSource(model, variable, {
signal: controller.signal,
skipFirst: accumulation
});
if (controller.signal.aborted) return;
if (!resolved) {
error = `No ${title.toLowerCase()} data available for this model right now.`;
loading = false;
return;
}
sourceBase = resolved;
fullTimes = resolved.times;
loading = false;
applyWindow();
};
// ---- theme ---------------------------------------------------------------
const applyStyle = async (m: maplibregl.Map): Promise<void> => {
m.setStyle(dark ? STYLE_DARK : STYLE_LIGHT);
await new Promise<void>((resolve) => m.once('style.load', () => resolve()));
applyPadding(m);
// setStyle wipes all layers; rebuild the animator for the new theme
if (source) startAnimation(m);
};
// ---- camera / marker -----------------------------------------------------
// 20% bottom padding keeps the focused location above the time scrubber.
const applyPadding = (m: maplibregl.Map): void => {
const h = mapEl?.clientHeight ?? 0;
m.setPadding({ top: 0, right: 0, bottom: Math.round(h * 0.2), left: 0 });
};
const makeMarkerEl = (): HTMLDivElement => {
const el = document.createElement('div');
el.className = 'wx-marker';
el.innerHTML = '<span class="wx-marker__ring"></span><span class="wx-marker__dot"></span>';
return el;
};
// ---- lifecycle -----------------------------------------------------------
onMount(() => {
registerOmProtocol();
dark = document.documentElement.classList.contains('dark');
lastKey = `${$storedModel}`;
map = new maplibregl.Map({
container: mapEl,
style: dark ? STYLE_DARK : STYLE_LIGHT,
center: [$storedLocation.longitude, $storedLocation.latitude],
zoom: 7.5,
attributionControl: { compact: true },
maxPitch: 0,
dragRotate: false
});
const m = map;
m.scrollZoom.disable(); // don't hijack page scroll to zoom
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
const pushBounds = () => {
const b = m.getBounds();
updateCurrentBounds([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
// mirror this map's camera to siblings (unless the move came from a sync)
m.on('move', () => {
pushBounds();
if (syncingCamera) return;
const c = m.getCenter();
sharedCamera.set({ center: [c.lng, c.lat], zoom: m.getZoom() });
});
m.on('resize', () => applyPadding(m));
m.on('load', () => {
pushBounds();
applyPadding(m);
marker = new maplibregl.Marker({ element: makeMarkerEl(), anchor: 'center' })
.setLngLat([$storedLocation.longitude, $storedLocation.latitude])
.addTo(m);
void load($storedModel);
});
// follow a sibling map's camera
const unsubCamera = sharedCamera.subscribe((cam) => {
if (!cam || !map) return;
const c = map.getCenter();
if (
Math.abs(c.lng - cam.center[0]) < 1e-6 &&
Math.abs(c.lat - cam.center[1]) < 1e-6 &&
Math.abs(map.getZoom() - cam.zoom) < 1e-6
)
return;
syncingCamera = true;
map.jumpTo({ center: cam.center, zoom: cam.zoom });
syncingCamera = false;
});
// keep dark in sync with the app theme toggle (root layout flips `.dark`)
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('dark');
if (isDark !== dark) {
dark = isDark;
if (map && map.isStyleLoaded()) void applyStyle(map);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
unsubCamera();
animator?.destroy();
loadController?.abort();
marker?.remove();
map?.remove();
map = undefined;
};
});
// reload when the shared model selection changes
$effect(() => {
const key = `${$storedModel}`;
if (map && map.isStyleLoaded() && key !== lastKey) {
lastKey = key;
void load($storedModel);
}
});
// recenter + move the marker when the user picks a new location
$effect(() => {
const loc = $storedLocation;
marker?.setLngLat([loc.longitude, loc.latitude]);
if (map && map.isStyleLoaded()) {
map.easeTo({ center: [loc.longitude, loc.latitude], duration: 800 });
}
});
// ---- shared-clock effects ------------------------------------------------
// play / pause together
$effect(() => {
const playing = $clockPlaying;
if (!animator) return;
if (playing) {
const from = index >= loopStart && index <= loopEnd ? index : loopStart;
animator.play(get(clockSpeed), from);
} else {
animator.pause();
}
});
// speed together
$effect(() => {
const ms = $clockSpeed;
animator?.setSpeed(ms);
});
// span together (re-window + restart)
$effect(() => {
const days = $clockSpan;
if (days !== appliedSpan) {
appliedSpan = days;
if (sourceBase) applyWindow();
}
});
// scrub together
$effect(() => {
const s = $clockScrub;
if (s.epoch !== appliedScrubEpoch) {
appliedScrubEpoch = s.epoch;
if (s.index >= 0) {
index = s.index;
animator?.scrubTo(s.index);
}
}
});
// ---- controls (write to the shared clock so every map follows) -----------
const togglePlay = (): void => clockPlaying.update((p) => !p);
const setSpeed = (ms: number): void => clockSpeed.set(ms);
const setSpan = (days: number): void => clockSpan.set(days);
const onScrubInput = (e: Event): void => {
const v = Number((e.currentTarget as HTMLInputElement).value);
clockScrub.set({ index: v, epoch: get(clockScrub).epoch + 1 });
};
const onScrubStart = (): void => clockPlaying.set(false);
const onScrubEnd = (): void => clockPlaying.set(true);
// ---- formatting / scrubber decorations -----------------------------------
const hour12 = $derived($storedUnits.time_format === '12h');
const fmtTime = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', hour12 }).format(d)
: '--:--';
const fmtDay = (d: Date | undefined): string =>
d
? new Intl.DateTimeFormat(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short'
}).format(d)
: '';
const pct = (i: number): number => (times.length > 1 ? (i / (times.length - 1)) * 100 : 0);
const dayMarkers = $derived.by(() => {
const out: { i: number; label: string; left: number }[] = [];
let prevDay = '';
times.forEach((t, i) => {
const day = t.toDateString();
if (day !== prevDay) {
prevDay = day;
out.push({
i,
label: new Intl.DateTimeFormat(undefined, { weekday: 'short' }).format(t),
left: pct(i)
});
}
});
return out;
});
const nowLeft = $derived(times.length ? pct(closestIndexToNow(times)) : 0);
</script>
<div class="relative h-full w-full">
<div bind:this={mapEl} class="h-full w-full"></div>
<!-- top-left status: variable + model + fallback notice -->
<div class="pointer-events-none absolute left-3 top-3 z-10 flex flex-col gap-1">
<div
class="pointer-events-auto rounded-md bg-background/85 px-3 py-1.5 text-sm font-medium shadow-sm backdrop-blur"
>
{title} · {modelLabel($storedModel)}
</div>
{#if source?.fellBack}
<div
class="pointer-events-auto w-fit rounded-md bg-amber-500/90 px-2 py-1 text-xs font-medium text-white shadow-sm"
>
No map for this model - showing global {source.domain}
</div>
{/if}
</div>
{#if error}
<div class="absolute inset-x-0 top-16 z-10 flex justify-center">
<div class="rounded-md bg-destructive px-4 py-2 text-sm text-destructive-foreground shadow">
{error}
</div>
</div>
{/if}
<!-- big scrubber -->
<div class="absolute inset-x-0 bottom-0 z-10 p-3 sm:p-4">
<div
class="mx-auto w-full rounded-xl border border-border bg-background/90 p-3 shadow-lg backdrop-blur sm:p-4"
>
<div class="flex items-center gap-3 sm:gap-4">
<button
type="button"
onclick={togglePlay}
disabled={loading || !!error || times.length < 2}
aria-label={$clockPlaying ? 'Pause' : 'Play'}
class="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition hover:opacity-90 disabled:opacity-40 sm:size-14"
>
{#if loading || firstFrameLoading}
<LoaderIcon class="size-6 animate-spin" />
{:else if $clockPlaying}
<PauseIcon class="size-6" />
{:else}
<PlayIcon class="size-6 translate-x-0.5" />
{/if}
</button>
<div class="flex min-w-0 flex-col">
<span class="text-2xl font-semibold leading-none tabular-nums sm:text-3xl">
{fmtTime(currentTime)}
</span>
<span class="mt-1 truncate text-xs text-muted-foreground sm:text-sm">
{fmtDay(currentTime)}
</span>
</div>
<div class="ml-auto flex flex-wrap items-center justify-end gap-2">
{#if times.length}
<span class="hidden text-xs text-muted-foreground tabular-nums xl:inline">
{loadedCount}/{times.length} cached
</span>
{/if}
<!-- range: how much to loop / preload -->
<div
class="flex items-center gap-1 rounded-lg bg-muted p-1"
title="Loop &amp; preload range"
>
{#each SPANS as s (s.label)}
<button
type="button"
onclick={() => setSpan(s.days)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpan === s.days
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
<!-- playback speed -->
<div class="flex items-center gap-1 rounded-lg bg-muted p-1" title="Playback speed">
{#each SPEEDS as s (s.ms)}
<button
type="button"
onclick={() => setSpeed(s.ms)}
class="rounded-md px-2 py-1 text-xs font-medium tabular-nums transition
{$clockSpeed === s.ms
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
>
{s.label}
</button>
{/each}
</div>
</div>
</div>
<!-- track -->
<div class="relative mt-3">
<input
type="range"
class="scrubber relative"
min="0"
max={Math.max(0, times.length - 1)}
step="1"
value={index}
disabled={loading || !!error || !times.length}
oninput={onScrubInput}
onpointerdown={onScrubStart}
onpointerup={onScrubEnd}
onpointercancel={onScrubEnd}
aria-label="Forecast time"
/>
<!-- now marker -->
{#if times.length}
<div
class="pointer-events-none absolute -top-1 h-3 w-0.5 bg-red-500"
style="left: {nowLeft}%"
title="Now"
></div>
{/if}
<!-- buffered-frames strip: shows which steps are cached -->
{#if times.length}
<div class="mt-2 flex h-1.5 w-full overflow-hidden rounded-full bg-muted-foreground/15">
{#each times as _t, i (i)}
<div
class="h-full flex-1 transition-colors {loaded[i]
? 'bg-primary'
: 'bg-transparent'} {i === index ? 'bg-red-500' : ''}"
></div>
{/each}
</div>
{/if}
<!-- day labels -->
<div class="relative mt-1 h-4">
{#each dayMarkers as d (d.i)}
<span
class="pointer-events-none absolute -translate-x-1/2 text-[10px] text-muted-foreground"
style="left: {Math.min(96, Math.max(4, d.left))}%"
>
{d.label}
</span>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
/* Big, prominent scrubber that works in light and dark. */
.scrubber {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
border-radius: 9999px;
background: color-mix(in oklab, var(--color-primary) 25%, transparent);
cursor: pointer;
outline: none;
}
.scrubber:disabled {
opacity: 0.5;
cursor: default;
}
.scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-webkit-slider-thumb:active {
cursor: grabbing;
}
.scrubber::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 9999px;
background: var(--color-primary);
border: 3px solid var(--color-background);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
cursor: grab;
}
.scrubber::-moz-range-progress {
height: 10px;
border-radius: 9999px;
background: var(--color-primary);
}
/* Pulsing "current location" marker (added to the map's own DOM, so styles
must be global). */
:global(.wx-marker) {
position: relative;
width: 20px;
height: 20px;
pointer-events: none;
}
:global(.wx-marker__dot) {
position: absolute;
inset: 6px;
border-radius: 9999px;
background: #ef4444;
box-shadow:
0 0 0 2px #fff,
0 1px 3px rgba(0, 0, 0, 0.5);
}
:global(.wx-marker__ring) {
position: absolute;
inset: 0;
border-radius: 9999px;
background: rgba(239, 68, 68, 0.55);
animation: wx-pulse 1.5s ease-out infinite;
}
@keyframes -global-wx-pulse {
0% {
transform: scale(0.4);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
</style>
-379
View File
@@ -1,379 +0,0 @@
import * as maplibregl from 'maplibre-gl';
/**
* Smooth cross-fade animator for a stack of MapLibre weather frames.
*
* Instead of tearing down and rebuilding a source per timestep (which flashes
* and stutters), each forecast step gets its own persistent layer(s). Playback
* drives a floating-point playhead and holds each frame crisp for most of its
* dwell, then cross-fades to the next over a short window - a fluid hand-off
* rather than a muddy full-interval dissolve.
*
* A frame can render several **channels** (e.g. a raster field plus a vector
* wind-arrows overlay); all a frame's channels fade together and it is only
* "ready" once every channel's source has loaded.
*
* Frames inside the active loop range stay resident so repeat loops are
* instant; frames visited only by scrubbing are evicted LRU. The playhead only
* advances once both the current and next frame have loaded, so a slow tile
* never produces a gap - it just briefly holds the current frame.
*/
export interface FrameChannel {
/** Unique per channel (part of source/layer ids). */
key: string;
sourceSpec: (url: string) => maplibregl.SourceSpecification;
/** Add the layer at opacity 0 (it is faded in by the animator). */
addLayer: (
map: maplibregl.Map,
sourceId: string,
layerId: string,
beforeLayer: string | undefined
) => void;
/** Paint property used to fade this channel, e.g. `raster-opacity`. */
opacityProp: string;
/** Peak opacity for this channel (defaults to the animator's `opacity`). */
peakOpacity?: number;
}
export interface FrameAnimatorOptions {
map: maplibregl.Map;
/** `om://` source URL for frame `i`. */
urlForFrame: (i: number) => string;
channels: FrameChannel[];
beforeLayer?: string;
/** Default peak opacity for channels that don't set their own. */
opacity?: number;
/** Cross-fade window in ms (0 = instant swaps). Default 130. */
crossFadeMs?: number;
/** LRU cap for frames outside the loop range (scrub-visited). */
outOfRangePool?: number;
/** Fired when the dominant integer frame changes (drives the scrubber). */
onShow?: (i: number) => void;
/** Fired when frame `i` finishes loading (drives the buffer strip). */
onReady?: (i: number) => void;
/** Fired once, when the very first frame becomes visible. */
onFirstFrame?: () => void;
}
interface LayerInfo {
layerId: string;
opacityProp: string;
peak: number;
}
interface Entry {
sources: string[];
layers: LayerInfo[];
loadedSources: Set<string>;
ready: boolean;
onData?: (e: maplibregl.MapSourceDataEvent) => void;
}
export class FrameAnimator {
private map: maplibregl.Map;
private opt: FrameAnimatorOptions;
private target: number;
private crossFadeMs: number;
private frames = new Map<number, Entry>();
private visible = new Set<number>();
private lru: number[] = []; // out-of-loop frames, oldest first
private frameCount = 0;
private loopStart = 0;
private loopEnd = 0;
private mode: 'still' | 'play' = 'still';
private q = 0; // loop-local playhead (float) when playing
private stillIndex = 0;
private speedMs = 450;
private raf = 0;
private lastTs = 0;
private lastShown = -1;
private firstShown = false;
private alive = true;
// bounded-parallel preloading
private preloadQueue: number[] = [];
private preloading = new Set<number>();
private preloadInFlight = 0;
private preloadMax = 8;
constructor(opt: FrameAnimatorOptions) {
this.map = opt.map;
this.opt = opt;
this.target = opt.opacity ?? 0.9;
this.crossFadeMs = opt.crossFadeMs ?? 200;
this.tick = this.tick.bind(this);
}
setFrameCount(n: number): void {
this.frameCount = n;
}
setLoopRange(start: number, end: number): void {
this.loopStart = Math.max(0, start);
this.loopEnd = Math.max(this.loopStart, end);
}
setSpeed(ms: number): void {
this.speedMs = ms;
}
get currentIndex(): number {
return this.mode === 'play'
? this.loopStart + (Math.floor(this.q) % this.loopLen())
: this.stillIndex;
}
private loopLen(): number {
return Math.max(1, this.loopEnd - this.loopStart + 1);
}
private inLoop(i: number): boolean {
return i >= this.loopStart && i <= this.loopEnd;
}
/** Show a single frame and stop animating (used for scrubbing / paused). */
scrubTo(i: number): void {
this.mode = 'still';
this.stillIndex = Math.max(0, Math.min(this.frameCount - 1, i));
this.start();
}
/** Start looping over the loop range, entering at `fromIndex` when given. */
play(speedMs: number, fromIndex?: number): void {
this.speedMs = speedMs;
this.mode = 'play';
const L = this.loopLen();
this.q = fromIndex != null && this.inLoop(fromIndex) ? (fromIndex - this.loopStart) % L : 0;
this.start();
}
pause(): void {
if (this.mode === 'play') this.stillIndex = this.currentIndex;
this.mode = 'still';
}
private start(): void {
if (!this.alive) return;
this.lastTs = 0;
if (!this.raf) this.raf = requestAnimationFrame(this.tick);
}
private stopRaf(): void {
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
private tick(ts: number): void {
if (!this.alive) return;
const dt = this.lastTs ? ts - this.lastTs : 16;
this.lastTs = ts;
if (this.mode === 'play') this.tickPlay(dt);
else this.tickStill();
this.raf = requestAnimationFrame(this.tick);
}
private tickStill(): void {
const i = this.stillIndex;
this.ensure(i);
if (!this.frames.get(i)?.ready) return; // hold previous frame until ready
this.markFirst();
this.setOp(i, 1);
this.hideExcept(new Set([i]));
this.emitShown(i);
}
private tickPlay(dt: number): void {
const L = this.loopLen();
const baseLocal = Math.floor(this.q) % L;
const curr = this.loopStart + baseLocal;
const next = this.loopStart + ((baseLocal + 1) % L);
this.ensure(curr);
this.ensure(next);
this.ensure(this.loopStart + ((baseLocal + 2) % L));
const currReady = this.frames.get(curr)?.ready;
const nextReady = this.frames.get(next)?.ready;
if (currReady) this.markFirst();
if (currReady && nextReady) {
this.q += dt / this.speedMs;
if (this.q >= L) this.q -= L;
const bl = Math.floor(this.q) % L;
const cB = this.loopStart + bl;
const cN = this.loopStart + ((bl + 1) % L);
const frac = this.q - Math.floor(this.q);
// Hold the current frame crisp for most of its dwell, then cross-fade
// to the next only over the short crossFadeMs window at the end.
const fadeFrac = Math.min(0.9, this.crossFadeMs / this.speedMs);
const fadeStart = 1 - fadeFrac;
if (fadeFrac <= 0 || frac < fadeStart) {
this.setOp(cB, 1);
this.hideExcept(new Set([cB]));
this.emitShown(cB);
} else {
const blend = (frac - fadeStart) / fadeFrac;
this.setOp(cB, 1 - blend);
this.setOp(cN, blend);
this.hideExcept(new Set([cB, cN]));
this.emitShown(blend < 0.5 ? cB : cN);
}
} else if (currReady) {
// next frame still loading - hold the current frame at full opacity
this.setOp(curr, 1);
this.hideExcept(new Set([curr]));
this.emitShown(curr);
}
}
private markFirst(): void {
if (this.firstShown) return;
this.firstShown = true;
this.opt.onFirstFrame?.();
}
private emitShown(i: number): void {
if (i === this.lastShown) return;
this.lastShown = i;
this.opt.onShow?.(i);
}
private ensure(i: number): void {
if (i < 0 || i >= this.frameCount) return;
if (this.frames.has(i)) {
this.touch(i);
return;
}
if (!this.map.isStyleLoaded()) return;
const url = this.opt.urlForFrame(i);
const sources: string[] = [];
const layers: LayerInfo[] = [];
for (const ch of this.opt.channels) {
const sourceId = `omAnim_${ch.key}_${i}`;
const layerId = `omAnimL_${ch.key}_${i}`;
this.map.addSource(sourceId, ch.sourceSpec(url));
ch.addLayer(this.map, sourceId, layerId, this.opt.beforeLayer);
if (this.map.getLayer(layerId)) {
sources.push(sourceId);
layers.push({ layerId, opacityProp: ch.opacityProp, peak: ch.peakOpacity ?? this.target });
}
}
const entry: Entry = { sources, layers, loadedSources: new Set(), ready: false };
const allLoaded = () => entry.sources.every((s) => this.map.getSource(s)?.loaded());
const markReady = () => {
entry.ready = true;
if (entry.onData) {
this.map.off('sourcedata', entry.onData);
entry.onData = undefined;
}
this.opt.onReady?.(i);
if (this.preloading.has(i)) {
this.preloading.delete(i);
this.preloadInFlight = Math.max(0, this.preloadInFlight - 1);
queueMicrotask(() => this.pumpPreload());
}
};
if (allLoaded()) {
markReady();
} else {
const onData = (e: maplibregl.MapSourceDataEvent) => {
if (!e.sourceId || !entry.sources.includes(e.sourceId)) return;
if (!e.isSourceLoaded) return;
if (allLoaded()) markReady();
};
entry.onData = onData;
this.map.on('sourcedata', onData);
}
this.frames.set(i, entry);
this.touch(i);
this.evict();
}
private touch(i: number): void {
if (this.inLoop(i)) return; // loop frames are never evicted
const at = this.lru.indexOf(i);
if (at >= 0) this.lru.splice(at, 1);
this.lru.push(i);
}
private evict(): void {
const cap = this.opt.outOfRangePool ?? 6;
while (this.lru.length > cap) {
const i = this.lru.shift();
if (i === undefined || i === this.stillIndex) continue;
this.remove(i);
}
}
private remove(i: number): void {
const e = this.frames.get(i);
if (!e) return;
if (e.onData) this.map.off('sourcedata', e.onData);
for (const { layerId } of e.layers)
if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
for (const s of e.sources) if (this.map.getSource(s)) this.map.removeSource(s);
this.frames.delete(i);
this.visible.delete(i);
}
/** Set every channel of frame `i` to `mul` × its peak opacity. */
private setOp(i: number, mul: number): void {
const e = this.frames.get(i);
if (!e) return;
for (const { layerId, opacityProp, peak } of e.layers) {
if (this.map.getLayer(layerId)) this.map.setPaintProperty(layerId, opacityProp, peak * mul);
}
if (mul > 0.001) this.visible.add(i);
else this.visible.delete(i);
}
private hideExcept(keep: Set<number>): void {
for (const i of [...this.visible]) if (!keep.has(i)) this.setOp(i, 0);
}
/**
* Eagerly load `indices` with at most `maxInFlight` frames loading at once.
* Each frame is a set of layers, so MapLibre + the tile worker pool fetch and
* render them in parallel; the cap bounds the number of open tile requests.
*/
preload(indices: number[], maxInFlight = 8): void {
this.preloadMax = Math.max(1, maxInFlight);
this.preloadQueue = indices.slice();
this.pumpPreload();
}
private pumpPreload(): void {
while (this.preloadInFlight < this.preloadMax && this.preloadQueue.length) {
const i = this.preloadQueue.shift();
if (i === undefined || i < 0 || i >= this.frameCount) continue;
if (this.frames.get(i)?.ready || this.preloading.has(i)) continue;
this.preloading.add(i);
this.preloadInFlight++;
this.ensure(i); // synchronous cache hits settle via markReady immediately
}
}
/** Remove every layer/source (e.g. before a style reload). */
reset(): void {
this.stopRaf();
for (const i of [...this.frames.keys()]) this.remove(i);
this.lru = [];
this.visible.clear();
this.lastShown = -1;
this.firstShown = false;
this.preloadQueue = [];
this.preloading.clear();
this.preloadInFlight = 0;
}
destroy(): void {
this.alive = false;
this.reset();
}
}
-33
View File
@@ -1,33 +0,0 @@
import { writable } from 'svelte/store';
/**
* Cross-map sync state, shared by every WeatherMap on the page so the maps stay
* matched. Split into one store per concern so a change to one (e.g. speed)
* doesn't wake effects that only care about another (e.g. the playhead).
*/
/** Camera shared across maps; the last map the user moved writes it. */
export interface SharedCamera {
center: [number, number];
zoom: number;
}
export const sharedCamera = writable<SharedCamera | null>(null);
/** Whether playback is running. */
export const clockPlaying = writable(true);
/** Minimum wall-time per frame, in ms. */
export const clockSpeed = writable(450);
/** Loop / preload span in days from today 00:00 (Infinity = the whole run). */
export const clockSpan = writable(2.5);
/**
* The scrub target. `epoch` bumps on every scrub so followers re-apply even if
* the index repeats; `index` is -1 before the first scrub.
*/
export interface ScrubState {
index: number;
epoch: number;
}
export const clockScrub = writable<ScrubState>({ index: -1, epoch: 0 });
-166
View File
@@ -1,166 +0,0 @@
/**
* Thin integration layer over @openmeteo/weather-map-layer for the native
* animated weather maps. Responsibilities:
*
* - register the `om://` MapLibre protocol once (shared settings/cache)
* - resolve drizzli's selected model id to a maps domain that actually serves
* the requested variable, falling back to a global model when the model has
* no map domain (or its run lacks the variable) - "use the global models as
* fallback"
* - build the fully-resolved `.om` tile-source URLs for a given model run and
* forecast time (the timestamp lives in the path, the variable in the query)
*/
import {
type DomainMetaDataJson,
defaultOmProtocolSettings,
omProtocol
} from '@openmeteo/weather-map-layer';
import * as maplibregl from 'maplibre-gl';
import { modelDomainCandidates } from '$lib/utils/maps-domain';
/** Tile host. Always the production CDN (serves CORS/CORP for our headers). */
const BASE = 'https://map-tiles.open-meteo.com';
/**
* Global models (widest coverage) tried after the selected model's own
* candidates. dwd_icon leads: it is truly global, hourly, and carries the
* longest run, which makes for the smoothest animation.
*/
const GLOBAL_FALLBACKS = ['dwd_icon', 'ecmwf_ifs025', 'ncep_gfs013'];
// Register the protocol exactly once with a single shared settings object.
export const omSettings = { ...defaultOmProtocolSettings };
let registered = false;
export const registerOmProtocol = (): void => {
if (registered) return;
registered = true;
maplibregl.addProtocol('om', (params, abortController) =>
omProtocol(params, abortController, omSettings)
);
};
const pad = (n: number): string => String(n).padStart(2, '0');
/** Model-run directory, e.g. `2026/07/25/0600Z` (UTC). */
export const fmtModelRun = (run: Date): string =>
`${run.getUTCFullYear()}/${pad(run.getUTCMonth() + 1)}/${pad(run.getUTCDate())}/${pad(
run.getUTCHours()
)}${pad(run.getUTCMinutes())}Z`;
/** Forecast-time filename stem, e.g. `2026-07-25T1400` (UTC). */
export const fmtValidTime = (t: Date): string =>
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}T${pad(
t.getUTCHours()
)}${pad(t.getUTCMinutes())}`;
/** Plain https `.om` URL for a single forecast step. */
export const omFileUrl = (domain: string, run: Date, time: Date): string =>
`${BASE}/data_spatial/${domain}/${fmtModelRun(run)}/${fmtValidTime(time)}.om`;
/**
* `om://` source URL for one forecast step of a given variable. The same URL
* serves both a raster field and (with `arrows`) a vector `wind-arrows` layer;
* MapLibre asks the protocol for image vs vector tiles per source type.
*/
export const omSourceUrl = (
domain: string,
run: Date,
time: Date,
variable: string,
dark: boolean,
arrows = false
): string =>
`om://${omFileUrl(domain, run, time)}?variable=${variable}${arrows ? '&arrows=true' : ''}${
dark ? '&dark=true' : ''
}`;
/** Ordered, de-duplicated domain candidates for a model, then global fallbacks. */
const domainCandidates = (model: string): string[] => {
const seen = new Set<string>();
const out: string[] = [];
for (const d of [...(modelDomainCandidates[model] ?? [model]), ...GLOBAL_FALLBACKS]) {
if (!seen.has(d)) {
seen.add(d);
out.push(d);
}
}
return out;
};
const metaCache = new Map<string, Promise<DomainMetaDataJson | null>>();
const fetchLatestMeta = (
domain: string,
signal?: AbortSignal
): Promise<DomainMetaDataJson | null> => {
let pending = metaCache.get(domain);
if (!pending) {
pending = fetch(`${BASE}/data_spatial/${domain}/latest.json`, { signal })
.then((r) => (r.ok ? (r.json() as Promise<DomainMetaDataJson>) : null))
.catch(() => null);
metaCache.set(domain, pending);
// short TTL so a fresh model run is picked up without a reload
setTimeout(() => metaCache.delete(domain), 60_000);
}
return pending;
};
export interface ResolvedSource {
/** The maps domain we actually render (may be a global fallback). */
domain: string;
/** Model run of this domain's latest data. */
modelRun: Date;
/** Forecast steps as Dates, ascending. */
times: Date[];
/** True when we fell back off the user's selected model. */
fellBack: boolean;
}
export interface ResolveOptions {
signal?: AbortSignal;
/**
* Drop the first timestep. Accumulation variables (e.g. precipitation, an
* hourly sum) have no value at the analysis time; instantaneous variables
* (e.g. wind) do, so leave this off for them.
*/
skipFirst?: boolean;
}
/**
* Resolve the selected model to a renderable source for `variable`. Probes each
* candidate domain's `latest.json` in order and returns the first that serves
* the variable. Global fallbacks are appended so there is (almost) always a
* result.
*/
export const resolveSource = async (
model: string,
variable: string,
opts: ResolveOptions = {}
): Promise<ResolvedSource | null> => {
const { signal, skipFirst } = opts;
const candidates = domainCandidates(model);
const preferred = new Set(modelDomainCandidates[model] ?? [model]);
for (const domain of candidates) {
const meta = await fetchLatestMeta(domain, signal);
if (signal?.aborted) return null;
if (!meta || !meta.variables?.includes(variable) || !meta.valid_times?.length) continue;
const sorted = meta.valid_times
.map((s) => new Date(s))
.filter((d) => !isNaN(d.getTime()))
.sort((a, b) => a.getTime() - b.getTime());
const times = skipFirst && sorted.length > 1 ? sorted.slice(1) : sorted;
if (!times.length) continue;
return {
domain,
modelRun: new Date(meta.reference_time),
times,
fellBack: !preferred.has(domain)
};
}
return null;
};
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { PREMIUM_PERKS, SIGNUP_URL, getPremiumPrice } from './config';
import { isPremium, premiumState, refreshPremium } from './premium';
interface Props {
/** Short feature name shown in the locked panel headline. */
feature?: string;
children: import('svelte').Snippet;
}
let { feature = 'This page', children }: Props = $props();
let unlockOpen = $state(false);
const price = getPremiumPrice();
// Re-verify the stored key whenever the gate mounts.
onMount(refreshPremium);
// Show a brief spinner only when we have no cached answer yet and are checking.
let initialChecking = $derived($premiumState.status === 'checking' && !$isPremium);
</script>
{#if $isPremium}
{@render children()}
{:else if initialChecking}
<div class="flex items-center justify-center py-24 text-sm text-muted-foreground">
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Checking your subscription…
</div>
{:else}
<div
class="mx-auto max-w-xl rounded-2xl border border-border bg-card px-6 py-10 text-center shadow-sm"
>
<div
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
>
<!-- padlock -->
<svg
class="h-7 w-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
</div>
<h2 class="text-xl font-bold tracking-tight">{feature} is a premium feature</h2>
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
Support this open-source weather project and unlock the extras from {price}.
</p>
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
{#each PREMIUM_PERKS as perk (perk)}
<li class="flex items-start gap-2.5 text-sm">
<svg
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M20 6 9 17l-5-5" />
</svg>
<span>{perk}</span>
</li>
{/each}
</ul>
<div class="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<a
href={SIGNUP_URL}
target="_blank"
rel="noopener"
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
>
Subscribe
</a>
<button
type="button"
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
onclick={() => (unlockOpen = true)}
>
I have a key
</button>
</div>
{#if $premiumState.status === 'invalid'}
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
Your saved key is no longer valid or has expired.
</p>
{/if}
</div>
{/if}
<UnlockDialog bind:open={unlockOpen} />
-45
View File
@@ -1,45 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import UnlockDialog from './UnlockDialog.svelte';
import { isPremium, refreshPremium } from './premium';
let open = $state(false);
// Verify once on load so the badge reflects real status site-wide.
onMount(refreshPremium);
</script>
<button
type="button"
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border transition-colors {$isPremium
? 'border-amber-400/50 bg-amber-400/10 text-amber-700 hover:bg-amber-400/20 dark:text-amber-300'
: 'border-border/70 text-muted-foreground hover:bg-muted hover:text-foreground'}"
onclick={() => (open = true)}
title={$isPremium ? 'Premium active' : 'Unlock premium'}
aria-label={$isPremium ? 'Premium active' : 'Unlock premium'}
>
{#if $isPremium}
<!-- star -->
<svg class="h-4.5 w-4.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.9 6.1 20.5l1.2-6.5L2.5 9.4l6.6-.9L12 2.5z"
/>
</svg>
{:else}
<!-- padlock -->
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
aria-hidden="true"
>
<rect x="4" y="10" width="16" height="11" rx="2" />
<path stroke-linecap="round" d="M8 10V7a4 4 0 0 1 8 0v3" />
</svg>
{/if}
</button>
<UnlockDialog bind:open />
-140
View File
@@ -1,140 +0,0 @@
<script lang="ts">
import { formatZoned } from '$lib/utils/date';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { SIGNUP_URL, getPremiumPrice } from './config';
import { clearLicense, isPremium, premiumState, storedLicenseKey, verifyKey } from './premium';
interface Props {
open?: boolean;
}
let { open = $bindable(false) }: Props = $props();
const price = getPremiumPrice();
// Prefill with the stored key so an existing subscriber sees their key.
let keyInput = $state('');
let submitting = $state(false);
let localError = $state<string | null>(null);
$effect(() => {
if (open) {
keyInput = $storedLicenseKey;
localError = null;
}
});
async function submit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
localError = null;
const result = await verifyKey(keyInput);
submitting = false;
if (result.valid) {
// close shortly so the success state is visible for a beat
setTimeout(() => (open = false), 700);
} else {
localError = result.error ?? 'That key is not valid or has expired.';
}
}
function removeKey() {
clearLicense();
keyInput = '';
}
let expiresLabel = $derived.by(() => {
const exp = $premiumState.expires;
if ($premiumState.status !== 'valid') return null;
if (!exp) return 'Lifetime access';
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
});
</script>
<Dialog.Root bind:open>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>Drizz.li Premium</Dialog.Title>
<Dialog.Description>
Paste the access key from your subscription email to unlock premium pages.
</Dialog.Description>
</Dialog.Header>
{#if $isPremium && $premiumState.status === 'valid'}
<div
class="flex items-start gap-3 rounded-lg border border-emerald-300/60 bg-emerald-50 px-3.5 py-3 text-sm text-emerald-800 dark:border-emerald-800/50 dark:bg-emerald-950/30 dark:text-emerald-200"
>
<svg
class="mt-0.5 h-5 w-5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="9" />
</svg>
<div>
<p class="font-semibold">Premium is active</p>
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
</div>
</div>
{/if}
<form onsubmit={submit} class="grid gap-3">
<div class="grid gap-1.5">
<Label for="license-key">Access key</Label>
<Input
id="license-key"
bind:value={keyInput}
placeholder="DRZ-XXXX-XXXX-XXXX"
autocomplete="off"
spellcheck={false}
class="font-mono tracking-wide"
/>
</div>
{#if localError}
<p class="text-sm text-destructive">{localError}</p>
{:else if $premiumState.status === 'error'}
<p class="text-sm text-destructive">
Couldn't reach the server. Check your connection and try again.
</p>
{/if}
<Button type="submit" disabled={submitting || !keyInput.trim()}>
{#if submitting}Verifying…{:else}Unlock{/if}
</Button>
</form>
<Dialog.Footer class="flex-col items-stretch gap-2 sm:flex-col sm:items-stretch">
{#if $isPremium}
<button
type="button"
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={removeKey}
>
Remove key from this device
</button>
{:else}
<p class="text-center text-xs text-muted-foreground">
No key yet?
<a
href={SIGNUP_URL}
target="_blank"
rel="noopener"
class="font-semibold text-primary underline-offset-2 hover:underline"
>
Subscribe from {price}
</a>
</p>
{/if}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
-67
View File
@@ -1,67 +0,0 @@
/**
* Paywall configuration.
*
* The frontend stays fully static and open source; the only server piece is the
* tiny `drizzli-paywall` verify API (a separate, self-hosted repo). Point the
* build at your deployment with the `VITE_PAYWALL_*` env vars (e.g. in a
* `.env` file), otherwise the sensible drizz.li defaults are used.
*/
const env = import.meta.env as Record<string, string | undefined>;
const stripTrailingSlash = (url: string): string => url.replace(/\/+$/, '');
/** Base URL of the self-hosted verify API (drizzli-paywall). */
export const PAYWALL_API_BASE = stripTrailingSlash(
env.VITE_PAYWALL_API_BASE ?? 'https://paywall.drizz.li'
);
/** Where prospective subscribers go to sign up (the paywall repo's signup form). */
export const SIGNUP_URL = env.VITE_PAYWALL_SIGNUP_URL ?? `${PAYWALL_API_BASE}/`;
// ─── Location-based pricing ──────────────────────────────────────────────────
// The signup form charges 3 in the visitor's currency (EUR / USD / CHF), picked
// from their location. Mirror that here so the paywall copy matches. Detection
// is timezone/locale based (no network geo lookup) and guarded for SSR.
const CURRENCY_SYMBOL: Record<string, string> = { EUR: '€', USD: '$', CHF: 'CHF' };
const US_ZONES =
/^America\/(New_York|Detroit|Chicago|Denver|Boise|Phoenix|Los_Angeles|Anchorage|Adak|Juneau|Sitka|Nome|Yakutat|Menominee|Indiana|Kentucky|North_Dakota)/;
export type PremiumCurrency = 'EUR' | 'USD' | 'CHF';
export function detectCurrency(): PremiumCurrency {
if (typeof Intl === 'undefined') return 'EUR';
let tz = '';
try {
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
/* ignore */
}
if (/Zurich|Vaduz/.test(tz)) return 'CHF';
let region = '';
try {
if (typeof navigator !== 'undefined') {
region = new Intl.Locale(navigator.language).maximize().region || '';
}
} catch {
/* ignore */
}
if (region === 'CH') return 'CHF';
if (region === 'US' || US_ZONES.test(tz)) return 'USD';
return 'EUR';
}
/** Display price for the paywall panel, e.g. "€3 / month" or "CHF 3 / month". */
export function getPremiumPrice(): string {
if (env.VITE_PREMIUM_PRICE) return env.VITE_PREMIUM_PRICE;
const currency = detectCurrency();
const symbol = CURRENCY_SYMBOL[currency];
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
}
/** Short, human list of what premium unlocks (shown on the locked panel). */
export const PREMIUM_PERKS = [
'Historical weather & climate-normal comparisons',
'New premium features as they land'
];
-129
View File
@@ -1,129 +0,0 @@
/**
* 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' });
}
+27
View File
@@ -0,0 +1,27 @@
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';
+27 -353
View File
@@ -13,7 +13,7 @@
import { Unit } from '@openmeteo/sdk/unit';
import { fetchWeatherApi } from 'openmeteo';
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
import { buildDaylightMarkAreas } from '$lib/utils/echarts';
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
@@ -22,7 +22,6 @@ import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
const ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
// ─── Core Helpers ───────────────────────────────────────────────────────────────
@@ -158,7 +157,7 @@ export interface WeatherUnitParams {
precipitation_unit?: 'mm' | 'inch';
}
export type { DaylightBand };
export type MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }];
// ─── Week Forecast Types ────────────────────────────────────────────────────────
@@ -166,8 +165,6 @@ export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
model?: string;
forecast_days?: number;
past_days?: number;
/** Hourly API variables to request; defaults to the full core set */
hourlyVariables?: string[];
}
export interface WeekHourlyData {
@@ -181,19 +178,6 @@ export interface WeekHourlyData {
relative_humidity_2m: number[];
apparent_temperature: 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 {
@@ -217,7 +201,7 @@ export interface WeekForecastResult {
hourlyTimestamps: number[];
hourlyDates: Date[];
dailyDates: Date[];
daylightBands: DaylightBand[];
markAreas: MarkArea[];
}
// ─── Model Comparison Types ─────────────────────────────────────────────────────
@@ -237,7 +221,7 @@ export interface ModelCompareResult {
timestamps: number[];
utcOffsetSeconds: number;
timezone: string;
daylightBands: DaylightBand[];
markAreas: MarkArea[];
sunrise: number[];
sunset: number[];
units: Record<string, string>;
@@ -267,7 +251,7 @@ export interface EnsembleForecastResult {
timestamps: number[];
utcOffsetSeconds: number;
timezone: string;
daylightBands: DaylightBand[];
markAreas: MarkArea[];
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
hourlyFlat: Record<string, number[]>;
hourlyUnitsFlat: Record<string, string>;
@@ -275,9 +259,6 @@ export interface EnsembleForecastResult {
// ─── 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 = [
'temperature_2m',
'precipitation',
@@ -304,40 +285,6 @@ const WEEK_DAILY_VARS = [
'wind_direction_10m_dominant'
] as const;
/**
* Assembles a WeekHourlyData structure from a name→values map, so variables that
* were not requested resolve to empty arrays. Shared by the week and historical
* fetchers (both return the same hourly shape, which the meteograms and hourly
* table consume).
*/
function weekHourlyFromByName(byName: Record<string, number[]>): WeekHourlyData {
const g = (name: string): number[] => byName[name] ?? [];
return {
temperature_2m: g('temperature_2m'),
precipitation: g('precipitation'),
precipitation_probability: g('precipitation_probability'),
weather_code: g('weather_code'),
windspeed_10m: g('wind_speed_10m'),
winddirection_10m: g('wind_direction_10m'),
cloud_cover: g('cloud_cover'),
relative_humidity_2m: g('relative_humidity_2m'),
apparent_temperature: g('apparent_temperature'),
dew_point_2m: g('dew_point_2m'),
wind_gusts_10m: g('wind_gusts_10m'),
pressure_msl: g('pressure_msl'),
surface_pressure: g('surface_pressure'),
rain: g('rain'),
showers: g('showers'),
snowfall: g('snowfall'),
cloud_cover_low: g('cloud_cover_low'),
cloud_cover_mid: g('cloud_cover_mid'),
cloud_cover_high: g('cloud_cover_high'),
uv_index: g('uv_index'),
visibility: g('visibility'),
cape: g('cape')
};
}
/**
* Fetches the 7-day (week) weather forecast for a single location and model.
* Returns typed hourly and daily data structures.
@@ -347,16 +294,10 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const pastDays = params.past_days ?? 0;
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> = {
latitude: params.latitude,
longitude: params.longitude,
hourly: hourlyVars.join(','),
hourly: WEEK_HOURLY_VARS.join(','),
daily: WEEK_DAILY_VARS.join(','),
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
@@ -387,15 +328,18 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
const hourlyTimestamps = getTimestamps(hourlyBlock);
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
// Values come back in the requested order; index them by API name so
// variables that were not requested resolve to empty arrays.
const byName: Record<string, number[]> = {};
hourlyVars.forEach((name, i) => {
const variable = hourlyBlock.variables(i);
byName[name] = variable ? getValues(variable) : [];
});
const hourly = weekHourlyFromByName(byName);
const hourly: WeekHourlyData = {
temperature_2m: getValues(hourlyBlock.variables(0)!),
precipitation: getValues(hourlyBlock.variables(1)!),
precipitation_probability: getValues(hourlyBlock.variables(2)!),
weather_code: getValues(hourlyBlock.variables(3)!),
windspeed_10m: getValues(hourlyBlock.variables(4)!),
winddirection_10m: getValues(hourlyBlock.variables(5)!),
cloud_cover: getValues(hourlyBlock.variables(6)!),
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
apparent_temperature: getValues(hourlyBlock.variables(8)!),
dew_point_2m: getValues(hourlyBlock.variables(9)!)
};
// Daily: variables are in the same order as WEEK_DAILY_VARS
const dailyDates = getDates(dailyBlock);
@@ -416,7 +360,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
};
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
const markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
return {
hourly,
@@ -426,7 +370,7 @@ export async function fetchWeekForecast(params: WeekForecastParams): Promise<Wee
hourlyTimestamps,
hourlyDates,
dailyDates,
daylightBands
markAreas
};
}
@@ -465,7 +409,7 @@ export async function fetchModelComparison(
const timestamps = getTimestamps(hourlyBlock);
// Extract sunrise/sunset from the first response's daily block
let daylightBands: DaylightBand[] = [];
let markAreas: MarkArea[] = [];
let sunrise: number[] = [];
let sunset: number[] = [];
const dailyBlock = firstResponse.daily();
@@ -474,7 +418,7 @@ export async function fetchModelComparison(
const sunsetVar = dailyBlock.variables(1)!;
sunrise = getInt64Values(sunriseVar);
sunset = getInt64Values(sunsetVar);
daylightBands = buildDaylightBands(sunrise, sunset);
markAreas = buildDaylightMarkAreas(sunrise, sunset);
}
// Process each model's response
@@ -530,7 +474,7 @@ export async function fetchModelComparison(
timestamps,
utcOffsetSeconds,
timezone,
daylightBands,
markAreas,
sunrise,
sunset,
units,
@@ -587,15 +531,15 @@ export async function fetchEnsembleForecast(
const timestamps = getTimestamps(hourlyBlock);
const timeLength = timestamps.length;
// Extract sunrise/sunset for daylight bands
let daylightBands: DaylightBand[] = [];
// Extract sunrise/sunset for mark areas
let markAreas: MarkArea[] = [];
if (dailyResponses.length > 0) {
const dailyResponse = dailyResponses[0];
const dailyBlock = dailyResponse.daily();
if (dailyBlock) {
const sunrise = getInt64Values(dailyBlock.variables(0)!);
const sunset = getInt64Values(dailyBlock.variables(1)!);
daylightBands = buildDaylightBands(sunrise, sunset);
markAreas = buildDaylightMarkAreas(sunrise, sunset);
}
}
@@ -685,7 +629,7 @@ export async function fetchEnsembleForecast(
timestamps,
utcOffsetSeconds,
timezone,
daylightBands,
markAreas,
hourlyFlat,
hourlyUnitsFlat
};
@@ -814,273 +758,3 @@ function modelEnumToString(modelEnum: number): string {
};
return modelMap[modelEnum] ?? `model_${modelEnum}`;
}
// ─── Historical (Archive) Types ─────────────────────────────────────────────
export interface HistoricalDailyData {
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
temperature_2m_mean: number[];
apparent_temperature_max: number[];
apparent_temperature_min: number[];
sunrise: number[];
sunset: number[];
sunshine_duration: number[];
precipitation_sum: number[];
rain_sum: number[];
snowfall_sum: number[];
precipitation_hours: number[];
windspeed_10m_max: number[];
windgusts_10m_max: number[];
winddirection_10m_dominant: number[];
}
export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitParams {
/** Inclusive range, YYYY-MM-DD (location-local dates). */
start_date: string;
end_date: string;
/** Hourly API variables to request; defaults to the core week set. */
hourlyVariables?: string[];
}
export interface HistoricalForecastResult {
hourly: WeekHourlyData;
daily: HistoricalDailyData;
utcOffsetSeconds: number;
timezone: string;
hourlyTimestamps: number[];
hourlyDates: Date[];
dailyDates: Date[];
daylightBands: DaylightBand[];
}
// Requested in this exact order; the daily block returns variables positionally.
const HISTORICAL_DAILY_VARS = [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'temperature_2m_mean',
'apparent_temperature_max',
'apparent_temperature_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'rain_sum',
'snowfall_sum',
'precipitation_hours',
'wind_speed_10m_max',
'wind_gusts_10m_max',
'wind_direction_10m_dominant'
] as const;
// ─── Historical (Archive) Fetch ─────────────────────────────────────────────
/**
* Fetches reanalysis (ERA5) weather for a past date range from the Open-Meteo
* archive API. Returns the same hourly shape as the week forecast (so the
* existing meteograms and hourly table render it unchanged) plus a richer daily
* block for the climate/statistics view.
*/
export async function fetchHistoricalWeather(
params: HistoricalForecastParams
): Promise<HistoricalForecastResult> {
const hourlyVars =
params.hourlyVariables && params.hourlyVariables.length > 0
? [...new Set(params.hourlyVariables)]
: [...WEEK_HOURLY_VARS];
const apiParams: Record<string, string | number | undefined> = {
latitude: params.latitude,
longitude: params.longitude,
start_date: params.start_date,
end_date: params.end_date,
hourly: hourlyVars.join(','),
daily: HISTORICAL_DAILY_VARS.join(','),
temperature_unit: params.temperature_unit ?? 'celsius',
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: params.timezone
};
const cleanParams: Record<string, string> = {};
for (const [key, value] of Object.entries(apiParams)) {
if (value !== undefined) cleanParams[key] = String(value);
}
const responses = await fetchWeatherApi(ARCHIVE_URL, cleanParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
const hourlyBlock = response.hourly()!;
const dailyBlock = response.daily()!;
const hourlyTimestamps = getTimestamps(hourlyBlock);
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
const byName: Record<string, number[]> = {};
hourlyVars.forEach((name, i) => {
const variable = hourlyBlock.variables(i);
byName[name] = variable ? getValues(variable) : [];
});
const hourly = weekHourlyFromByName(byName);
// Daily variables come back in HISTORICAL_DAILY_VARS order.
const dailyDates = getDates(dailyBlock);
const d = (i: number): number[] => {
const v = dailyBlock.variables(i);
return v ? getValues(v) : [];
};
const sunrise = getInt64Values(dailyBlock.variables(6)!);
const sunset = getInt64Values(dailyBlock.variables(7)!);
const daily: HistoricalDailyData = {
weather_code: d(0),
temperature_2m_max: d(1),
temperature_2m_min: d(2),
temperature_2m_mean: d(3),
apparent_temperature_max: d(4),
apparent_temperature_min: d(5),
sunrise,
sunset,
sunshine_duration: d(8),
precipitation_sum: d(9),
rain_sum: d(10),
snowfall_sum: d(11),
precipitation_hours: d(12),
windspeed_10m_max: d(13),
windgusts_10m_max: d(14),
winddirection_10m_dominant: d(15)
};
const daylightBands = buildDaylightBands(sunrise, sunset);
return {
hourly,
daily,
utcOffsetSeconds,
timezone,
hourlyTimestamps,
hourlyDates,
dailyDates,
daylightBands
};
}
// ─── Climate Normals ────────────────────────────────────────────────────────
export interface ClimateNormals {
/** Indexed by day-of-year ordinal 1..366 (index 0 unused); NaN where no data. */
tmax: number[];
tmin: number[];
tmean: number[];
/** Mean daily precipitation (per calendar day). */
precip: number[];
baseStart: string;
baseEnd: string;
temperature_unit: string;
precipitation_unit: string;
}
export interface ClimateNormalsParams extends WeatherLocation, WeatherUnitParams {
/** Baseline period; defaults to the 1991-2020 WMO normal period. */
baseStart?: string;
baseEnd?: string;
}
// Days before the first of each month in a leap reference year, so that a
// (month, day) pair maps to a stable 1..366 ordinal regardless of leap years.
const CUM_DAYS_LEAP = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
/** Day-of-year ordinal (1..366) from month (1-12) and day-of-month (1-31). */
export function monthDayToOrdinal(month: number, day: number): number {
const m = Math.min(12, Math.max(1, Math.round(month)));
return CUM_DAYS_LEAP[m - 1] + day;
}
/**
* Computes daily climate normals for a location by averaging a multi-decade
* archive across years, per day-of-year, with a ±7-day smoothing window so the
* curve is stable. One archive request; used for the "vs normal" comparison.
*/
export async function fetchClimateNormals(params: ClimateNormalsParams): Promise<ClimateNormals> {
const baseStart = params.baseStart ?? '1991-01-01';
const baseEnd = params.baseEnd ?? '2020-12-31';
// UTC keeps the day-of-year bucketing exact (no offset spill across midnight);
// timezone is irrelevant to a per-calendar-day normal.
const apiParams: Record<string, string> = {
latitude: String(params.latitude),
longitude: String(params.longitude),
start_date: baseStart,
end_date: baseEnd,
daily: 'temperature_2m_max,temperature_2m_min,temperature_2m_mean,precipitation_sum',
temperature_unit: params.temperature_unit ?? 'celsius',
precipitation_unit: params.precipitation_unit ?? 'mm',
timezone: 'UTC'
};
const responses = await fetchWeatherApi(ARCHIVE_URL, apiParams);
const response = responses[0];
const dailyBlock = response.daily()!;
const dates = getDates(dailyBlock);
const tmaxV = getValues(dailyBlock.variables(0)!);
const tminV = getValues(dailyBlock.variables(1)!);
const tmeanV = getValues(dailyBlock.variables(2)!);
const precipV = getValues(dailyBlock.variables(3)!);
const N = 367; // ordinals 1..366
const mk = () => ({ sum: new Array<number>(N).fill(0), cnt: new Array<number>(N).fill(0) });
const acc = { tmax: mk(), tmin: mk(), tmean: mk(), precip: mk() };
const add = (bucket: { sum: number[]; cnt: number[] }, ord: number, val: number) => {
if (Number.isFinite(val)) {
bucket.sum[ord] += val;
bucket.cnt[ord] += 1;
}
};
for (let i = 0; i < dates.length; i++) {
const dt = dates[i];
const ord = monthDayToOrdinal(dt.getUTCMonth() + 1, dt.getUTCDate());
add(acc.tmax, ord, tmaxV[i]);
add(acc.tmin, ord, tminV[i]);
add(acc.tmean, ord, tmeanV[i]);
add(acc.precip, ord, precipV[i]);
}
const mean = (bucket: { sum: number[]; cnt: number[] }): number[] =>
bucket.sum.map((s, i) => (bucket.cnt[i] > 0 ? s / bucket.cnt[i] : NaN));
// Circular ±window smoothing across the 366 ordinals (skips empty days).
const smooth = (arr: number[], window = 7): number[] => {
const out = new Array<number>(N).fill(NaN);
for (let o = 1; o <= 366; o++) {
let s = 0;
let c = 0;
for (let k = -window; k <= window; k++) {
const idx = ((o - 1 + k + 366) % 366) + 1;
const v = arr[idx];
if (Number.isFinite(v)) {
s += v;
c++;
}
}
out[o] = c > 0 ? s / c : NaN;
}
return out;
};
return {
tmax: smooth(mean(acc.tmax)),
tmin: smooth(mean(acc.tmin)),
tmean: smooth(mean(acc.tmean)),
precip: smooth(mean(acc.precip)),
baseStart,
baseEnd,
temperature_unit: params.temperature_unit ?? 'celsius',
precipitation_unit: params.precipitation_unit ?? 'mm'
};
}
-93
View File
@@ -43,96 +43,3 @@ export const defaultLocation: 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 -1
View File
@@ -1,5 +1,5 @@
import { isSameDay as isSameDayDateFns } from 'date-fns';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
import { isSameDay as isSameDayDateFns } from 'date-fns';
/**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
+321
View File
@@ -0,0 +1,321 @@
/**
* 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);
});
}
+71
View File
@@ -0,0 +1,71 @@
/**
* 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';
+435
View File
@@ -0,0 +1,435 @@
/**
* 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);
}
+349
View File
@@ -0,0 +1,349 @@
/**
* 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 '';
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 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;
}
+46 -65
View File
@@ -1,21 +1,13 @@
import { error, redirect } from '@sveltejs/kit';
import type { GeoLocation } from '$lib/stores/settings';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
export const geoLocationNameToRoute = (name: string) => {
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
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 {
// 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);
if (location.population && location.population > 543000) {
return locationRoute;
@@ -23,9 +15,30 @@ export function buildLocationRoute(location: GeoLocation): string {
return locationRoute + '_' + location.id;
}
export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
interface ResolveLocationOptions {
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,
name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
name: `${latitude}N° ${longitude}E°`,
latitude,
longitude,
elevation: 0,
@@ -42,41 +55,8 @@ export const coordinateLocation = (latitude: number, longitude: number): GeoLoca
admin1: undefined,
admin3: 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;
};
}
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]));
}
} else {
let urlLocationName: string;
let urlLocationId: string | undefined;
@@ -84,44 +64,45 @@ export async function resolveLocationFromRoute({
const split = urlLocation.split('_');
urlLocationName = split[0];
urlLocationId = split[1];
} else if (urlLocation.includes('-')) {
urlLocationName = urlLocation.replace(/-/g, ' ');
} else if (/^\d+$/.test(urlLocation)) {
urlLocationName = '';
urlLocationId = urlLocation;
} else {
urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : urlLocation;
urlLocationName = urlLocation;
urlLocationId = undefined;
}
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)) {
if (urlLocationId) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
);
if (!res.ok) error(404, 'Location not found');
const candidate = await res.json();
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
location = await res.json();
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
);
if (!res.ok) error(404, 'Location not found');
const geocodingResponse = await res.json();
const candidate = geocodingResponse?.results?.[0];
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
if (geocodingResponse.results) {
location = geocodingResponse.results[0];
} else {
error(404, 'Location not found');
}
}
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
// every path with a trailing slash. Match that here or the equality check
// never holds and the redirect loops forever.
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
const locationRoute = geoLocationNameToRoute(location.name);
const canonicalSuffix =
location.population && location.population > 543000
? locationRoute
: locationRoute + '_' + location.id;
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
}
}
storedLocation.set(location);
return location;
}
-80
View File
@@ -1,80 +0,0 @@
/**
* 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
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+2 -39
View File
@@ -1,10 +1,4 @@
<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 WeatherNav from '$lib/components/navigation/weather-nav.svelte';
@@ -14,23 +8,6 @@
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 mobileMenuOpen = $state(false);
@@ -65,14 +42,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/30"
transition:fade={{ duration: 150 }}
onclick={closeMobileMenu}
onkeydown={closeMobileMenu}
></div>
<div
class="relative z-1 h-full w-55 shadow-lg"
transition:fly={{ x: -220, duration: 200, opacity: 1 }}
>
<div class="relative z-1 h-full w-55 shadow-lg">
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
</div>
</div>
@@ -82,18 +55,8 @@
<div class="flex min-w-0 flex-1 flex-col h-full">
<Header onMenuToggle={toggleMobileMenu} />
<main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-3 lg:px-8 lg:py-6'}
>
{#if fullBleed}
<main class="flex-1 overflow-y-auto p-5 md:px-8 md:py-6">
{@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>
</div>
</div>
-5
View File
@@ -1,5 +0,0 @@
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 -1
View File
@@ -1,3 +1,3 @@
<svelte:head>
<title>Drizzli</title>
<title>Open-Meteo Weather</title>
</svelte:head>
+2 -10
View File
@@ -18,8 +18,8 @@
--secondary-foreground: oklch(0.25 0.02 60);
--muted: oklch(0.96 0.008 85);
--muted-foreground: oklch(0.5 0.02 60);
--accent: oklch(0.95 0.02 70);
--accent-foreground: oklch(0.25 0.02 60);
--accent: oklch(0.96 0.008 85);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.01 80);
--input: oklch(0.91 0.01 80);
@@ -118,14 +118,6 @@
}
@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;
}
+1 -1
View File
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
render(Page);
const title = document.querySelector('title');
expect(title?.textContent).toBe('Drizz.li');
expect(title?.textContent).toBe('Open-Meteo Weather');
});
});
+13
View File
@@ -0,0 +1,13 @@
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
};
};
+13
View File
@@ -0,0 +1,13 @@
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
};
};
-23
View File
@@ -1,23 +0,0 @@
<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>
+15
View File
@@ -0,0 +1,15 @@
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;
+115 -282
View File
@@ -1,61 +1,53 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { onDestroy, onMount } from 'svelte';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import {
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 { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import {
type DaylightBand,
type EnsembleForecastResult,
type MarkArea,
fetchEnsembleForecast
} from '$lib/services/weather';
import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import { defaultParameters } from '../../options';
import type { PageData } from './$types';
const CHART_GROUP = '14-day-ensemble';
import type * as echarts from 'echarts';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(true);
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: CanvasChart[] = $state([]);
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let { data }: { data: PageData } = $props();
// 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 location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'precipitation', 'wind_speed_10m', 'cloud_cover', 'pressure_msl'],
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;
hourly: ['temperature_2m'],
models: ['gfs_seamless']
});
// ─── Cached API Response ────────────────────────────────────────────────────
@@ -64,7 +56,7 @@
ensembleResult: EnsembleForecastResult;
timestamps: number[];
timezone: string;
daylightBands: DaylightBand[];
markAreas: MarkArea[];
}
let fetchedData: FetchedData | null = $state(null);
@@ -72,13 +64,20 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
// preselect the persisted ensemble model (client-only, keeps SSR stable)
params.models = [get(storedEnsembleModel)];
mounted = true;
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// ─── Data Fetching (only when params.hourly or params.models change) ───────
@@ -90,12 +89,12 @@
const loc = location;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
const loadData = async () => {
loading = true;
loadError = null;
chartInstances = [];
chartComponents = [];
fetchEnsembleForecast({
const result: EnsembleForecastResult = await fetchEnsembleForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: hourlyVars,
@@ -105,287 +104,121 @@
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((result: EnsembleForecastResult) => {
if (version !== requestVersion) return;
});
fetchedData = {
ensembleResult: result,
timestamps: result.timestamps,
timezone: result.timezone,
daylightBands: result.daylightBands
markAreas: result.markAreas
};
console.log(fetchedData.timezone);
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
// Ensemble members stop at the model's horizon; past it the service collapses
// 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;
});
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.slice(0, validLength).map((t) => t / 1000) : []
);
// Surface a note when the chosen model's ensemble stops short of the request.
let fullHours = $derived.by(() => (fetchedData ? fetchedData.timestamps.length : 0));
let validDays = $derived(Math.max(0, Math.round(validLength / 24)));
let isTrimmed = $derived(fetchedData != null && validLength > 0 && validLength < fullHours - 1);
interface ChartDef {
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 [];
loadData();
});
const { ensembleResult } = fetchedData;
const variables = params.hourly || [];
const defs: ChartDef[] = [];
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
for (let vi = 0; vi < variables.length; vi++) {
const variable = variables[vi];
$effect(() => {
if (!fetchedData) return;
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const varData = ensembleResult.variables[variable];
if (!varData) continue;
const unit = varData.unit;
const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length;
const { average, min: minValues, max: maxValues } = varData;
// 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 series: Array<Record<string, unknown>> = [];
// Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [
{
name: 'Max',
type: 'line',
color: BAND_COLOR,
data: vMax,
width: 1,
fill: true,
fillOpacity: 0.25,
bandTo: vMin,
format: (v) => `${v.toFixed(1)} ${unit}`
},
{
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 spreadData: Array<[number, number, number]> = minValues.map(
(minVal, index) =>
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
);
series.push(...buildSpreadSeries({ variable, spreadData }));
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries());
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
];
const isFirst = vi === 0;
const isLast = vi === variables.length - 1;
const isLast = vi === variableCount - 1;
const axis = axisForVar(variable);
defs.push({
// each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
subtitle: isFirst
? `min · mean · max across ${memberCount} ensemble members`
: `min · mean · max (${unit})`,
unit,
showCredit: isLast,
const option = composeChartOption({
title: isFirst
? {
text: 'Model Spread',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit, timezone },
legend: {
show: _showLegend,
data: [variable + '_average']
},
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
toolbox: false,
showCredit: isLast,
colors,
timezone
});
newOptions.push(option);
}
return defs;
chartOptions = newOptions;
});
</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 ─────────────────────────────────────────────────────────── -->
{#if isTrimmed}
<div
class="mb-4 flex items-start gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3.5 py-2.5 text-sm text-amber-800 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-200"
<ChartContainer
{loading}
chartCount={params.hourly?.length || 0}
chartHeight={showLegend ? 400 : 300}
>
<svg
class="mt-0.5 h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"
/>
</svg>
<span>
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
trimmed to its available range.
</span>
</div>
{/if}
{#if loadError}
<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
{#each chartOptions as option, i (i)}
<EChart
{option}
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
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}
</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}
</ChartContainer>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
{#snippet controls()}
<div class="flex items-center gap-2">
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
-23
View File
@@ -1,23 +0,0 @@
<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>
+15
View File
@@ -0,0 +1,15 @@
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;
+120 -279
View File
@@ -1,42 +1,39 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import {
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightSeries,
buildModelSeries,
calculateAverage,
composeChartOption,
findUnit,
getThemeColors
} from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
CHART_COLORS,
CanvasChart,
type ChartSeries,
SERIES_COLORS,
calculateAverage,
findUnit,
groupRange,
isColumnUnit
} from '$lib/charts';
import {
type DaylightBand,
type MarkArea,
type ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
import { findModel, hourly, modelGroups } from '../../options';
import { hourly, models as modelsFlat } from '../../options';
import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
import type * as echarts from 'echarts';
const models = modelGroups.map((group) => group.models);
const CHART_GROUP = 'model-compare';
const models = [modelsFlat];
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
@@ -44,34 +41,35 @@
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: CanvasChart[] = $state([]);
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
let { data }: { data: PageData } = $props();
// 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 location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'rain', 'wind_speed_10m'],
models: ['ecmwf_ifs', 'meteofrance_seamless', 'ukmo_seamless', 'icon_seamless', 'gfs_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;
hourly: [
'temperature_2m',
'rain',
'relative_humidity_2m',
'wind_speed_10m',
'wind_direction_10m'
],
models: [
'ecmwf_ifs',
'ecmwf_ifs025',
'meteofrance_seamless',
'ukmo_seamless',
'icon_seamless',
'gem_seamless',
'gfs_seamless'
]
});
// ─── Cached API Response ────────────────────────────────────────────────────
@@ -80,7 +78,7 @@
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
timezone: string;
daylightBands: DaylightBand[];
markAreas: MarkArea[];
timestamps: number[];
sunrise: number[];
sunset: number[];
@@ -91,51 +89,21 @@
// ─── Lifecycle ──────────────────────────────────────────────────────────────
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;
});
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
// ─── Chart Instance Tracking ────────────────────────────────────────────────
const SECONDS_PER_DAY = 24 * 3600;
function dayStartSec(day: Date): number | null {
if (!fetchedData) return null;
const tz = fetchedData.timezone;
const target = formatZoned(day, tz, 'yyyy-MM-dd');
const idx = fetchedData.timestamps.findIndex(
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
);
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
if (start == null || liveCharts.length === 0) return;
// charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
liveCharts[0]?.resetRange();
}
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
@@ -146,12 +114,12 @@
const loc = location;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
const loadData = async () => {
loading = true;
loadError = null;
chartInstances = [];
chartComponents = [];
fetchModelComparison({
const result: ModelCompareResult = await fetchModelComparison({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
@@ -160,243 +128,116 @@
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch',
timezone: loc.timezone
})
.then((result: ModelCompareResult) => {
if (version !== requestVersion) return;
});
fetchedData = {
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
timezone: result.timezone,
daylightBands: result.daylightBands,
markAreas: result.markAreas,
timestamps: result.timestamps,
sunrise: result.sunrise,
sunset: result.sunset
};
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
);
interface ChartDef {
title?: string;
subtitle?: string;
unit: string;
showCredit: boolean;
series: ChartSeries[];
}
// Human labels for the compared variables (API names → readable title)
const VAR_LABELS: Record<string, string> = {
temperature_2m: 'Temperature',
apparent_temperature: 'Feels like',
dew_point_2m: 'Dew point',
precipitation: 'Precipitation',
rain: 'Rain',
showers: 'Showers',
snowfall: 'Snowfall',
wind_speed_10m: 'Wind speed',
wind_gusts_10m: 'Wind gusts',
relative_humidity_2m: 'Relative humidity',
cloud_cover: 'Cloud cover',
pressure_msl: 'Pressure (MSL)'
};
const varLabel = (v: string): string =>
VAR_LABELS[v] ?? v.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
loadData();
});
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
$effect(() => {
if (!fetchedData) return;
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
const variableCount = chartVariables.length;
const timeLength = timestamps.length;
const defs: ChartDef[] = [];
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = chartVariables[vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const isColumn = isColumnUnit(unit);
const series: ChartSeries[] = [];
const series: Array<Record<string, unknown>> = [];
let modelIndex = 0;
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
// strip the variable prefix and use the concise model label so the
// tooltip/legend stay readable (model ids are very long)
const modelId = model.slice(variable.length + 1);
series.push({
name: findModel(modelId)?.label ?? modelId,
type: isColumn ? 'bar' : 'line',
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
data: values as (number | null)[],
width: 2
});
modelIndex++;
const seriesData = (values as (number | null)[]).map(
(val, idx) => [timestamps[idx], val] as [number, number | null]
);
series.push(
buildModelSeries({
name: model,
data: seriesData,
unit
})
);
}
const { average } = calculateAverage(hourlyData, variable, timeLength);
series.push({
name: 'Average',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn,
outline: !isColumn
});
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries());
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
defs.push({
// label every chart with its variable so each is identifiable
title: varLabel(variable),
subtitle: isFirst
? `${params.models?.length ?? 0} models · dashed = average`
: `across ${params.models?.length ?? 0} models`,
unit,
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit, timezone },
legend: { show: _showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
toolbox: false,
showCredit: isLast,
series
colors,
timezone
});
newOptions.push(option);
}
return defs;
chartOptions = newOptions;
});
</script>
<!-- ─── Page hero: location (matches the other forecast pages) ──────────────── -->
<div class="relative mb-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 md:mb-5">
<div class="flex min-w-0 items-center gap-3">
<img
class="h-10 w-10 shrink-0 rounded-full shadow-sm ring-2 ring-border"
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
alt={location.country ?? ''}
/>
<div class="min-w-0">
<h1 class="truncate text-2xl leading-tight font-bold tracking-tight md:text-3xl">
{location.name}
</h1>
<p class="truncate text-sm text-muted-foreground">
<span class="lg:hidden"
>{#if location.admin1}{location.admin1},
{/if}{location.country ?? ''}<span class="mx-1 opacity-50">·</span></span
>Model comparison
</p>
</div>
</div>
<!-- Range / zoom controls, aligned with the title like the other pages -->
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
<span class="hidden text-xs text-muted-foreground lg:inline">
drag or
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
>
+ scroll to zoom
</span>
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
role="group"
aria-label="Chart time range"
>
{#each rangePresets as preset (preset.label)}
<button
type="button"
class="cursor-pointer rounded-md px-2.5 py-1 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
onclick={preset.apply}
>
{preset.label}
</button>
{/each}
</div>
</div>
</div>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
{#if loadError}
<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}
<!-- 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
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
{#each chartOptions as option, i (i)}
<EChart
{option}
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
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}
</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}
</ChartContainer>
{#if fetchedData && !loading}
<ModelPictogramTimeline
@@ -412,11 +253,11 @@
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="model-comparison">
<ChartToolbar charts={chartInstances} fileName="model-comparison">
{#snippet controls()}
<div class="flex items-center gap-2">
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
@@ -1,23 +0,0 @@
<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>
@@ -1,276 +0,0 @@
<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>
@@ -1,13 +0,0 @@
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 };
};
@@ -1,108 +0,0 @@
<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>
@@ -1,260 +0,0 @@
<script lang="ts">
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
import {
type ClimateNormals,
type HistoricalDailyData,
monthDayToOrdinal
} from '$lib/services/weather';
import { getColor, getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
interface Props {
daily: HistoricalDailyData;
dailyDates: Date[];
timezone: string;
units: WeatherUnits;
normals: ClimateNormals | null;
selectedDay: Date;
onSelectDay: (date: Date) => void;
}
let { daily, dailyDates, timezone, units, normals, selectedDay, onSelectDay }: Props = $props();
const tempUnit = $derived(getTempUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
// Day-of-year ordinal for each day, so we can look up its climate normal.
let ordinals = $derived(
dailyDates.map((d) =>
monthDayToOrdinal(
Number(formatZoned(d, timezone, 'M')),
Number(formatZoned(d, timezone, 'd'))
)
)
);
const finite = (v: number | undefined): v is number => v != null && Number.isFinite(v);
const mean = (xs: number[]): number =>
xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : NaN;
// ─── Range scaling for the min/max bars ─────────────────────────────────────
const BAR_H = 78;
let scale = $derived.by(() => {
const lo = Math.min(...daily.temperature_2m_min.filter(finite));
const hi = Math.max(...daily.temperature_2m_max.filter(finite));
const span = hi - lo || 1;
return { lo, hi, span };
});
const yOf = (v: number): number => (1 - (v - scale.lo) / scale.span) * BAR_H;
// ─── Summary statistics ─────────────────────────────────────────────────────
let stats = $derived.by(() => {
const means = daily.temperature_2m_mean.filter(finite);
const avg = mean(means);
// warmest / coldest day
let warm = { t: -Infinity, i: -1 };
let cold = { t: Infinity, i: -1 };
for (let i = 0; i < dailyDates.length; i++) {
const mx = daily.temperature_2m_max[i];
const mn = daily.temperature_2m_min[i];
if (finite(mx) && mx > warm.t) warm = { t: mx, i };
if (finite(mn) && mn < cold.t) cold = { t: mn, i };
}
const totalPrecip = daily.precipitation_sum.filter(finite).reduce((a, b) => a + b, 0);
const wetDays = daily.precipitation_sum.filter((p) => finite(p) && p >= 1).length;
// climate comparison (only when normals are available)
let tempAnomaly: number | null = null;
let normalPrecip: number | null = null;
if (normals) {
const normMeans: number[] = [];
let np = 0;
let npCount = 0;
for (let i = 0; i < ordinals.length; i++) {
const nm = normals.tmean[ordinals[i]];
if (finite(nm)) normMeans.push(nm);
const npv = normals.precip[ordinals[i]];
if (finite(npv)) {
np += npv;
npCount++;
}
}
if (normMeans.length && finite(avg)) tempAnomaly = avg - mean(normMeans);
if (npCount) normalPrecip = np;
}
return { avg, warm, cold, totalPrecip, wetDays, tempAnomaly, normalPrecip };
});
const fmtTemp = (v: number): string => (finite(v) ? `${v.toFixed(1)}°` : '');
const fmtSigned = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}°`;
const fmtPrecip = (v: number): string => `${v.toFixed(v < 10 ? 1 : 0)} ${precipUnit}`;
function anomalyColor(delta: number): string {
const a = Math.min(0.9, 0.25 + Math.abs(delta) / 12);
return delta >= 0 ? `rgba(220, 70, 60, ${a})` : `rgba(50, 110, 210, ${a})`;
}
</script>
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
<p class="mt-1 text-2xl font-bold tabular-nums">
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
>{tempUnit.replace('°', '')}</span
>
</p>
{#if stats.tempAnomaly != null}
<p
class="mt-0.5 text-xs font-semibold"
class:text-red-600={stats.tempAnomaly >= 0}
class:text-blue-600={stats.tempAnomaly < 0}
class:dark:text-red-400={stats.tempAnomaly >= 0}
class:dark:text-blue-400={stats.tempAnomaly < 0}
>
{fmtSigned(stats.tempAnomaly)} vs normal
</p>
{:else}
<p class="mt-0.5 text-xs text-muted-foreground">19912020 normal loading…</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
{#if stats.normalPrecip != null}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
normal {fmtPrecip(stats.normalPrecip)} · {stats.wetDays} wet {stats.wetDays === 1
? 'day'
: 'days'}
</p>
{:else}
<p class="mt-0.5 text-xs text-muted-foreground">{stats.wetDays} wet days</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
{#if stats.warm.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
{formatZoned(dailyDates[stats.warm.i], timezone, 'EEE d LLL')}
</p>
{/if}
</div>
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
{#if stats.cold.i >= 0}
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
{formatZoned(dailyDates[stats.cold.i], timezone, 'EEE d LLL')}
</p>
{/if}
</div>
</div>
<!-- ─── Daily strip: min/max range bars + anomaly + precip ─────────────────────── -->
<section
class="mt-4 -mx-3 overflow-hidden border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border"
>
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
<h3 class="text-base font-bold">
Daily <span class="font-semibold text-muted-foreground"> select a day for hourly detail</span
>
</h3>
{#if normals}
<span class="hidden text-xs text-muted-foreground sm:inline">
normal band = 19912020 mean
</span>
{/if}
</div>
<div class="overflow-x-auto">
<div class="flex min-w-min">
{#each dailyDates as date, i (date.getTime())}
{@const selected = isSameDayInZone(date, selectedDay, timezone)}
{@const tMax = daily.temperature_2m_max[i]}
{@const tMin = daily.temperature_2m_min[i]}
{@const tMean = daily.temperature_2m_mean[i]}
{@const norm = normals ? normals.tmean[ordinals[i]] : NaN}
{@const anomaly = finite(tMean) && finite(norm) ? tMean - norm : null}
{@const precip = daily.precipitation_sum[i]}
<button
type="button"
class="flex w-[52px] shrink-0 cursor-pointer flex-col items-center gap-1 border-r border-border/40 px-1 py-2 text-center transition-colors hover:bg-muted/60 {selected
? 'bg-primary/10'
: ''}"
onclick={() => onSelectDay(date)}
aria-pressed={selected}
>
<span class="text-[11px] font-semibold {selected ? 'text-primary' : 'text-foreground'}">
{formatZoned(date, timezone, 'EEE')}
</span>
<span class="text-[10px] text-muted-foreground"
>{formatZoned(date, timezone, 'd MMM')}</span
>
<svg class="my-0.5" width="20" height="20" aria-hidden="true">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
daily.weather_code[i],
true
)}.svg#Layer_1"
></use>
</svg>
<!-- min/max range bar -->
<div class="relative w-3.5" style="height:{BAR_H}px">
{#if finite(norm)}
<!-- normal marker -->
<div
class="absolute -left-0.5 -right-0.5 border-t border-dashed border-muted-foreground/50"
style="top:{yOf(norm)}px"
></div>
{/if}
{#if finite(tMax) && finite(tMin)}
<div
class="absolute left-0 w-full rounded-full"
style="top:{yOf(tMax)}px;height:{Math.max(
3,
yOf(tMin) - yOf(tMax)
)}px;background:{getColor(tMean ?? (tMax + tMin) / 2, units.temperature_unit)}"
></div>
{/if}
</div>
<span
class="text-[11px] font-bold tabular-nums"
style="color:{getTempStyle(tMax, units.temperature_unit).bg}">{fmtTemp(tMax)}</span
>
<span class="text-[10px] font-medium text-muted-foreground tabular-nums"
>{fmtTemp(tMin)}</span
>
{#if anomaly != null}
<span
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
style="background:{anomalyColor(anomaly)}"
title="{fmtSigned(anomaly)} vs normal"
></span>
{/if}
{#if finite(precip) && precip >= 0.1}
<span
class="mt-0.5 text-[10px] font-semibold text-sky-600 dark:text-sky-400 tabular-nums"
>
{precip.toFixed(precip < 10 ? 1 : 0)}
</span>
{/if}
</button>
{/each}
</div>
</div>
</section>
@@ -1,211 +0,0 @@
<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>
@@ -0,0 +1,12 @@
[
"kinshasa",
"shenzhen",
"shanghai",
"guangzhou",
"chengdu",
"beijing",
"mumbai",
"lagos",
"lahore",
"istanbul"
]
+10 -81
View File
@@ -1,76 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { storedLocation, storedModel, storedTheme } from '$lib/stores/settings';
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 });
});
// Minimal page: no geolocation/state UI, just an embedded Open-Meteo map.
// const iframeSrc = 'https://maps.open-meteo.com/';
const iframeSrc =
'https://allow-cross-origin-loads.maps-5aj.pages.dev/?time=2026-02-21T1400#1.31/0/-0.1';
</script>
<svelte:head>
@@ -79,21 +11,18 @@
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
</svelte:head>
<!-- Full-bleed map: the layout drops its padding for this route. The map
follows our theme through the color-scheme declared on :root/.dark -->
<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) -->
<!-- Full-viewport map. No surrounding UI or location state. -->
<div
style="position:relative; inset:0; margin:0; padding:0; height:100%; width:100%; background:#000;"
>
<iframe
bind:this={iframeEl}
src={iframeSrc}
title="Open-Meteo Interactive Map"
loading="lazy"
allowfullscreen
allow="cross-origin-isolated"
referrerpolicy="no-referrer"
class="block h-full w-full border-0"
class="map-iframe"
style="border:0; width:100%; height:100%; display:block;"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
</div>
+48 -430
View File
@@ -5,310 +5,56 @@ export const defaultParameters = {
precipitation_unit: 'mm'
};
export interface WeatherModel {
value: string;
label: string;
/** Native grid resolution, from open-meteo/weather-map-layer domains.ts */
resolution?: string;
/** Model-run cadence (model_interval), from domains.ts */
update?: string;
}
export interface WeatherModelGroup {
value: string;
label: string;
models: WeatherModel[];
}
/**
* Forecast models grouped by provider. Labels, grid resolutions and update
* cadences are synced from the open-meteo/weather-map-layer project
* (src/domains.ts: domainGroups + domainOptions grid/model_interval data).
*/
export const modelGroups: WeatherModelGroup[] = [
{
value: 'auto',
label: 'Automatic',
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'varies' }]
},
{
value: 'ecmwf',
label: 'ECMWF',
models: [
{ value: 'ecmwf_ifs', label: 'ECMWF IFS HRES', resolution: '9 km', update: 'every 6 h' },
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25°', resolution: '25 km', update: 'every 6 h' },
{
value: 'ecmwf_aifs025_single',
label: 'ECMWF AIFS 0.25° Single',
resolution: '25 km',
update: 'every 6 h'
}
]
},
{
value: 'dwd',
label: 'DWD Germany',
models: [
{
value: 'icon_seamless',
label: 'DWD ICON Seamless',
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 = [
{ value: 'best_match', label: 'Best match' },
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' },
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' },
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' },
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' },
{ value: 'bom_access_global', label: 'BOM ACCESS Global' },
{ value: 'kma_seamless', label: 'KMA Seamless' },
{ value: 'kma_ldps', label: 'KMA LDPS' },
{ value: 'kma_gdps', label: 'KMA GDPS' },
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' },
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' },
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' },
{ 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' },
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' },
{ value: 'dmi_seamless', label: 'DMI Seamless' },
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' },
{ value: 'ukmo_seamless', label: 'UKMO Seamless' },
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' },
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' },
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' },
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' },
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' },
{ value: 'metno_nordic', label: 'MET Norway Nordic' },
{ value: 'metno_seamless', label: 'MET Norway Seamless' },
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' },
{ value: 'gem_regional', label: 'GEM Regional' },
{ value: 'gem_global', label: 'GEM Global' },
{ value: 'gem_seamless', label: 'GEM Seamless' },
{ value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'jma_msm', label: 'JMA MSM' },
{ value: 'jma_gsm', label: 'JMA GSM' },
{ value: 'gfs_seamless', label: 'GFS Seamless' },
{ value: 'gfs_global', label: 'GFS Global' },
{ 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: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' },
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' },
{ value: 'icon_global', label: 'ICON Global' },
{ value: 'icon_eu', label: 'ICON EU' },
{ value: 'icon_d2', label: 'ICON-D2' },
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' }
];
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 = [
[
{ value: 'temperature_2m', label: 'Temperature 2m' },
@@ -351,131 +97,3 @@ export const hourly = [
{ 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);
+102
View File
@@ -0,0 +1,102 @@
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'
];
-100
View File
@@ -1,100 +0,0 @@
/**
* 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;
};
+69 -9
View File
@@ -1,13 +1,55 @@
import { type RGBA, needsWhiteText, rgbaCss, sampleScale, temperatureScale } from './color-scales';
import colorScaleHex from './color-scale-hex';
const toCelsius = (temperature: number, unit: string): number =>
unit === 'celsius' ? temperature : ((temperature - 32) * 5) / 9;
const componentFromStr = (numStr: string, percent: number) => {
const num = Math.max(0, parseInt(numStr, 10));
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
};
export const getTempColor = (temperature: number, unit = 'celsius'): RGBA =>
sampleScale(temperatureScale.breakpoints, temperatureScale.colors, toCelsius(temperature, unit));
export const rgbToHex = (rgb: string): string => {
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
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]));
export const getColor = (temperature: number, unit = 'celsius'): string =>
rgbaCss(getTempColor(temperature, unit));
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
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 {
bg: string;
@@ -15,6 +57,24 @@ export interface TempStyle {
}
export const getTempStyle = (temp: number, unit: string): TempStyle => {
const rgba = getTempColor(temp, unit);
return { bg: rgbaCss(rgba), fg: needsWhiteText(rgba) ? 'white' : 'black' };
const bg = getColor(temp, unit);
const fg = textWhite(hexToRgb(bg)) ? '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;
};
+9 -12
View File
@@ -38,9 +38,9 @@ const weatherCodes: Record<number, string> = {
51: 'sprinkle',
52: 'rain',
53: 'rain',
54: 'sprinkle',
55: 'rain',
56: 'rain-mix',
54: 'snowflake-cold',
55: 'snowflake-cold',
56: 'snowflake-cold',
57: 'sprinkle',
58: 'rain',
60: 'sprinkle',
@@ -56,11 +56,11 @@ const weatherCodes: Record<number, string> = {
71: 'snow',
72: 'snow',
73: 'snow',
74: 'snow',
75: 'snow',
76: 'snow',
74: 'snowflake-cold',
75: 'snowflake-cold',
76: 'snowflake-cold',
77: 'snow',
78: 'snow',
78: 'snowflake-cold',
80: 'rain',
81: 'sprinkle',
82: 'rain',
@@ -80,13 +80,10 @@ const weatherCodes: Record<number, string> = {
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 {
const prefix = daytime ? 'day' : 'night';
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
return `wi-${daytime ? 'day' : 'night'}-${name}`;
return `wi-${prefix}-${name}`;
}
export default weatherCodes;
-23
View File
@@ -1,23 +0,0 @@
<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>
+15
View File
@@ -0,0 +1,15 @@
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;
+36 -228
View File
@@ -1,151 +1,71 @@
<script lang="ts">
import { type Component, onMount } from 'svelte';
import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import {
storedChartLayout,
storedLocation,
storedModel,
storedUnits,
storedVariablePrefs
} from '$lib/stores/settings';
import { ChartContainer } from '$lib/components/charts';
import { storedLocation } from '$lib/stores/settings';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
import { defaultParameters } from '../../options';
import DailyCards from './DailyCards.svelte';
import DailyStripSticky from './DailyStripSticky.svelte';
import HourlyTable from './HourlyTable.svelte';
import MeteogramCharts from './MeteogramCharts.svelte';
import ModelSelector from './ModelSelector.svelte';
import VariableSidebar from './VariableSidebar.svelte';
import { neededHourlyApiVars } from './variables';
import type { PageData } from './$types';
import type { GeoLocation } from '$lib/stores/settings';
import type { FetchedDaily, FetchedHourly } from './types';
let { data }: { data: PageData } = $props();
let params = $state({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
models: ['best_match'],
...defaultParameters
});
// units live in a persisted store; mirror them into params so a change
// re-runs the fetch effect below (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;
});
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 location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let mounted = $state(false);
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();
let fetchedHourly: FetchedHourly | null = $state(null);
let fetchedDaily: FetchedDaily | null = $state(null);
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
let meteogramCharts: MeteogramCharts | undefined = $state();
const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime());
meteogramCharts?.scrollToDay(date);
};
onMount(() => {
// preselect the persisted model (client-only so prerendered HTML stays stable)
params.models = [get(storedModel)];
mounted = true;
// load the map bundle only when its section is about to enter the viewport
if (!mapSection) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
io.disconnect();
import('$lib/maps/WeatherMap.svelte').then((m) => (WeatherMap = m.default));
}
},
{ rootMargin: '300px' }
);
io.observe(mapSection);
return () => io.disconnect();
});
$effect(() => {
const loc = location;
const modelList = params.models;
const requestVars = hourlyVars;
if (!mounted || !loc || !modelList?.length) return;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
const loadData = async () => {
loading = true;
loadError = null;
fetchWeekForecast({
const result: WeekForecastResult = await fetchWeekForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
model: modelList[0],
hourlyVariables: requestVars,
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',
forecast_days: forecastDays,
past_days: pastDays,
forecast_days: 7,
past_days: 0,
timezone: loc.timezone
})
.then((result: WeekForecastResult) => {
if (version !== requestVersion) return;
});
fetchedHourly = {
hourly: result.hourly,
@@ -153,7 +73,7 @@
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
markAreas: result.markAreas
};
fetchedDaily = {
@@ -163,101 +83,21 @@
};
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
};
loadData();
});
</script>
<svelte:head>
<title>Drizz.li | Weather</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<title>Weather | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head>
<div class="week-page">
<div class="weather-content" style="min-height: 50vh">
<!-- 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}
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
{#if fetchedHourly && fetchedDaily}
<HourlyTable
@@ -266,56 +106,24 @@
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div
transition:fade={{ duration: 200 }}
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if}
</div>
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8" transition:fade={{ duration: 200 }}>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
<MeteogramCharts
bind:this={meteogramCharts}
data={fetchedHourly}
{selectedDay}
units={params}
{loading}
/>
{/if}
<!-- Animated weather maps for the shown forecast window. The wrapper is
always present so it can be observed; the browser-only map bundle is
fetched only once it scrolls near the viewport, then each map follows
the selected model / location. -->
<div bind:this={mapSection}>
<section class="mt-8">
<h2 class="mb-3 text-lg font-semibold tracking-tight">Precipitation map</h2>
<div
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
>
{#if WeatherMap}
<WeatherMap variable="precipitation" title="Precipitation" accumulation />
{:else}
<div class="h-full w-full animate-pulse bg-muted"></div>
{/if}
</div>
</section>
<section class="mt-8">
<h2 class="mb-3 text-lg font-semibold tracking-tight">Wind map</h2>
<div
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
>
{#if WeatherMap}
<WeatherMap variable="wind_u_component_10m" title="Wind" arrows />
{:else}
<div class="h-full w-full animate-pulse bg-muted"></div>
{/if}
</div>
</section>
</div>
<ModelSelector
selectedModel={params.models?.[0] ?? 'best_match'}
onModelChange={(model) => {
params.models = [model];
}}
/>
</div>
</div>
@@ -1,315 +0,0 @@
<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 { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import weatherCodes from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
@@ -12,57 +12,9 @@
selectedDay: Date;
units: WeatherUnits;
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,
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;
});
});
let { daily, selectedDay, units, onSelectDay }: Props = $props();
function getDaylightSeconds(index: number): number {
if (!daily) return 0;
@@ -82,90 +34,14 @@
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.1) return '#fcd34d';
if (ratio >= 0.2) return '#fcd34d';
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>
<!-- Shared filter: erodes the filled weather glyphs slightly so their
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}
>
<div in:fade out:fade class="mb-6 min-h-[260px]">
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
{#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)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
{@const tempMax = daily.daily.temperature_2m_max[index]}
@@ -181,142 +57,101 @@
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
{@const minStyle = getTempStyle(tempMin, unit)}
{#if tempMax != null && !isNaN(tempMax)}
<button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
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
{selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected}
? 'scale-[1.03] border-primary bg-accent shadow-md'
: 'border-transparent bg-card hover:bg-accent'}"
onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
>
<span class="text-sm font-bold tracking-wide">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
<span class="text-[11px] text-muted-foreground">
{getRelativeDayLabel(time, daily.timezone)}
</span>
<!-- Weather icon: large day with a night badge in the corner -->
<div class="relative my-1 px-3 -ml-2.5">
<svg
class="day-icon fill-foreground"
width="100px"
height="100px"
style="filter: url(#thin-day-icon)"
<!-- Weather icon -->
<div
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
style="background: {sunColor}22"
>
<svg class="fill-foreground" width="48px" height="48px">
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
false
)}.svg#Layer_1"
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wCode as keyof typeof weatherCodes
] ?? 'clear'}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
<div class="flex w-full flex-col">
<div
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
</div>
<div
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)}°
</span>
</div>
</div>
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<!-- Details section -->
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
<!-- Sunshine bar -->
<div class="flex w-full items-center gap-1 px-1">
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
<span class="text-[10px] font-medium text-muted-foreground">
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<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"
>
<!-- Precipitation -->
<div class="flex items-center gap-1 text-[11px]">
<svg class="fill-foreground shrink-0" width="14px" height="14px">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
<span>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
<span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
</div>
<!-- Wind with direction -->
<div class="flex items-center gap-1 text-[11px]">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0 -mr-2"
<div
class="inline-flex shrink-0"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
<svg class="fill-foreground" width="20px" height="20px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
</div>
{:else}
<svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
>
<svg class="fill-foreground shrink-0" width="20px" height="20px">
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
<span>
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
@@ -325,112 +160,19 @@
</button>
{/if}
{/each}
<!-- Mobile: the "load more" card is the last item in the SAME flex row
as the cards (reached by scrolling); keeping it in this container
avoids the zoom/flex mis-position seen when it was a sibling. On
md+ it's hidden here and the pinned sibling below is shown. -->
{#if canExtend && onExtend}
<button
type="button"
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
</div>
{/if}
</div>
<!-- Desktop: "load more" card pinned to the right of the scroll rail (a
sibling, not inside the scroll) so it's always visible and full-height. -->
{#if daily && canExtend && onExtend}
<button
type="button"
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
</button>
{/if}
</div>
<style>
/* Reserve a constant thin scrollbar gutter (no layout shift), but keep the
thumb invisible until the user is actively scrolling. */
.day-scroll {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
/* Soft fade at the right edge (only) so cards dissolve into the page
margin instead of being hard-cut as they scroll off. */
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
mask-image: linear-gradient(to right, #000 calc(100% - 32px), transparent);
}
.day-scroll.scrolling {
scrollbar-color: color-mix(in oklab, var(--color-border) 85%, transparent) transparent;
}
.day-scroll::-webkit-scrollbar {
height: 8px;
}
.day-scroll::-webkit-scrollbar-thumb {
border-radius: 4px;
background: transparent;
transition: background 0.2s;
}
.day-scroll.scrolling::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-border) 85%, transparent);
}
/* The cards group fills the viewport so the row overflows past the side
buttons. On md+ we also reserve room so the "load more" button stays in
view; on mobile it keeps its full width after the cards (reached by
scrolling) and is never clipped. */
.cards-fill {
min-width: 100%;
}
@media (min-width: 768px) {
.cards-fill {
min-width: calc(100% - var(--fill-reserve, 0rem));
}
}
/* Mobile: keep the exact desktop layout, just scale the whole card down. */
@media (max-width: 768px) {
.day-card {
zoom: 0.72;
button {
min-width: 92px !important;
}
button :global(svg[width='48px']) {
width: 40px;
height: 40px;
}
}
</style>
@@ -1,314 +0,0 @@
<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,16 +1,6 @@
<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 { groupHover, setGroupHover } from '$lib/charts';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import {
@@ -29,43 +19,11 @@
selectedDay: Date;
units: WeatherUnits;
locationName: string;
/** Opens the variable-customization sidebar (button lives in the header). */
onCustomize?: () => void;
}
let { data, daily, selectedDay, units, locationName, onCustomize }: Props = $props();
let { data, daily, selectedDay, units, locationName }: Props = $props();
// 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
);
let hourlyInterval = $state<1 | 3>(3);
const today = new Date();
const tempUnit = $derived(getTempUnit(units));
@@ -160,67 +118,6 @@
return `rgba(0, 180, 200, ${hum ** 3.5 / 10 ** 7.8})`;
}
// ─── Maps-project colour ramps (open-meteo/maps) ────────────────────────────
// Same breakpoint colours the weather maps use, applied as cell backgrounds.
function hexRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '');
const n =
h.length === 3
? h
.split('')
.map((c) => c + c)
.join('')
: h;
return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
}
function mixRgb(
a: [number, number, number],
b: [number, number, number],
f: number
): [number, number, number] {
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f)
];
}
// Pressure: 940 hPa blue → 1010 white → 1060 red.
const PRESSURE_LOW = hexRgb('#4444ff');
const PRESSURE_MID = hexRgb('#ffffff');
const PRESSURE_HIGH = hexRgb('#ff4444');
function getPressureBg(hpa: number | null): string {
if (hpa == null || isNaN(hpa)) return 'transparent';
const v = Math.max(940, Math.min(1060, hpa));
const c =
v <= 1010
? mixRgb(PRESSURE_LOW, PRESSURE_MID, (v - 940) / 70)
: mixRgb(PRESSURE_MID, PRESSURE_HIGH, (v - 1010) / 50);
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, 0.5)`;
}
// UV index: 0 → 12 across the maps' teal→green→yellow→orange→pink ramp. The
// opacity tracks the value so low/night hours stay faint instead of tinting
// the whole row.
const UV_STOPS = [
'#009392',
'#39b185',
'#9ccb86',
'#e9e29c',
'#eeb479',
'#e88471',
'#cf597e'
].map(hexRgb);
function getUvBg(uv: number | null): string {
if (uv == null || isNaN(uv) || uv <= 0) return 'transparent';
const v = Math.min(12, uv);
const p = (v / 12) * (UV_STOPS.length - 1);
const i = Math.min(UV_STOPS.length - 2, Math.floor(p));
const c = mixRgb(UV_STOPS[i], UV_STOPS[i + 1], p - i);
const alpha = 0.18 + (v / 12) * 0.5;
return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${alpha.toFixed(3)})`;
}
function formatPrecipTooltip(precip: number | null, prob: number | null): string {
const parts: string[] = [];
if (prob != null && prob > 0) parts.push(`Probability: ${prob}%`);
@@ -262,101 +159,6 @@
let sunsetPercent = $derived(
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>
{#snippet weatherIcon(name: string, size: number = 16)}
@@ -367,210 +169,130 @@
{#snippet rowHeader(iconName?: string, unit?: string, label?: string)}
<th class="hdr" scope="row">
<div class="flex flex-col items-center gap-0.5 leading-tight">
<div class="flex flex-col items-center leading-tight">
{#if iconName}
{@render weatherIcon(iconName, 18)}
{@render weatherIcon(iconName)}
{/if}
{#if label}
<span class="text-[11px] font-semibold">{label}</span>
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
{/if}
{#if unit}
<span class="text-[10px] font-medium text-muted-foreground">{unit}</span>
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
{/if}
</div>
</th>
{/snippet}
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@const iconPx = is3h ? 38 : 33}
<!-- 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>
<!-- 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-2">
{#if onCustomize}
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<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"
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"
>
<!-- 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>
<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>
{/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>
<span class="select-none text-muted-foreground">1h</span>
</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}
>
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@const iconPx = is3h ? 40 : 26}
<div class="overflow-hidden rounded-lg border border-border">
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
<col class="w-16 md:w-20" />
<col class="w-14 md:w-16" />
{#each cellData as _ (_.idx)}
<col />
{/each}
</colgroup>
<tbody>
<!-- Time + Daylight bar (merged) -->
<tr class="row">
<th class="hdr" scope="row" bind:clientWidth={headerColWidth}>
<span class="text-[10px] font-semibold text-muted-foreground">Time</span>
<tr class="!border-t-0">
<th class="hdr" scope="row">
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
</th>
<td
colspan={cellData.length}
class="relative h-12 cursor-default overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
<!-- Daylight background -->
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
<div
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/40"
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/10"
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/40"
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
style="width:{100 - sunsetPercent}%"
></div>
<!-- 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
class="absolute bottom-0.5 left-1 text-[10px] leading-none font-semibold whitespace-nowrap text-amber-700 dark:text-amber-300"
class="absolute bottom-0.5 left-1 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
>
<svg
class="inline-block fill-foreground"
class="fill-foreground inline-block"
width="12px"
height="12px"
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>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunrise)}</span>
</span>
</div>
<!-- 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
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"
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"
>
<svg
class="inline-block fill-foreground"
class="fill-foreground inline-block"
width="12px"
height="12px"
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>
</svg>
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
</span>
</div>
{/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 -->
{#each cellData as cell, i (cell.idx)}
{@const leftPct = (i / cellData.length) * 100}
{@const widthPct = 100 / cellData.length}
<span
class="absolute top-0 flex items-start pl-1 font-bold {is3h
? 'pt-2 text-sm'
: 'pt-2.5'}
{cell.isNow ? 'text-red-600 dark:text-red-400' : ''}"
class="absolute top-0 flex items-start pt-1 font-bold pl-0.5 text-sm
{cell.isNow ? 'text-destructive' : ''}"
style="left:{leftPct}%;width:{widthPct}%"
>
{#if is3h}
<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>
{formatZoned(cell.date, data.timezone, 'HH')}
{:else}
<span class="inline-flex items-baseline gap-1">
<span class="text-[11px] font-semibold"
>{formatZoned(cell.date, data.timezone, 'HH')}</span
>
<sup
class="inline-block -translate-x-0.5 translate-y-[0.16rem] align-baseline text-[9px] leading-none font-semibold {cell.isNow
? 'text-red-500 dark:text-red-400'
: 'text-muted-foreground'}">00</sup
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
>00</sup
>
</span>
{/if}
@@ -580,197 +302,110 @@
</tr>
<!-- Weather Icons -->
{#if showRow('icons')}
<tr class="row">
<tr>
{@render rowHeader('wi-day-cloudy')}
{#each cellData as cell, i (cell.idx)}
{@const wCode = hourly.weather_code[cell.idx]}
<td
class="cell h-12 leading-0"
class="cell leading-[0] {is3h ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
class:now={cell.isNow}
class:icon-day={cell.isDaytime}
class:icon-night={!cell.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)}
</td>
{/each}
</tr>
{/if}
<!-- Temperature -->
{#if showRow('temperature')}
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
<tr>
{@render rowHeader('wi-thermometer', tempUnit)}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.temperature_2m[cell.idx]}
{@const style = getTempStyle(temp, String(units.temperature_unit))}
<td
class="cell h-12 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
class:now={cell.isNow}
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Feels Like (short row) -->
{#if showRow('feels')}
<tr class="row">
<!-- Feels Like -->
<tr>
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
class:now={cell.isNow}
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Wind -->
{#if showRow('wind')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
<tr>
{@render rowHeader('wi-strong-wind', windUnit)}
{#each cellData as cell (cell.idx)}
{@const wind = hourly.windspeed_10m[cell.idx]}
{@const windDir = hourly.winddirection_10m[cell.idx]}
<td class="relative cell h-12 align-middle leading-none">
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
{#if windDir != null && !isNaN(windDir)}
<span
class="absolute top-0 left-1/2 inline-block origin-center leading-0"
style="transform: translateX(-50%) {getWindArrowRotation(windDir)}"
class="inline-block leading-[0]"
style="transform:{getWindArrowRotation(windDir)}"
>
{@render weatherIcon('wi-direction-down', 40)}
{@render weatherIcon('wi-direction-down', 24)}
</span>
{/if}
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
{formatValue(wind)}
</span>
</td>
{/each}
</tr>
{/if}
<!-- Wind Gusts (off by default) -->
{#if showRow('gusts')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Gusts')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.wind_gusts_10m?.[cell.idx]}
<td
class="cell h-12 font-semibold text-foreground/80 {is3h
? 'text-sm'
: 'text-xs'}"
>
{formatValue(v)}
</td>
{/each}
</tr>
{/if}
<!-- Humidity (short row) -->
{#if showRow('humidity')}
<tr class="row">
{@render rowHeader('wi-humidity', '%', 'Humidity')}
<!-- Humidity -->
<tr>
{@render rowHeader('wi-humidity', '%')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
{/if}
<!-- Cloud Cover -->
{#if showRow('clouds')}
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
<tr>
{@render rowHeader('wi-cloud', '%')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-12"
class="cell"
class:now={cell.isNow}
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
>
{formatValue(cloud)}
</td>
{/each}
</tr>
{/if}
<!-- Pressure (off by default) -->
{#if showRow('pressure')}
<tr class="row">
{@render rowHeader('wi-barometer', 'hPa', 'Pressure')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.pressure_msl?.[cell.idx]}
<td
class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getPressureBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- UV Index (off by default) -->
{#if showRow('uv')}
<tr class="row">
{@render rowHeader('wi-day-sunny', 'UV', 'UV')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.uv_index?.[cell.idx]}
<td
class="cell h-12 font-semibold {is3h ? 'text-sm' : 'text-xs'}"
style="background:{getUvBg(v ?? null)}"
>
{v != null && !isNaN(v) ? v.toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Visibility (off by default) -->
{#if showRow('visibility')}
<tr class="row">
{@render rowHeader('wi-fog', 'km', 'Visibility')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.visibility?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) ? (v / 1000).toFixed(0) : '-'}
</td>
{/each}
</tr>
{/if}
<!-- Precipitation -->
{#if showRow('precipitation')}
<tr class="row">
{@render rowHeader('wi-raindrop', precipUnit, 'Precip')}
<tr>
{@render rowHeader('wi-raindrop', precipUnit)}
{#each cellData as cell (cell.idx)}
{@const precip = hourly.precipitation[cell.idx]}
{@const prob = hourly.precipitation_probability[cell.idx]}
<td
class="cell precip-cell h-12"
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
class:now={cell.isNow}
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
@@ -783,93 +418,62 @@
</td>
{/each}
</tr>
{/if}
<!-- Snowfall (off by default) -->
{#if showRow('snowfall')}
<tr class="row">
{@render rowHeader('wi-snow', 'cm', 'Snow')}
{#each cellData as cell (cell.idx)}
{@const v = hourly.snowfall?.[cell.idx]}
<td class="cell h-12 {is3h ? 'text-sm' : 'text-xs'}">
{v != null && !isNaN(v) && v > 0 ? v.toFixed(1) : '-'}
</td>
{/each}
</tr>
{/if}
</tbody>
</table>
<!-- Hovered-column highlight, mirroring the meteogram crosshair -->
{#if hoveredCol >= 0 && tableWidth > 0}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
<div
class="pointer-events-none absolute inset-y-0 z-10 border-x border-primary/40 bg-primary/10"
style="left:{headerColWidth + hoveredCol * colWidth}px;width:{colWidth}px"
></div>
{/if}
<!-- "Now" column highlight + exact-time line -->
{#if nowLeftPx != null}
{@const colWidth = (tableWidth - headerColWidth) / cellData.length}
{@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>
</section>
{/if}
<style>
/* ── Uniform grid ───────────────────────────────────────────── */
.row + .row {
border-top: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent);
tr {
border-top: 1px solid hsl(var(--border));
}
/* ── Base cell ──────────────────────────────────────────── */
.cell {
padding: 2px;
padding: 6px 2px;
text-align: center;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
border-right: 1px solid hsl(var(--border) / 0.2);
overflow: hidden;
}
.cell + .cell {
border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
.cell:last-child {
border-right: none;
}
/* ── Row header ─────────────────────────────────────────────── */
.cell.now {
font-weight: 700;
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
}
/* ── Row header ─────────────────────────────────────────── */
.hdr {
/* 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;
padding: 4px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: color-mix(in oklab, var(--color-muted) 45%, var(--color-card));
border-right: 1px solid var(--color-border);
background: hsl(var(--background));
border-right: 2px solid hsl(var(--border));
white-space: nowrap;
overflow: hidden;
}
/* ── Precipitation ──────────────────────────────────────────── */
/* ── Precipitation ──────────────────────────────────────── */
.precip-cell {
position: relative;
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 {
@@ -896,10 +500,10 @@
}
:global(.dark) .precip-label {
color: rgba(140, 190, 255, 0.95);
color: rgba(120, 180, 255, 0.95);
}
/* ── Responsive ─────────────────────────────────────────────── */
/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) {
.hdr {
padding: 3px 2px;
@@ -907,6 +511,7 @@
}
.cell {
font-size: 11px;
padding: 4px 1px;
}
}
</style>
@@ -1,18 +1,23 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import * as echarts from 'echarts';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { CanvasChart, groupRange } from '$lib/charts';
import { getWeatherIconName } from '../../utils/weather-codes';
import ChartCustomizer from './ChartCustomizer.svelte';
import { type FetchedHourly, type WeatherUnits } from './types';
import { VARIABLE_BY_KEY, buildPanelDef } from './variables';
import { getColor } from '../../utils/colors';
import {
type FetchedHourly,
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindDirectionLabel,
getWindUnit
} from './types';
interface Props {
data: FetchedHourly;
@@ -25,321 +30,589 @@
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
const CHART_HEIGHT = 300;
const MS_PER_DAY = 24 * 3600 * 1000;
let customizerOpen = $state(false);
let downloadingPng = $state(false);
let showCharts = $state(false);
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
async function downloadPng(): Promise<void> {
if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true;
try {
// pair each chart with its panel title so the export is labelled
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
await downloadChartsPng(items, 'week-forecast');
} finally {
setTimeout(() => (downloadingPng = false), 500);
}
}
export function scrollToDay(day: Date): void {
if (chartInstances.length === 0 || !data) return;
// 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 targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
const firstHourIdx = data.hourlyDates.findIndex(
(d) => formatZoned(d, tz, 'yyyy-MM-dd') === targetDayStr
);
return firstHourIdx === -1 ? null : data.timestamps[firstHourIdx] / 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);
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 resetZoom(): void {
liveCharts[0]?.resetRange();
for (const chart of chartInstances) {
if (chart && !chart.isDisposed()) {
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
}
}
onResetZoom?.();
}
const now = new Date();
// "Selected day" is redundant while today is the selected day, so hide it then
let rangePresets = $derived.by(() => {
const isToday = isSameDayInZone(now, selectedDay, data.timezone);
return [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
...(isToday ? [] : [{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) }]),
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
});
// Soft band drawn on every chart marking the currently selected day
let selectedDayHighlight = $derived.by(() => {
const start = dayStartSec(selectedDay);
return start == null ? undefined : { start, end: start + SECONDS_PER_DAY };
});
// ─── Pictograms (weather icons across the top) ──────────────────────────────
function isDaytime(tSec: number): boolean {
return data.daylightBands.some((b) => tSec >= b.start && tSec < b.end);
function handleChartReady(chart: echarts.ECharts): void {
chart.group = CHART_GROUP;
chartInstances = [...chartInstances, chart];
if (chartInstances.length === 3) {
echarts.connect(CHART_GROUP);
requestAnimationFrame(() => scrollToDay(selectedDay));
}
}
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)) });
$effect(() => {
// Reset chart instances when data changes
if (data) {
chartInstances = [];
chartComponents = [];
}
return out;
});
// Wind-direction arrows for panels showing wind (deg = direction from North).
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;
$effect(() => {
if (!data) return;
const { hourly, timestamps, markAreas } = data;
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 } }
});
// True while the shared group is zoomed in (not the full range).
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
const insideZoom = (): Record<string, unknown> => ({
type: 'inside',
xAxisIndex: 0,
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false
});
// ─── Panel definitions ──────────────────────────────────────────────────────
interface RenderPanel extends ChartPanel {
def: ReturnType<typeof buildPanelDef>;
title: string;
titleShort: string;
const sliderZoom = (): Record<string, unknown> => ({
type: 'slider',
xAxisIndex: 0,
filterMode: 'none',
height: 20,
bottom: 4,
borderColor: colors.axisLine,
fillerColor: 'rgba(100, 140, 200, 0.2)',
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)' }
}
});
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 };
})
const tooltipBase = (
formatter: (
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 => {
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';
const tempOption: Record<string, unknown> = {
title: {
text: 'Temperature & Cloud Cover',
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 === '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 }
};
// 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))
)
);
chartOptions = [tempOption, precipOption, windOption];
});
</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">
{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)}
>
<div class="charts-toggle-section">
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
<span>Detailed Meteogram Charts</span>
<svg
class="h-3.5 w-3.5"
class="toggle-chevron {showCharts ? 'open' : ''}"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
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-linecap="round"
stroke-linejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
<polyline points="6 9 12 15 18 9" />
</svg>
{/if}
PNG
</button>
<!-- Kept last so toggling it wraps onto a new line instead of shifting
the other controls (no layout shift on mobile). -->
{#if zoomActive}
<button
type="button"
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
onclick={resetZoom}
>
<svg
class="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
</svg>
Reset zoom
</button>
{/if}
</div>
</div>
{#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, <button
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
onclick={() => (customizerOpen = true)}>add some variables</button
>.
{#if showCharts}
<div class="detailed-charts" in:fade={{ duration: 200 }}>
<div class="charts-header">
<h3 class="charts-title">
{formatZoned(selectedDay, data.timezone, 'EEEE')}
<small>
{getRelativeDayLabel(selectedDay, data.timezone) !==
formatZoned(selectedDay, data.timezone, 'EEEE')
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
: ''}
</small>
</h3>
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
Show All
</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
<ChartContainer {loading} chartCount={3} chartHeight={300}>
{#each chartOptions as option, i (i)}
<EChart
{option}
height={i === 2 ? '320px' : '300px'}
onChartReady={handleChartReady}
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}
</ChartContainer>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
</div>
</div>
{/if}
</section>
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
<style>
.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,37 +1,23 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { type WeatherModelGroup, modelGroups } from '../../options';
import { models } from '../../options';
interface Props {
selectedModel: string;
onModelChange: (model: string) => void;
/** Model list to offer; defaults to the deterministic forecast models */
groups?: WeatherModelGroup[];
label?: string;
}
let {
selectedModel,
onModelChange,
groups = modelGroups,
label = 'Weather model'
}: Props = $props();
let { selectedModel, onModelChange }: Props = $props();
let model = $derived(
groups.flatMap((group) => group.models).find((mo) => mo.value === selectedModel)
let modelLabel = $derived(
models.find((mo) => String(mo.value) === selectedModel)?.label ?? 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>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">
<Select.Root
name="model_selection"
type="single"
@@ -41,68 +27,19 @@
}}
>
<Select.Trigger
aria-label="{label} selection"
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"
aria-label="Forecast model selection"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
>
<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>
{modelLabel}
</Select.Trigger>
<Select.Content preventScroll={false} class="max-h-[min(480px,60vh)] border-border">
{#each groups as group (group.value)}
<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>
<Select.Content preventScroll={false} class="border-border">
{#each models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
{/each}
</Select.Content>
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground">
Weather model
</Label>
</Select.Root>
</div>
</div>
@@ -0,0 +1,57 @@
<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>
@@ -1,123 +0,0 @@
<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}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
export interface WeatherUnits {
temperature_unit: string;
@@ -12,7 +12,7 @@ export interface FetchedHourly {
timezone: string;
timestamps: number[];
hourlyDates: Date[];
daylightBands: DaylightBand[];
markAreas: MarkArea[];
}
export interface FetchedDaily {
@@ -1,507 +0,0 @@
/**
* 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)
};
}
+1 -13
View File
@@ -9,21 +9,9 @@ const config = {
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// 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' }),
adapter: adapter(),
// Pregenerate city pages to improve SEO during static build
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: (() => {
try {
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
-14
View File
@@ -32,20 +32,6 @@ const viteServerConfig = (): Plugin => ({
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
// @openmeteo/weather-map-layer is linked from ../../open-meteo/weather-map-layer
// (a file: dependency), so allow Vite to serve files from the shared Projects
// root and keep a single maplibre-gl instance across drizzli and the layer.
server: {
fs: { allow: ['../..'] }
},
resolve: {
dedupe: ['maplibre-gl']
},
optimizeDeps: {
// these ship WASM and must not be pre-bundled by esbuild
exclude: ['@openmeteo/file-reader', '@openmeteo/file-format-wasm']
},
test: {
expect: { requireAssertions: true },