103 changed files with 6052 additions and 14198 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
# Open-Meteo Weather Web
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**: [Highcharts](https://www.highcharts.com/) (Current, transitioning to a more flexible charting library in the future)
## 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;
}
}
```
+3
View File
@@ -0,0 +1,3 @@
{
"userWords": ["ConfigInterface"]
}
+1733 -1516
View File
File diff suppressed because it is too large Load Diff
+33 -34
View File
@@ -1,5 +1,5 @@
{
"name": "drizzli",
"name": "open-meteo-weather",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -14,48 +14,47 @@
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label popover select separator switch -y -o && prettier --write src/lib/components/ui"
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label 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"
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.1",
"vite": "^7.2.6",
"vitest": "^4.0.15",
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@openmeteo/weather-map-layer": "file:../../open-meteo/weather-map-layer",
"maplibre-gl": "^5.24.0"
"@openmeteo/sdk": "^1.23.0",
"highcharts": "^12.4.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';
@@ -1,157 +0,0 @@
<!--
ChartContainer.svelte — Consistent chart layout wrapper
Provides a container with:
- Consistent padding and spacing
- Loading overlay with spinner
- Fade transitions
- Responsive min-height calculation
- Slot for chart content
Usage:
<ChartContainer loading={!chartsReady} chartCount={3}>
{#each charts as chart}
<CanvasChart {...chart} />
{/each}
</ChartContainer>
-->
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Whether the charts are still loading */
loading?: boolean;
/** Number of charts being rendered (used for min-height calculation) */
chartCount?: number;
/** Height per individual chart in pixels (default: 300) */
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) */
children?: Snippet;
}
let {
loading = true,
chartCount = 1,
chartHeight = 300,
extraPadding = 2,
minWidth = 560,
bleed = true,
class: className = '',
children
}: Props = $props();
// ─── Computed ───────────────────────────────────────────────────────────────
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 }}>
{#if children}
{@render children()}
{/if}
</div>
<!-- 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:pointer-events-none={!loading}
class:opacity-0={!loading}
class:opacity-100={loading}
>
<div class="flex flex-col items-center gap-3">
<svg
class="lucide lucide-loader-circle animate-spin text-muted-foreground"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="sr-only">Loading charts...</span>
</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 {
margin-left: -1.5rem;
margin-right: -1.5rem;
}
.chart-bleed.no-bleed {
margin-left: 0;
margin-right: 0;
}
}
.chart-content {
width: 100%;
}
/* Smooth transition for the loading overlay */
.loading-overlay {
will-change: opacity;
}
</style>
@@ -1,168 +0,0 @@
<!--
ChartToolbar.svelte — Chart action bar with download and display controls
Provides a toolbar row with:
- Download full meteogram as PNG button
- Slot for additional custom controls (e.g. legend toggle)
When multiple charts are provided, they are stitched into a single
combined image on download.
Usage:
<ChartToolbar
charts={chartComponents}
fileName="model-comparison"
>
{#snippet controls()}
<Switch bind:checked={showLegend} />
{/snippet}
</ChartToolbar>
-->
<script module lang="ts">
export type { ExportableChart } from './downloadChartsPng';
</script>
<script lang="ts">
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
import type { Snippet } from 'svelte';
// ─── Props ──────────────────────────────────────────────────────────────────
interface Props {
/** Chart components available for download (undefined entries are skipped) */
charts?: Array<ExportableChart | undefined | null>;
/** Base file name for downloaded images (without extension) */
fileName?: string;
/** Optional CSS class for the outer container */
class?: string;
/** Slot for additional controls (switches, checkboxes, etc.) */
controls?: Snippet;
}
let {
charts = [],
fileName = 'drizzli-chart',
class: className = '',
controls
}: Props = $props();
// ─── State ──────────────────────────────────────────────────────────────────
let downloading = $state(false);
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.some((chart) => chart != null));
// ─── Download ───────────────────────────────────────────────────────────────
async function handleDownload(): Promise<void> {
if (!hasCharts || downloading) return;
downloading = true;
try {
await downloadChartsPng(charts, fileName);
} finally {
setTimeout(() => {
downloading = false;
}, 500);
}
}
</script>
<div
class="chart-toolbar flex flex-col items-center gap-4 md:flex-row md:justify-between {className}"
>
<!-- Left side: Custom controls slot -->
<div class="flex flex-wrap items-center gap-4 md:gap-6">
{#if controls}
{@render controls()}
{/if}
</div>
<!-- Right side: Download button -->
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="toolbar-btn"
disabled={!hasCharts || downloading}
onclick={handleDownload}
title="Download meteogram as PNG image"
>
{#if downloading}
<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>PNG</span>
</button>
</div>
</div>
<style>
.toolbar-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
line-height: 1.25rem;
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,
border-color 150ms ease;
white-space: nowrap;
user-select: none;
}
.toolbar-btn:hover:not(:disabled) {
color: hsl(var(--foreground));
background: hsl(var(--muted));
border-color: hsl(var(--foreground) / 0.2);
}
.toolbar-btn:active:not(:disabled) {
background: hsl(var(--muted) / 0.8);
transform: translateY(0.5px);
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbar-btn:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
</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`);
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Chart Components — Barrel Export
*
* Re-exports all chart-related Svelte components from a single entry point.
*
* Usage:
* import { ChartContainer, ChartToolbar } from '$lib/components/charts';
*/
export { default as ChartContainer } from './ChartContainer.svelte';
export { default as ChartToolbar } from './ChartToolbar.svelte';
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
+180 -235
View File
@@ -1,19 +1,14 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, tick } from 'svelte';
import { createEventDispatcher, onDestroy } 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';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
export let label: string = 'Search location...';
export let label: string = 'Search Locations...';
export let placeholder: string = 'Enter city name...';
interface ResultSet {
@@ -23,52 +18,26 @@
const dispatch = createEventDispatcher();
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
let searchQuery = '';
let popoverOpen = false;
let searchInputEl: HTMLInputElement | null = null;
onDestroy(() => {
clearTimeout(debounceTimeout);
clearInterval(debounceTimeout);
});
const closePopover = () => {
popoverOpen = false;
let scrollY: number | undefined;
const closeModal = () => {
dialogOpen = false;
if (scrollY) {
window.scrollTo({ top: scrollY, behavior: 'instant' });
}
};
const selectLocation = (location: GeoLocation) => {
addRecent(location);
searchQuery = '';
closePopover();
closeModal();
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();
}
$: if (popoverOpen) {
focusInput();
}
$: results = (async () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
@@ -89,19 +58,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,
@@ -124,209 +91,187 @@
return (await result.json()) as ResultSet;
})();
let dialogOpen = false;
</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"
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
class="group flex h-14 w-full cursor-pointer items-center justify-start rounded-xl border-2 border-gray-200 bg-white px-6 transition-all duration-200 hover:border-blue-400 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700"
onclick={(e) => {
e.preventDefault();
dialogOpen = !dialogOpen;
}}
>
<svg
class="h-4 w-4 shrink-0 text-primary"
class="mr-3 h-5 w-5 text-gray-400 transition-colors group-hover:text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
</Popover.Trigger>
<span
class="text-gray-600 transition-colors group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white"
>
{label}
</span>
</Dialog.Trigger>
<Popover.Content
class="popover-dropdown w-(--bits-popover-anchor-width) min-w-[320px] p-0"
side="bottom"
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => {
e.preventDefault();
focusInput();
}}
>
<div class="flex flex-col">
<div class="p-3">
<div class="flex gap-2">
<div class="flex-1">
<Input
type="search"
{placeholder}
class="h-9"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
bind:value={searchQuery}
bind:ref={searchInputEl}
/>
</div>
<Button
variant="outline"
size="default"
class="h-9 px-2.5"
title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</Button>
</div>
</div>
<Dialog.Portal>
<Dialog.Overlay class="bg-black/20 backdrop-blur-sm" />
<div class="max-h-[min(400px,50vh)] overflow-y-auto px-3 pb-3">
{#await results}
<div class="flex h-20 items-center justify-center">
<div class="flex items-center space-x-2">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Searching...</span>
<Dialog.Content
class="top-[10%] flex max-h-[calc(100vh-10%)] min-h-[500px] translate-y-0 flex-col overflow-hidden rounded-2xl border-border bg-white shadow-2xl sm:max-w-[700px] dark:bg-gray-800"
>
<Dialog.Header class="pb-6">
<Dialog.Title class="text-center text-2xl font-bold">Find Your Location</Dialog.Title>
<p class="text-center text-gray-600 dark:text-gray-300">
Search for a city or use GPS to detect your location
</p>
</Dialog.Header>
<div class="flex-1 overflow-hidden">
<div class="px-6">
<div class="mb-6 flex gap-3">
<div class="flex-1">
<Input
type="search"
{placeholder}
class="h-12 text-lg"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
bind:value={searchQuery}
/>
</div>
</div>
{:then results}
{#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"
>
<svg
class="mt-0.5 h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<Button
variant="outline"
size="lg"
class="px-4"
title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-xs">
Start typing to search or use GPS to detect your position
</span>
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</Button>
</div>
</div>
<div class="flex-1 overflow-y-auto px-6 pb-6">
{#await results}
<div class="flex h-32 items-center justify-center">
<div class="flex items-center space-x-3">
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span class="text-gray-600 dark:text-gray-300">Searching...</span>
</div>
</div>
{:then results}
{#if results.results && results.results.length === 0}
{#if searchQuery.length < 2}
<Alert.Root class="border-blue-200 bg-blue-50 dark:bg-blue-900/20">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<Alert.Description class="text-blue-700 dark:text-blue-300">
Start typing to search for locations or use GPS to detect your current position
</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root class="border-orange-200 bg-orange-50 dark:bg-orange-900/20">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different search term.
</Alert.Description>
</Alert.Root>
{/if}
{:else if !results.results}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{:else}
<div class="space-y-2">
{#each results.results || [] as location, i (i)}
<button
class="group w-full rounded-xl border border-gray-200 p-4 text-left transition-all duration-200 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:hover:bg-blue-900/20"
onclick={() => selectLocation(location)}
>
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center space-x-4">
<img
class="h-10 w-10 rounded-full shadow-md"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
<div class="flex-1">
<h3
class="font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400"
>
{location.name}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">
{location.admin1 || ''}
{location.country || ''}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{location.latitude?.toFixed(2)}°N {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p>
</div>
</div>
<svg
class="h-5 w-5 text-gray-400 group-hover:text-blue-500"
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}
{:else if results.results && results.results.length > 0}
<div class="space-y-0.5">
{#each results.results as location, i (i)}
{@render locationRow(location)}
{/each}
</div>
{:else if results.results}
<Alert.Root
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different term.
</Alert.Description>
</Alert.Root>
{:else}
{:catch error}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
<Alert.Description>Error: {error.message}</Alert.Description>
</Alert.Root>
{/if}
{:catch error}
<Alert.Root variant="destructive">
<Alert.Description>Error: {error.message}</Alert.Description>
</Alert.Root>
{/await}
{/await}
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
-172
View File
@@ -1,172 +0,0 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { type GeoLocation, type Theme, storedLocation, storedTheme } 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;
}
let { onMenuToggle }: Props = $props();
let location = $state(get(storedLocation));
storedLocation.subscribe((value) => {
location = value;
});
const themeCycle: Theme[] = ['system', 'light', 'dark'];
const themeTitles: Record<Theme, string> = {
system: 'Theme: follow system',
light: 'Theme: light',
dark: 'Theme: dark'
};
function cycleTheme() {
storedTheme.update(
(current) => themeCycle[(themeCycle.indexOf(current) + 1) % themeCycle.length]
);
}
function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation);
const currentPath = get(page).url.pathname;
if (currentPath.startsWith('/weather/compare')) {
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 }));
}
}
</script>
<header
class="topbar flex h-14 shrink-0 items-center gap-3 border-b border-topbar-border bg-topbar px-3 md:px-4"
>
<!-- 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"
onclick={onMenuToggle}
aria-label="Toggle menu"
>
<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="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<!-- 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"
>
<img
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
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">
{location.name}
{#if location.admin1 || location.country}
<span class="font-normal text-muted-foreground">
· {#if location.admin1}{location.admin1},&nbsp;
{/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 -->
<div class="w-full max-w-sm md:max-w-md">
<LocationSearch
label="Search location..."
on:location={(event) => {
navigateToLocation(event.detail);
}}
/>
</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>
.topbar {
z-index: 40;
}
</style>
+109 -144
View File
@@ -2,50 +2,30 @@
import { resolve } from '$app/paths';
import { page } from '$app/stores';
interface Props {
collapsed?: boolean;
onToggle?: () => void;
onMobileClose?: () => void;
}
let { collapsed = false, onToggle, onMobileClose }: Props = $props();
import { Button } from '$lib/components/ui/button';
const links = [
{
title: '7-Day Forecast',
url: '/weather/week' as const,
iconPaths: [
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
]
title: 'Current Weather',
url: '/weather/week',
description: 'Current conditions and overview',
icon: '🌡️'
},
{
title: 'Model Comparison',
url: '/weather/compare' as const,
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
url: '/weather/compare',
description: 'Compare multiple weather models',
icon: '📊'
},
{
title: '14-Day Forecast',
url: '/weather/14-day' as const,
iconPaths: [
'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,
// Heroicons "map" outline icon
iconPaths: [
'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7'
]
title: '14 Day Forecast',
url: '/weather/14-day',
description: 'Extended forecast with uncertainty',
icon: '📅'
}
];
let mobileNavOpened = $state(false);
let currentPath = $derived($page.url.pathname);
const isActive = (url: string) => {
@@ -53,126 +33,111 @@
};
</script>
<aside
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
class:w-55={!collapsed}
class:w-14={collapsed}
<nav
class="sticky top-0 z-50 border-b border-gray-200/50 bg-white/90 shadow-sm backdrop-blur-lg dark:border-gray-700/50 dark:bg-gray-900/90"
>
<!-- 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">
<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'}"
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"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<!-- rain falls from above onto the canopy -->
<path stroke-linecap="round" d="M4.5 3v.01M19.5 3v.01M8.5 1.5v.01M15.5 2.5v.01" />
<path stroke-linecap="round" d="M12 4.5V6" />
<path
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"
/>
<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>
{/if}
</a>
</div>
<!-- Navigation links -->
<nav class="flex-1 space-y-1 px-2 py-3">
{#each links as link (link.title)}
{@const active = isActive(link.url)}
<a
href={resolve(link.url)}
class="relative flex 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 {active
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
: ''}"
title={collapsed ? link.title : undefined}
onclick={onMobileClose}
>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<svg
class="h-4.5 w-4.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
{#each link.iconPaths as d (d)}
<path stroke-linecap="round" stroke-linejoin="round" {d} />
{/each}
</svg>
</div>
{#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">{link.title}</span>
{/if}
</a>
{/each}
</nav>
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
{#if onToggle}
<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"
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}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<div class="container mx-auto px-6">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<div class="flex items-center space-x-3">
<div class="rounded-lg bg-gradient-to-r from-blue-600 to-purple-600 p-2">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
stroke-width="2"
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>
</div>
{#if !collapsed}
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
{/if}
</button>
<a
href={resolve('/')}
class="text-xl font-bold text-gray-900 transition-colors hover:text-blue-600 dark:text-white"
>
Open-Meteo Weather
</a>
</div>
<!-- Desktop Navigation -->
<div class="hidden items-center space-x-2 md:flex">
{#each links as link (link.title)}
<Button
href={link.url}
variant={isActive(link.url) ? 'default' : 'ghost'}
size="sm"
class="group relative px-4 py-2 {isActive(link.url)
? 'bg-blue-600 text-white shadow-md'
: 'hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
>
<span class="mr-1">{link.icon}</span>
{link.title}
</Button>
{/each}
</div>
<!-- Mobile menu button -->
<div class="md:hidden">
<Button
variant="ghost"
size="sm"
class="rounded-lg"
onclick={() => (mobileNavOpened = !mobileNavOpened)}
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if mobileNavOpened}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
{/if}
</svg>
</Button>
</div>
</div>
{/if}
</aside>
<!-- Mobile Navigation -->
{#if mobileNavOpened}
<div class="border-t border-gray-200 py-4 md:hidden dark:border-gray-700">
<div class="space-y-2">
{#each links as link (link.title)}
<Button
href={link.url}
variant={isActive(link.url) ? 'default' : 'ghost'}
class="w-full justify-start py-3 {isActive(link.url) ? 'bg-blue-600 text-white' : ''}"
onclick={() => (mobileNavOpened = false)}
>
<div class="flex items-center space-x-3">
<span class="text-lg">{link.icon}</span>
<div class="text-left">
<div class="font-medium">{link.title}</div>
<div
class="text-xs {isActive(link.url)
? 'text-blue-100'
: 'text-gray-500 dark:text-gray-400'}"
>
{link.description}
</div>
</div>
</div>
</Button>
{/each}
</div>
</div>
{/if}
</div>
</nav>
<style>
.nav-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 60%;
border-radius: 0 3px 3px 0;
background: var(--sidebar-primary);
:global(.container) {
max-width: 1200px;
}
</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?.()}
-19
View File
@@ -1,19 +0,0 @@
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Portal from './popover-portal.svelte';
import Trigger from './popover-trigger.svelte';
import Root from './popover.svelte';
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal
};
@@ -1,7 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -1,34 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { type WithoutChildrenOrChild, cn } from '$lib/utils/ui.js';
import PopoverPortal from './popover-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -1,7 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -1,18 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>
@@ -1,7 +0,0 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
-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' });
}
File diff suppressed because it is too large Load Diff
+18 -111
View File
@@ -1,24 +1,24 @@
import { persisted } from 'svelte-persisted-store';
export interface GeoLocation {
id: number;
name: string;
latitude: number;
longitude: number;
elevation: number;
feature_code: string;
country_code: string | undefined;
admin1_id: number | undefined;
admin3_id?: number | undefined;
admin4_id?: number | undefined;
timezone: string;
population: number | undefined;
postcodes: string[] | undefined;
country_id: number | undefined;
country: string | undefined;
admin1: string | undefined;
admin3?: string | undefined;
admin4?: string | undefined;
id?: number;
name?: string;
latitude?: number;
longitude?: number;
elevation?: number;
feature_code?: string;
country_code?: string;
admin1_id?: number;
admin3_id?: number;
admin4_id?: number;
timezone?: string;
population?: number;
postcodes?: string[];
country_id?: number;
country?: string;
admin1?: string;
admin3?: string;
admin4?: string;
}
export const defaultLocation: GeoLocation = {
@@ -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)}`;
}
+30
View File
@@ -0,0 +1,30 @@
export interface Parameters {
latitude?: number | number[];
longitude?: number | number[];
hourly?: string[];
models?: string[];
daily?: string[];
current?: string[];
minutely_15?: string[];
timezone?: string;
location_mode?: string;
csv_coordinates?: string;
time_mode?: string;
past_days?: string;
forecast_days?: string;
end_date?: string;
start_date?: string;
past_hours?: string;
cell_selection?: string;
forecast_hours?: string;
past_minutely_15?: string;
temporal_resolution?: string;
forecast_minutely_15?: string;
tilt?: string;
azimuth?: string;
timeformat?: string;
wind_speed_unit?: string;
temperature_unit?: string;
precipitation_unit?: string;
[key: string]: any;
}
-61
View File
@@ -1,61 +0,0 @@
import { isSameDay as isSameDayDateFns } from 'date-fns';
import { formatInTimeZone, toZonedTime } from 'date-fns-tz';
/**
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
* Patterns: 'HH:mm' for 24h time, 'EEE' for short weekday, etc.
*/
export function formatZoned(date: Date, timeZone: string, pattern: string): string {
return formatInTimeZone(date, timeZone, pattern);
}
/**
* Checks if two dates are the same day in a specific timezone.
* Important for comparing weather forecast days against a selected date.
*/
export function isSameDayInZone(date1: Date, date2: Date, timeZone: string): boolean {
const z1 = toZonedTime(date1, timeZone);
const z2 = toZonedTime(date2, timeZone);
return isSameDayDateFns(z1, z2);
}
/**
* Gets the numeric hour (0-23) for a date in a specific timezone.
*/
export function getZonedHour(date: Date, timeZone: string): number {
return parseInt(formatInTimeZone(date, timeZone, 'H'), 10);
}
/**
* Returns a relative label like "Today", "Tomorrow", "Yesterday",
* or a formatted date string, all relative to the target timezone.
*/
export function getRelativeDayLabel(date: Date, timeZone: string): string {
const now = new Date();
const zonedDate = toZonedTime(date, timeZone);
const zonedNow = toZonedTime(now, timeZone);
if (isSameDayDateFns(zonedDate, zonedNow)) return 'Today';
const tomorrow = new Date(zonedNow);
tomorrow.setDate(tomorrow.getDate() + 1);
if (isSameDayDateFns(zonedDate, tomorrow)) return 'Tomorrow';
const yesterday = new Date(zonedNow);
yesterday.setDate(yesterday.getDate() - 1);
if (isSameDayDateFns(zonedDate, yesterday)) return 'Yesterday';
return formatInTimeZone(date, timeZone, 'EEE d MMM');
}
/**
* Formats a UTC offset in seconds to a string like "UTC+1" or "UTC-05:00"
*/
export function formatUtcOffset(offsetSeconds: number): string {
const sign = offsetSeconds >= 0 ? '+' : '-';
const abs = Math.abs(offsetSeconds);
const hours = Math.floor(abs / 3600);
const minutes = Math.floor((abs % 3600) / 60);
const pad = (n: number) => n.toString().padStart(2, '0');
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${pad(minutes)}`;
}
+18
View File
@@ -1,4 +1,9 @@
export * from './ui.ts';
export * from './meteo.ts';
export const isNumeric = (num: string | number) =>
(typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) &&
!isNaN(num as number);
export const pad = (n: string | number) => {
if (n === null || n === undefined) {
@@ -6,3 +11,16 @@ export const pad = (n: string | number) => {
}
return ('0' + n).slice(-2);
};
export function debounce<F extends (...args: unknown[]) => unknown>(
func: F,
timeout = 100
): (this: ThisParameterType<F>, ...args: Parameters<F>) => void {
let timer: ReturnType<typeof setTimeout>;
return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}
-127
View File
@@ -1,127 +0,0 @@
import { error, redirect } from '@sveltejs/kit';
import type { GeoLocation } 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;
}
return locationRoute + '_' + location.id;
}
export const coordinateLocation = (latitude: number, longitude: number): GeoLocation => ({
id: 0,
name: `${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude,
longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
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]));
}
let urlLocationName: string;
let urlLocationId: string | undefined;
if (urlLocation.includes('_')) {
const split = urlLocation.split('_');
urlLocationName = split[0];
urlLocationId = split[1];
} else if (/^\d+$/.test(urlLocation)) {
urlLocationName = '';
urlLocationId = urlLocation;
} else {
urlLocationName = urlLocation.includes('-') ? urlLocation.replace(/-/g, ' ') : 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)) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${encodeURIComponent(urlLocationId)}`
);
if (!res.ok) error(404, 'Location not found');
const candidate = await res.json();
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(urlLocationName)}&count=1&language=en&format=json`
);
if (!res.ok) error(404, 'Location not found');
const geocodingResponse = await res.json();
const candidate = geocodingResponse?.results?.[0];
if (!isGeoLocation(candidate)) error(404, 'Location not found');
location = candidate;
}
// 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)}/`;
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
}
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;
};
+7
View File
@@ -0,0 +1,7 @@
export function geoLocationNameToRoute(name: string): string {
// Placeholder implementation
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-*|-*$/g, '');
}
+3 -85
View File
@@ -1,50 +1,9 @@
<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';
import favicon from '$lib/assets/favicon.svg';
import './layout.css';
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);
const toggleSidebar = () => {
sidebarCollapsed = !sidebarCollapsed;
};
const toggleMobileMenu = () => {
mobileMenuOpen = !mobileMenuOpen;
};
const closeMobileMenu = () => {
mobileMenuOpen = false;
};
</script>
<svelte:head>
@@ -53,47 +12,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<div class="flex h-screen overflow-hidden bg-background text-foreground">
<!-- Desktop sidebar -->
<div class="hidden h-full shrink-0 md:block">
<WeatherNav collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
</div>
<!-- Mobile overlay -->
{#if mobileMenuOpen}
<div class="fixed inset-0 z-50 md:hidden" role="presentation">
<!-- 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 }}
>
<WeatherNav collapsed={false} onMobileClose={closeMobileMenu} />
</div>
</div>
{/if}
<!-- Main area: topbar + content -->
<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}
{@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>
<main class="min-h-screen">
{@render children()}
</main>
-4
View File
@@ -1,5 +1 @@
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';
+269 -1
View File
@@ -1,3 +1,271 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings';
import { Button } from '$lib/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
let mounted = $state(false);
let location = $derived($storedLocation);
onMount(() => {
mounted = true;
});
const features = [
{
title: 'Week Prediction',
description:
'Get detailed hourly weather forecasts for the next 7 days with interactive charts and temperature gradients.',
href: `/weather`,
icon: 'calendar',
gradient: 'from-blue-500 to-cyan-500'
},
{
title: 'Model Comparison',
description:
'Compare multiple weather models side-by-side to understand forecast uncertainty and accuracy.',
href: '/weather/compare',
icon: 'trending-up',
gradient: 'from-purple-500 to-pink-500'
},
{
title: '14 Day Weather',
description:
'Extended forecast with ensemble model spreads showing temperature ranges and uncertainty.',
href: '/weather/14-day',
icon: 'cloud',
gradient: 'from-green-500 to-blue-500'
}
];
</script>
<svelte:head>
<title>Drizzli</title>
<title>Open-Meteo Weather - Advanced Weather Forecasting</title>
<meta
name="description"
content="Professional weather forecasting with multiple models, extended forecasts, and detailed comparisons. Powered by Open-Meteo API."
/>
</svelte:head>
<div
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
>
<!-- Hero Section -->
<div class="container mx-auto px-6 pt-20 pb-16">
{#if mounted}
<div class="mb-16 text-center" in:fade={{ duration: 800, delay: 200 }}>
<h1
class="mb-6 bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600 bg-clip-text text-5xl font-bold text-transparent md:text-7xl"
>
Open-Meteo Weather
</h1>
<p class="mx-auto mb-8 max-w-3xl text-xl text-gray-600 md:text-2xl dark:text-gray-300">
Professional weather forecasting with advanced models, detailed comparisons, and extended
predictions
</p>
<!-- Quick Access Button -->
<div in:fly={{ y: 20, duration: 600, delay: 600 }}>
<Button
href="/weather"
size="lg"
class="bg-gradient-to-r from-blue-600 to-purple-600 px-8 py-3 text-lg text-white shadow-lg transition-all duration-300 hover:from-blue-700 hover:to-purple-700 hover:shadow-xl"
>
View Current Weather
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</Button>
</div>
</div>
{/if}
<!-- Features Grid -->
<div class="mb-20 grid gap-8 md:grid-cols-3">
{#each features as feature, index (feature.title)}
{#if mounted}
<div in:fly={{ y: 30, duration: 600, delay: 300 + index * 150 }}>
<Card
class="h-full border-0 bg-white/80 shadow-lg backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl dark:bg-gray-800/80"
>
<CardHeader>
<div
class="h-12 w-12 rounded-xl bg-gradient-to-r {feature.gradient} mb-4 flex items-center justify-center"
>
{#if feature.icon === 'calendar'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{:else if feature.icon === 'trending-up'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
{:else if feature.icon === 'cloud'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
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>
{/if}
</div>
<CardTitle class="mb-2 text-xl">{feature.title}</CardTitle>
<CardDescription class="text-gray-600 dark:text-gray-300">
{feature.description}
</CardDescription>
</CardHeader>
<CardContent class="pt-0">
<Button
href={feature.href}
variant="outline"
class="w-full hover:bg-gradient-to-r hover:{feature.gradient} transition-all duration-300 hover:border-transparent hover:text-white"
>
Explore {feature.title}
</Button>
</CardContent>
</Card>
</div>
{/if}
{/each}
</div>
<!-- Current Weather for Selected Location -->
{#if mounted && location}
<div class="mx-auto max-w-2xl" in:fade={{ duration: 600, delay: 800 }}>
<Card
class="border-blue-200 bg-gradient-to-r from-blue-500/10 to-purple-500/10 dark:border-blue-700"
>
<CardHeader>
<CardTitle class="text-center text-2xl">
Current Location: {location.name}
</CardTitle>
<CardDescription class="text-center">
{location.country}{location.latitude?.toFixed(2)}°, {location.longitude?.toFixed(
2
)}°
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<Button href="/weather" variant="default" class="bg-blue-600 hover:bg-blue-700">
View Detailed Forecast
</Button>
</CardContent>
</Card>
</div>
{/if}
</div>
<!-- Features Overview Section -->
<div class="bg-white/50 py-20 dark:bg-gray-800/50">
<div class="container mx-auto px-6">
{#if mounted}
<div class="mb-16 text-center" in:fade={{ duration: 600, delay: 1000 }}>
<h2 class="mb-4 text-4xl font-bold text-gray-800 dark:text-white">
Why Choose Our Weather Service?
</h2>
<p class="mx-auto max-w-2xl text-xl text-gray-600 dark:text-gray-300">
Powered by Open-Meteo API with multiple weather models and advanced visualization
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Highcharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
>
<svg
class="h-8 w-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if feature.icon === 'layers'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
{:else if feature.icon === 'clock'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if feature.icon === 'bar-chart'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
{:else if feature.icon === 'refresh'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
{/if}
</svg>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-800 dark:text-white">
{feature.title}
</h3>
<p class="text-gray-600 dark:text-gray-300">{feature.desc}</p>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
:global(.container) {
max-width: 1200px;
}
</style>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
+55 -69
View File
@@ -6,75 +6,71 @@
:root {
--radius: 0.625rem;
--background: oklch(0.985 0.002 90);
--foreground: oklch(0.205 0.02 60);
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.205 0.02 60);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.205 0.02 60);
--primary: oklch(0.65 0.17 55);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.965 0.01 85);
--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);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.91 0.01 80);
--input: oklch(0.91 0.01 80);
--ring: oklch(0.65 0.17 55);
--chart-1: oklch(0.65 0.17 55);
--chart-2: oklch(0.62 0.16 250);
--chart-3: oklch(0.75 0.15 75);
--chart-4: oklch(0.55 0.12 250);
--chart-5: oklch(0.8 0.14 65);
--sidebar-foreground: oklch(0.3 0.02 60);
--sidebar-primary: oklch(0.65 0.17 55);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.95 0.03 70);
--sidebar-accent-foreground: oklch(0.3 0.02 60);
--sidebar-border: oklch(0.92 0.01 80);
--sidebar-ring: oklch(0.65 0.17 55);
--sidebar: oklch(0.99 0.003 85);
--topbar-bg: oklch(1 0 0);
--topbar-border: oklch(0.92 0.01 80);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.17 0.015 60);
--foreground: oklch(0.96 0.005 85);
--card: oklch(0.22 0.015 60);
--card-foreground: oklch(0.96 0.005 85);
--popover: oklch(0.22 0.015 60);
--popover-foreground: oklch(0.96 0.005 85);
--primary: oklch(0.72 0.17 55);
--primary-foreground: oklch(0.15 0.02 60);
--secondary: oklch(0.26 0.015 60);
--secondary-foreground: oklch(0.96 0.005 85);
--muted: oklch(0.26 0.015 60);
--muted-foreground: oklch(0.65 0.02 60);
--accent: oklch(0.65 0.16 250);
--accent-foreground: oklch(0.96 0.005 85);
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.72 0.17 55);
--chart-1: oklch(0.72 0.17 55);
--chart-2: oklch(0.65 0.16 250);
--chart-3: oklch(0.8 0.14 65);
--chart-4: oklch(0.6 0.2 300);
--chart-5: oklch(0.7 0.22 20);
--sidebar-foreground: oklch(0.96 0.005 85);
--sidebar-primary: oklch(0.72 0.17 55);
--sidebar-primary-foreground: oklch(0.15 0.02 60);
--sidebar-accent: oklch(0.26 0.02 55);
--sidebar-accent-foreground: oklch(0.96 0.005 85);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.72 0.17 55);
--sidebar: oklch(0.19 0.015 60);
--topbar-bg: oklch(0.2 0.015 60);
--topbar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
@@ -113,19 +109,9 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-topbar: var(--topbar-bg);
--color-topbar-border: var(--topbar-border);
}
@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;
}
+4 -3
View File
@@ -1,13 +1,14 @@
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render the redirect page with correct title', async () => {
it('should render h1', async () => {
render(Page);
const title = document.querySelector('title');
expect(title?.textContent).toBe('Drizz.li');
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});
+208 -1
View File
@@ -1,9 +1,216 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { page } from '$app/stores';
import { storedLocation } from '$lib/stores/settings';
import LocationSearch from '$lib/components/location/location-search.svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
interface CurrentWeather {
current: {
temperature_2m: number;
weather_code: number;
};
}
let location = $state(get(storedLocation));
let mounted = $state(false);
let currentWeather = $state<CurrentWeather | null>(null);
// Subscribe to location changes
storedLocation.subscribe((value) => {
location = value;
if (mounted) {
loadCurrentWeather();
}
});
onMount(() => {
mounted = true;
loadCurrentWeather();
});
const loadCurrentWeather = async () => {
if (!location?.latitude) return;
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,weather_code&forecast_days=1`
);
const data = await response.json();
currentWeather = data;
} catch (error) {
console.error('Failed to load current weather:', error);
}
};
const getWeatherIcon = (code: number): string => {
const iconMap: Record<number, string> = {
0: '☀️',
1: '🌤️',
2: '⛅',
3: '☁️',
45: '🌫️',
48: '🌫️',
51: '🌦️',
53: '🌦️',
55: '🌦️',
61: '🌧️',
63: '🌧️',
65: '🌧️',
71: '🌨️',
73: '🌨️',
75: '❄️',
95: '⛈️'
};
return iconMap[code] || '☁️';
};
const getPageTitle = () => {
const path = $page.url.pathname;
if (path.includes('/compare')) return 'Model Comparison';
if (path.includes('/14-day')) return '14 Day Forecast';
if (path.includes('/week')) return 'Week Prediction';
return 'Weather Forecast';
};
</script>
{@render children?.()}
<WeatherNav />
<div
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
>
<!-- Hero Header Section -->
<div class="relative overflow-hidden bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600">
<div class="absolute inset-0 bg-black/10"></div>
<div class="relative">
<div class="container mx-auto px-6 py-12">
{#if mounted}
<div class="flex flex-col items-center space-y-6" in:fade={{ duration: 800 }}>
<!-- Page Title -->
<div class="text-center" in:fly={{ y: -20, duration: 600, delay: 200 }}>
<h1 class="mb-2 text-3xl font-bold text-white md:text-4xl">
{getPageTitle()}
</h1>
</div>
<!-- Location Display & Search -->
<div class="w-full max-w-2xl" in:fly={{ y: 20, duration: 600, delay: 400 }}>
<div
class="rounded-2xl bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:bg-gray-800/95"
>
<!-- Current Location Display -->
{#if location}
<div
class="flex flex-col items-center justify-between space-y-4 md:flex-row md:space-y-0 md:space-x-6"
>
<!-- Location Info -->
<div class="flex flex-1 items-center space-x-4">
<div class="flex-shrink-0">
<img
class="h-12 w-12 rounded-full shadow-lg"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
</div>
<div class="flex-1 text-left">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{location.name}
</h2>
<p class="text-gray-600 dark:text-gray-300">
{#if location.admin1}{location.admin1},
{/if}{location.country}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{location.latitude?.toFixed(2)}°N, {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p>
</div>
</div>
<!-- Current Weather -->
{#if currentWeather}
<div class="flex items-center space-x-3" in:fade={{ delay: 800 }}>
<div class="text-center">
<div class="mb-1 text-3xl">
{getWeatherIcon(currentWeather.current.weather_code)}
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(currentWeather.current.temperature_2m)}°C
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Location Search -->
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-gray-600">
<LocationSearch
label="🔍 Change location or search for a new city..."
on:location={(event) => {
storedLocation.set(event.detail);
}}
/>
</div>
</div>
</div>
<!-- Stats Row (if location has population) -->
{#if location?.population}
<div
class="flex justify-center space-x-8 text-white/90"
in:fly={{ y: 20, duration: 600, delay: 600 }}
>
<div class="text-center">
<div class="text-sm font-medium">Population</div>
<div class="text-lg font-bold">{location.population.toLocaleString()}</div>
</div>
{#if location.timezone}
<div class="text-center">
<div class="text-sm font-medium">Timezone</div>
<div class="text-lg font-bold">{location.timezone}</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Decorative elements -->
<div class="pointer-events-none absolute top-0 left-0 h-full w-full overflow-hidden">
<div class="absolute -top-4 -right-4 h-24 w-24 rounded-full bg-white/10"></div>
<div class="absolute top-1/3 -left-8 h-16 w-16 rounded-full bg-white/5"></div>
<div class="absolute bottom-8 left-1/4 h-12 w-12 rounded-full bg-white/10"></div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
{#if mounted}
<div in:fade={{ duration: 600, delay: 400 }}>
{@render children?.()}
</div>
{/if}
</div>
</div>
<style>
:global(.container) {
max-width: 1200px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => {
return {
title: `Weather ${location.name}`,
location: location
};
};
+4 -2
View File
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load = (async () => {
export const prerender = true;
export const load: PageLoad = async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
};
+14
View File
@@ -0,0 +1,14 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => {
return {
heroTitle: `14 Day Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+321 -13
View File
@@ -1,23 +1,331 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
// 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
import '../compare/highcharts.css';
import { defaultParameters } from './options';
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation);
// Local component state for chart configuration
let params = $state({
latitude: [52.52],
longitude: [13.41],
...defaultParameters,
hourly: ['temperature_2m'],
models: ['gfs_seamless']
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
const more = (await import('highcharts/highcharts-more')).default;
// more(Highcharts);
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default;
if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
);
}
});
$effect(() => {
const loadData = async () => {
count = 0;
if (Highcharts) {
node.replaceChildren();
const dataDaily = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
);
const wd = await dataDaily.json();
const dataReq = await fetch(
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&forecast_days=14`
);
const data = await dataReq.json();
let plotBands: any = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let rise = wd.daily.sunrise;
let set = wd.daily.sunset;
plotBands = rise.map(function (r: any, i: number) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
let minValues = new Array(data.hourly.time.length).fill(undefined);
let maxValues = new Array(data.hourly.time.length).fill(undefined);
for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of (values as any[]).entries()) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
if (minValues[index] > val || minValues[index] === undefined) {
minValues[index] = val;
}
if (maxValues[index] < val || maxValues[index] === undefined) {
maxValues[index] = val;
}
}
}
unit = data.hourly_units[model];
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
const minMax = [];
for (let [index, min] of minValues.entries()) {
minMax.push([min, maxValues[index]]);
}
series.push({
name: 'temperature_2m_spread',
data: minMax,
type: 'arearange',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-spread-series'
});
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts!.Chart({
chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: 50,
marginRight: 0
},
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Spread' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
(params.models?.join(', ') || '') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series as any,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
}
}
};
loadData();
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]"
>
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
</div>
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
@@ -1,392 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { storedEnsembleModel, storedLocation, storedUnits } from '$lib/stores/settings';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
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,
fetchEnsembleForecast
} from '$lib/services/weather';
import { defaultParameters, ensembleModelGroups } from '../../options';
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
import type { PageData } from './$types';
const CHART_GROUP = '14-day-ensemble';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(true);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: CanvasChart[] = $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 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;
});
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
ensembleResult: EnsembleForecastResult;
timestamps: number[];
timezone: string;
daylightBands: DaylightBand[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── 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));
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true;
loadError = null;
fetchEnsembleForecast({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: hourlyVars,
models: modelList,
forecast_days: 14,
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((result: EnsembleForecastResult) => {
if (version !== requestVersion) return;
fetchedData = {
ensembleResult: result,
timestamps: result.timestamps,
timezone: result.timezone,
daylightBands: result.daylightBands
};
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 [];
const { ensembleResult } = fetchedData;
const variables = params.hourly || [];
const defs: ChartDef[] = [];
for (let vi = 0; vi < variables.length; vi++) {
const variable = variables[vi];
const varData = ensembleResult.variables[variable];
if (!varData) continue;
const unit = varData.unit;
const isColumn = isColumnUnit(unit);
const memberCount = varData.members.length;
// Trim to the valid horizon so the axis scale ignores the trailing
// zeros the service pads past the model's range.
const vMax = varData.max.slice(0, validLength);
const vMin = varData.min.slice(0, validLength);
const vAvg = varData.average.slice(0, validLength);
// Min/max spread band + mean, instead of every individual member
const series: ChartSeries[] = [
{
name: 'Max',
type: 'line',
color: BAND_COLOR,
data: 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 isFirst = vi === 0;
const isLast = vi === variables.length - 1;
const axis = axisForVar(variable);
defs.push({
// each chart is labelled so the variable is obvious at a glance
title: `${varLabel(variable)}${isColumn ? '' : ' spread'}`,
subtitle: isFirst
? `min · mean · max across ${memberCount} ensemble members`
: `min · mean · max (${unit})`,
unit,
showCredit: isLast,
series,
zeroBaseLeft: axis.zeroBaseLeft,
yMin: axis.yMin,
yMax: axis.yMax
});
}
return defs;
});
</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"
>
<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
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}
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="14-day-forecast">
{#snippet controls()}
<div class="flex items-center 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>
</div>
{/snippet}
</ChartToolbar>
</div>
@@ -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/14-day/',
event
});
return { location };
};
+7
View File
@@ -0,0 +1,7 @@
// Default configuration for 14-day ensemble forecast charts
export const defaultParameters = {
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
+81
View File
@@ -0,0 +1,81 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined
): void => {
if (ctx && series) {
const fillColor = `hsla(${config.styles.mutedForeground}, 0.5)`;
ctx.beginPath();
ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30);
for (const [index, value] of series.entries()) {
ctx.strokeStyle = '#444';
ctx.lineWidth = 0.1;
const nextValue = series[index + 1];
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX + 1.5 * config.deltaX)) /
2;
const yc = (35 + (value ** 1.5 / 1000) * 30 + 35 + (nextValue ** 1.5 / 1000) * 30) / 2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
35 + (value ** 1.5 / 1000) * 30,
xc,
yc
);
}
ctx.quadraticCurveTo(
config.maxX,
35 + (series[series.length - 1] ** 1.5 / 1000) * 30,
config.maxX,
35 + (series[series.length - 1] ** 1.5 / 1000) * 30
);
ctx.quadraticCurveTo(
config.maxX,
35 - (series[series.length - 1] ** 1.5 / 1000) * 30,
config.maxX,
35 - (series[series.length - 1] ** 1.5 / 1000) * 30
);
// same series but reversed
for (const [ind, _v] of series.entries()) {
const index = series.length - 1 - ind;
const value = series[index];
const nextValue = series[index - 1];
ctx.strokeStyle = '#444';
ctx.lineWidth = 0.1;
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX - 0.5 * config.deltaX)) /
2;
const yc = (35 - (value ** 2 / 10000) * 30 + (35 - (nextValue ** 2 / 10000) * 30)) / 2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
35 - (value ** 2 / 10000) * 30,
xc,
yc
);
}
ctx.quadraticCurveTo(
0.5 * config.deltaX,
35 - (series[0] ** 1.5 / 1000) * 30,
0,
35 - (series[0] ** 1.5 / 1000) * 30
);
ctx.closePath();
// USE PRE-CALC STYLE
ctx.fillStyle = fillColor;
ctx.fill();
}
};
+22
View File
@@ -0,0 +1,22 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Date[]
): void => {
if (ctx) {
for (const [index, value] of series.entries()) {
if (value.getHours() > 6 && value.getHours() < 21) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX, config.maxY);
ctx.lineTo(index * config.deltaX, 0);
ctx.lineTo((index + 1) * config.deltaX, 0);
ctx.lineTo((index + 1) * config.deltaX, config.maxY);
ctx.closePath();
ctx.fillStyle = '#f4ff0014';
ctx.fill();
}
}
}
};
+23
View File
@@ -0,0 +1,23 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined
): void => {
if (ctx && series) {
const strokeStyle = `hsla(${config.styles.primary}, 1)`;
for (const [index, value] of series.entries()) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY);
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = 12;
ctx.lineTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY - value ** 0.55 * 45);
ctx.stroke();
ctx.closePath();
}
}
};
+43
View File
@@ -0,0 +1,43 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Date[],
today: Date,
canvasElement: HTMLCanvasElement
): void => {
if (ctx && series) {
for (const [index, _v] of series.entries()) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX, 0);
ctx.lineTo(index * config.deltaX, config.maxY);
if (series[index].getHours() === 0) {
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 1)`;
ctx.lineWidth = 3;
} else if (
series[index].getDate() === today.getDate() &&
series[index].getHours() === today.getHours()
) {
// fill now line
ctx.stroke();
ctx.closePath();
ctx.beginPath();
ctx.strokeStyle = 'red';
ctx.lineWidth = 5;
const minutes = today.getMinutes();
ctx.moveTo(index * config.deltaX + (config.deltaX / 60) * minutes, 0);
ctx.lineTo(index * config.deltaX + (config.deltaX / 60) * minutes, config.maxY);
ctx.stroke();
ctx.closePath();
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
ctx.lineWidth = 1;
}
ctx.stroke();
ctx.closePath();
}
}
};
@@ -0,0 +1,62 @@
import { getColor } from '../utils/colors';
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
unit = 'celsius'
): void => {
if (ctx && series) {
const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY);
tempGradientFill.addColorStop(0, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.25, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.85, getColor(config.minTemp, unit) + '06');
tempGradientFill.addColorStop(1, getColor(config.minTemp, unit) + '00');
ctx.beginPath();
ctx.moveTo(
0,
0.25 * config.maxY + ((config.maxTemp - series[0]) / config.diffTemp) * 0.55 * config.maxY
);
for (const [index, value] of series.filter((t) => !isNaN(t)).entries()) {
const indexDiffTemp = config.maxTemp - value;
const indexDiffTempNext = config.maxTemp - series[index + 1];
ctx.strokeStyle = '#d3d3d3';
ctx.lineWidth = 4;
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX + 1.5 * config.deltaX)) /
2;
const yc =
(0.25 * config.maxY +
(indexDiffTemp / config.diffTemp) * 0.55 * config.maxY +
(0.25 * config.maxY + (indexDiffTempNext / config.diffTemp) * 0.55 * config.maxY)) /
2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
0.25 * config.maxY + (indexDiffTemp / config.diffTemp) * 0.55 * config.maxY,
xc,
yc
);
}
ctx.quadraticCurveTo(
config.maxX,
0.25 * config.maxY +
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY,
(config.maxX + config.maxX + config.deltaX) / 2,
0.25 * config.maxY +
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY
);
ctx.lineTo(config.maxX, config.maxY);
ctx.lineTo(0, config.maxY);
ctx.closePath();
ctx.fillStyle = tempGradientFill;
ctx.fill();
}
};
+14
View File
@@ -0,0 +1,14 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => {
return {
heroTitle: `Model Compare ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+419 -13
View File
@@ -1,23 +1,429 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
// 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
import { hourly, models } from '../options';
import './highcharts.css';
import { defaultParameters } from './options';
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation);
let params = $state({
latitude: [52.52],
longitude: [13.41],
...defaultParameters,
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
models: [
'ecmwf_ifs025',
'meteofrance_seamless',
'ukmo_seamless',
'icon_seamless',
'gem_seamless'
]
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (
await import('highcharts/es-modules/Extensions/Debugger/Debugger.js' as any)
).default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js' as any)
).default;
if (Highcharts) {
(Highcharts as any).errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
);
}
});
$effect(() => {
const loadData = async () => {
count = 0;
if (Highcharts) {
node.replaceChildren();
const dataReq = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise`
);
const data = await dataReq.json();
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_');
let plotBands: any = [];
if (
'daily' in data &&
'sunrise_' + dailyFirstModelKey in data.daily &&
'sunset_' + dailyFirstModelKey in data.daily
) {
let rise = data.daily['sunrise_' + dailyFirstModelKey];
let set = data.daily['sunset_' + dailyFirstModelKey];
plotBands = rise.map(function (r: any, i: number) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
for (let variable of params.hourly || []) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of (values as any[]).entries()) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
}
}
unit = data.hourly_units[model];
if (!averageOnly) {
series.push({
name: model,
data: values,
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval
});
}
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts!.Chart({
chart: {
renderTo: chartDiv,
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: 50,
marginRight: 0
},
credits: {
text: count === (params.hourly?.length || 0) - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Compare' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${params.hourly?.join(', ') || ''}</span> in models: <span class="font-bold">` +
(params.models?.join(', ') || '') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series as any,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
}
}
};
loadData();
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div
class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * (params.hourly?.length || 0) +
2}px]"
>
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
</div>
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.models?.length || 0}&nbsp;/&nbsp;{models.length}
</div>
</div>
{/if}
</div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
<div class="mb-3">
{#each models as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_model"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.models?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.models?.includes(value)) {
params.models = params.models.filter((item) => {
return item !== value;
});
} else if (params.models) {
params.models.push(value);
params.models = params.models;
}
}}
/>
<Label
id="{value}_model_label"
for="{value}_model"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
</div>
<!-- HOURLY -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.hourly?.length || 0}&nbsp;/&nbsp;{hourly.flat().length}
</div>
</div>
{/if}
</div>
<div
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{#each hourly as group, i (i)}
<div>
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.hourly?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.hourly?.includes(value)) {
params.hourly = params.hourly.filter((item) => {
return item !== value;
});
} else if (params.hourly) {
params.hourly.push(value);
params.hourly = params.hourly;
}
}}
/>
<Label
id="{value}_label"
for="{value}_hourly"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
@@ -1,528 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { storedLocation, storedModel, storedUnits } from '$lib/stores/settings';
import { formatZoned } from '$lib/utils/date';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
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 ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
import { findModel, hourly, modelGroups } from '../../options';
import { defaultParameters } from '../../options';
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
const models = modelGroups.map((group) => group.models);
const CHART_GROUP = 'model-compare';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: CanvasChart[] = $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 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;
});
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
timezone: string;
daylightBands: DaylightBand[];
timestamps: number[];
sunrise: number[];
sunset: number[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── 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));
// ─── Zoom range controls (mirrors the 7-day meteograms) ─────────────────────
const SECONDS_PER_DAY = 24 * 3600;
function dayStartSec(day: Date): number | null {
if (!fetchedData) return null;
const tz = fetchedData.timezone;
const target = formatZoned(day, tz, 'yyyy-MM-dd');
const idx = fetchedData.timestamps.findIndex(
(t) => formatZoned(new Date(t), tz, 'yyyy-MM-dd') === target
);
return idx === -1 ? null : fetchedData.timestamps[idx] / 1000;
}
function setRangeDays(from: Date, days: number): void {
const start = dayStartSec(from);
if (start == null || liveCharts.length === 0) return;
// charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(start, start + days * SECONDS_PER_DAY);
}
function resetZoom(): void {
liveCharts[0]?.resetRange();
}
const rangePresets = [
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
{ label: 'All', apply: () => resetZoom() }
];
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
// versioned so a slow stale response can never overwrite a newer one
const version = ++requestVersion;
loading = true;
loadError = null;
fetchModelComparison({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
models: modelList,
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((result: ModelCompareResult) => {
if (version !== requestVersion) return;
fetchedData = {
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
timezone: result.timezone,
daylightBands: result.daylightBands,
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 [];
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
const variableCount = chartVariables.length;
const timeLength = timestamps.length;
const defs: ChartDef[] = [];
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[] = [];
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 { 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 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,
showCredit: isLast,
series
});
}
return defs;
});
</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
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}
{#if fetchedData && !loading}
<ModelPictogramTimeline
timestamps={fetchedData.timestamps}
hourlyFlat={fetchedData.hourly as Record<string, number[]>}
models={params.models || []}
sunrise={fetchedData.sunrise}
sunset={fetchedData.sunset}
timezone={fetchedData.timezone}
/>
{/if}
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={liveCharts} fileName="model-comparison">
{#snippet controls()}
<div class="flex items-center 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>
</div>
{/snippet}
</ChartToolbar>
</div>
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.models?.length || 0}&nbsp;/&nbsp;{models.flat().length}
</div>
</div>
{/if}
</div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{#each models as group, i (i)}
<div class="mb-3">
{#each group as item (item.value)}
{@const { value, label } = item as { value: string; label: string }}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_model"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.models?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.models?.includes(value)) {
params.models = params.models.filter((item) => {
return item !== value;
});
} else if (params.models) {
params.models = [...params.models, value];
}
}}
/>
<Label
id="{value}_model_label"
for="{value}_model"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.hourly?.length || 0}&nbsp;/&nbsp;{hourly.flat().length}
</div>
</div>
{/if}
</div>
<div
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{#each hourly as group, i (i)}
<div>
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.hourly?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.hourly?.includes(value)) {
params.hourly = params.hourly.filter((item) => {
return item !== value;
});
} else if (params.hourly) {
params.hourly = [...params.hourly, value];
}
}}
/>
<Label
id="{value}_label"
for="{value}_hourly"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
@@ -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/compare/',
event
});
return { location };
};
@@ -1,152 +0,0 @@
<script lang="ts">
import { formatZoned, getZonedHour } from '$lib/utils/date';
import { getWeatherIconName } from '../../utils/weather-codes';
interface Props {
timestamps: number[];
hourlyFlat: Record<string, number[]>;
models: string[];
sunrise: number[];
sunset: number[];
timezone: string;
}
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
let hourlyInterval = $state<1 | 3>(3);
let filteredIndices = $derived(
timestamps.reduce<number[]>((acc, ts, i) => {
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
acc.push(i);
}
return acc;
}, [])
);
function checkNewDay(i: number, ts: number): boolean {
if (i === 0) return false;
const prevTs = timestamps[filteredIndices[i - 1]];
return (
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
);
}
/**
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
*/
let allDaytimeFlags = $derived(
timestamps.map((ts) => {
const tsS = ts / 1000;
for (let i = 0; i < sunrise.length; i++) {
const s = sunrise[i];
const e = sunset[i];
// Between sunrise and sunset of the same day
if (tsS >= s && tsS < e) return true;
// Between sunset of day i and sunrise of day i+1 (night)
const nextS = sunrise[i + 1] || Infinity;
if (tsS >= e && tsS < nextS) return false;
}
// Fallback: before the first sunrise
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
return true;
})
);
/**
* Filters models that actually have weather_code data available in the response.
*/
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
</script>
{#snippet weatherIcon(name: string, size: number = 24)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#if displayModels.length > 0}
<div class="mt-8">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
<span class="select-none text-muted-foreground">3h</span>
<button
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
title="Toggle between 1-hour and 3-hour intervals"
>
<span
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
></span>
</button>
<span class="select-none text-muted-foreground">1h</span>
</div>
</div>
<div
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
style="scrollbar-width: thin"
>
<table class="w-full border-collapse">
<thead>
<tr class="bg-muted/30">
<th
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
>
Model
</th>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<th
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
? 'border-l-2 border-l-primary/30'
: ''}"
>
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
<div class="text-muted-foreground">
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
</div>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each displayModels as model (model)}
<tr class="group hover:bg-muted/10">
<td
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
>
{model.replace(/_/g, ' ')}
</td>
{#each filteredIndices as idx, i (idx)}
{@const ts = timestamps[idx]}
{@const codes = hourlyFlat[`weather_code_${model}`]}
{@const code = codes ? codes[idx] : 0}
{@const day = allDaytimeFlags[idx]}
{@const isNewDay = checkNewDay(i, ts)}
<td
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
? 'border-l-2 border-l-primary/20'
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
>
{@render weatherIcon(getWeatherIconName(code, day))}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<style>
th,
td {
white-space: nowrap;
}
</style>
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
// Default configuration for weather comparison charts
export const defaultParameters = {
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
-8
View File
@@ -1,8 +0,0 @@
interface ConfigInterface {
maxX: number;
maxY: number;
deltaX: number;
minTemp: number;
maxTemp: number;
diffTemp: number;
}
+13
View File
@@ -0,0 +1,13 @@
export interface ConfigInterface {
maxX: number;
maxY: number;
deltaX: number;
minTemp: number;
maxTemp: number;
diffTemp: number;
styles: {
mutedForeground: string;
primary: string;
border: string;
};
}
@@ -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"
]
-99
View File
@@ -1,99 +0,0 @@
<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 });
});
</script>
<svelte:head>
<title>Weather Map | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather/maps" />
<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) -->
<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"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
></iframe>
</div>
+13 -430
View File
@@ -5,310 +5,21 @@ 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: 'gfs_seamless', label: 'NCEP GFS Seamless' },
{ value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'kma_seamless', label: 'KMA Seamless' },
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
{ value: 'gem_seamless', label: 'GEM Seamless' },
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
];
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 +62,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;
};
+75 -16
View File
@@ -1,20 +1,79 @@
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;
export const getTempColor = (temperature: number, unit = 'celsius'): RGBA =>
sampleScale(temperatureScale.breakpoints, temperatureScale.colors, toCelsius(temperature, unit));
export const getColor = (temperature: number, unit = 'celsius'): string =>
rgbaCss(getTempColor(temperature, unit));
export interface TempStyle {
bg: string;
fg: 'white' | 'black';
function 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 getTempStyle = (temp: number, unit: string): TempStyle => {
const rgba = getTempColor(temp, unit);
return { bg: rgbaCss(rgba), fg: needsWhiteText(rgba) ? 'white' : 'black' };
export function rgbToHex(rgb: 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]));
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
if (!rgb) {
return '355522';
}
return hex;
}
export const getColor = (value: number, unit = 'celsius'): string => {
let index = 0;
if (unit === 'celsius') {
if (value <= -40) {
index = 0;
} else if (value >= 60) {
index = colorScaleHex.length - 1;
} else {
index = value + 40;
}
} else {
const tempInCelsius = Math.round(((value - 32) * 5) / 9);
if (tempInCelsius <= -40) {
index = 0;
} else if (tempInCelsius >= 60) {
index = colorScaleHex.length - 1;
} else {
index = tempInCelsius + 40;
}
}
index = Math.floor(index);
return colorScaleHex[index];
};
export const textWhite = (hex: string): boolean => {
const cleaned = (hex || '').replace('#', '').trim().toLowerCase();
if (!cleaned) {
return true;
}
let r = 0,
g = 0,
b = 0;
if (cleaned.length === 6) {
r = parseInt(cleaned.slice(0, 2), 16);
g = parseInt(cleaned.slice(2, 4), 16);
b = parseInt(cleaned.slice(4, 6), 16);
} else {
throw new Error('Invalid color format');
}
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
return true;
}
// Perceived brightness (YIQ / luma). If brightness is low, use white text.
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
};
File diff suppressed because one or more lines are too long
+7 -16
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,4 @@ 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 name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
if (NEUTRAL_ICONS.has(name)) return `wi-${name}`;
return `wi-${daytime ? 'day' : 'night'}-${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>
+25
View File
@@ -0,0 +1,25 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from './$types';
export const prerender = true;
export const load: PageLoad = async () => {
const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name || '');
throw redirect(
303,
'/weather/week/' +
(location.population
? location.population > 543000
? locationRoute
: locationRoute + '_' + location.id
: locationRoute + '_' + location.id)
);
};
+639 -274
View File
@@ -1,321 +1,686 @@
<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 { fetchWeatherApi } from 'openmeteo';
import { ChartContainer } from '$lib/components/charts';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
import { pad } from '$lib/utils/index';
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 { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import type { PageData } from './$types';
import type { FetchedDaily, FetchedHourly } from './types';
import cloudCover from '../../canvas/cloud-cover';
import daylight from '../../canvas/daylight';
import precip from '../../canvas/precip';
import raster from '../../canvas/raster';
import tempGradient from '../../canvas/temp-gradient';
import { defaultParameters, models } from '../../options';
import { getColor } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
let { data }: { data: PageData } = $props();
import type { ConfigInterface } from '../../config';
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 location = $state($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let variableSidebarOpen = $state(false);
let diffTemp: number | undefined = $state();
let maxTemp: number | undefined = $state();
// 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);
let weatherCodesHourly: Float32Array | null | undefined = $state();
let canvasElement: HTMLCanvasElement | null | undefined = $state();
// 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)
)
const today = new Date();
let selectedDay = $state(new Date());
let selectedDayIndex = $state(1);
let entries = $state(0);
let weather = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [params.models],
hourly: [
'precipitation',
'precipitation_probability',
'temperature_2m',
'weather_code',
'windspeed_10m',
'winddirection_10m',
'cloud_cover',
'relative_humidity_2m'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: params.temperature_unit,
wind_speed_unit: params.wind_speed_unit,
precipitation_unit: params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const hourly = response.hourly()!;
weatherCodesHourly = hourly.variables(3)?.valuesArray();
let hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map(
(_, i) =>
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
);
const hourlyTemps = hourly.variables(2)?.valuesArray();
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
const hourlyPrecip = hourly.variables(0)?.valuesArray();
const indexes = [];
if (hourlyTemps) {
for (const index of hourlyTemps.keys()) {
indexes.push(index);
}
}
const maxX = 10000;
const maxY = 500;
const deltaX = 10000 / (hourly.variables(0)?.valuesArray()?.length || 1);
const ctx = canvasElement?.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, maxX, maxY);
const minTemp = Math.min(
...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t))
);
maxTemp = Math.max(...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t)));
diffTemp = maxTemp - minTemp;
const config: ConfigInterface = {
maxX: maxX,
maxY: maxY,
deltaX: deltaX,
minTemp: minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp,
styles: {
mutedForeground: '240 3.7% 15.9%',
primary: '222.2 47.4% 11.2%',
border: '214.3 31.8% 91.4%'
}
};
// create canvas
daylight(ctx, config, hourlyTime);
raster(ctx, config, hourlyTime, today, canvasElement!);
tempGradient(ctx, config, hourlyTemps, params.temperature_unit);
cloudCover(ctx, config, hourlyCloudCover);
precip(ctx, config, hourlyPrecip);
}
return {
entries: [
{
id: 0,
name: 'temperature_2m',
title: 'Temperature',
values: hourly
.variables(2)
?.valuesArray()
?.map((t) => Number(t.toFixed(1)))
},
{
id: 1,
name: 'precipitation',
title: 'Precipitation',
values: hourly
.variables(0)
?.valuesArray()
?.map((p) => Number(p.toFixed(1)))
},
{
id: 2,
name: 'precipitation_probability',
title: 'Precip Prob.',
values: hourly
.variables(1)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
},
{
id: 3,
name: 'windspeed_10m',
title: 'Wind',
values: hourly
.variables(4)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
},
{
id: 4,
name: 'relative_humidity_2m',
title: 'Rel. Hum.',
values: hourly
.variables(7)
?.valuesArray()
?.map((p) => Number(p.toFixed(0)))
}
],
entriesLength: hourly.variables(0)?.valuesArray()?.length,
hourlyTime: hourlyTime,
windDirections: hourly.variables(5)?.valuesArray(),
indexes: indexes
};
})(location)
);
// 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 weatherDaily = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [params.models],
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'windspeed_10m_max',
'windgusts_10m_max',
'winddirection_10m_dominant'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: params.temperature_unit,
wind_speed_unit: params.wind_speed_unit,
precipitation_unit: params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!;
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
let requestVersion = 0;
return {
daily: {
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
(_, i) =>
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
),
weather_code: daily.variables(0)!,
temperature_2m_max: daily.variables(1)!,
temperature_2m_min: daily.variables(2)!,
sunrise: daily.variables(3)!,
sunset: daily.variables(4)!,
sunshine_duration: daily.variables(5)!,
precipitation_sum: daily.variables(6)!,
windspeed_10m_max: daily.variables(7)!,
windgusts_10m_max: daily.variables(8)!,
winddirection_10m_dominant: daily.variables(9)!
}
};
})(location)
);
// 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;
let winddir = true;
entries = 6;
// 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);
let scrollDiv: HTMLElement | undefined = $state();
let tableCells;
const selectedDay = new SvelteDate();
const switchDay = (date: Date, index: number) => {
selectedDay = date;
let fetchedHourly: FetchedHourly | null = $state(null);
let fetchedDaily: FetchedDaily | null = $state(null);
tableCells = document.querySelectorAll('td.time');
// Charts intentionally keep their current range: they show the full week
// unless the user narrows it via the range presets or Ctrl+scroll.
const switchDay = (date: Date) => {
selectedDay.setTime(date.getTime());
for (let tableCell of tableCells) {
const htmlCell = tableCell as HTMLElement;
if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110, behavior: 'smooth' });
break;
}
}
selectedDayIndex = index;
};
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));
setTimeout(() => {
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
const htmlCell = tableCell as HTMLElement;
if (Number(htmlCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv?.scrollTo({ left: htmlCell.offsetLeft - 110 });
break;
}
},
{ rootMargin: '300px' }
);
io.observe(mapSection);
return () => io.disconnect();
}
}, 150);
document.onkeydown = (e) => {
if (scrollDiv !== document.activeElement && !scrollDiv?.contains(document.activeElement)) {
if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) {
let newDate = new Date();
newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate, selectedDayIndex - 1);
}
}
if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new Date();
newDate.setDate(selectedDay.getDate() + 1);
switchDay(newDate, selectedDayIndex + 1);
}
}
}
};
});
$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;
loading = true;
loadError = null;
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,
timezone: loc.timezone
})
.then((result: WeekForecastResult) => {
if (version !== requestVersion) return;
fetchedHourly = {
hourly: result.hourly,
utc_offset_seconds: result.utcOffsetSeconds,
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
daylightBands: result.daylightBands
};
fetchedDaily = {
daily: result.daily,
timezone: result.timezone,
dailyDates: result.dailyDates
};
loading = false;
})
.catch((err: unknown) => {
if (version !== requestVersion) return;
loadError = err instanceof Error ? err.message : String(err);
loading = false;
});
});
let modelSelected = $derived(models.find((mo) => String(mo.value) === params.models?.[0]));
// let modelSelectedValue = $derived(params.models[0]);
//
</script>
<svelte:head>
<title>Drizz.li | Weather</title>
<link rel="canonical" href="https://drizz.li/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
<title>Weather | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather" />
<meta name="description" content="segseg" />
</svelte:head>
<div class="week-page">
<div class="">
<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
in:fade
out:fade
style="min-height: 256px"
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
>
{#await weatherDaily then wd}
{#each wd.daily.time as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()}
{#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max
.values(index)!
.toFixed(1)))}
<button
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
class="cursor-pointer"
onclick={() => {
switchDay(time, index);
}}
>
<div
class="gap-md-1 flex flex-row items-center justify-center rounded-xl p-1 md:flex-col md:justify-center md:p-3 {selected
? 'bg-accent'
: ''}"
>
<div class="weather-week-date">
<b>{time.getDate()} - {time.getMonth() + 1}</b>
</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>
<div
data-text={time.toLocaleDateString('en-GB', { weekday: 'long' })}
class="grow-text relative mx-auto inline-flex flex-col {selected
? 'font-bold'
: ''}"
>
{time.toLocaleDateString('en-GB', { weekday: 'long' })}
</div>
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
<div class="weather-week-icon pe-none py-2">
<svg class="fill-foreground" width="60px" height="60px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
(wd.daily.weather_code.values(index) ?? 0) as number
]}.svg#Layer_1"
></use>
</svg>
</div>
<div
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
style={`background-color: ${getColor(Math.round(wd.daily.temperature_2m_max.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_max.values(index)?.toFixed(1)}
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
style={`background: ${getColor(Math.round(wd.daily.temperature_2m_min.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_min.values(index)?.toFixed(1)}
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div class="mt-2 flex items-center justify-center gap-1">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="26px" height="26px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"
></use>
</svg>
</div>
</div>
{#if loadError}
<div
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
Failed to load weather data: {loadError}
</div>
{/if}
<!-- Desktop: the full day cards -->
<div class="hidden md:block">
<DailyCards
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
</div>
<!-- Mobile: a compact day strip and the hourly table share this wrapper, so
the strip stays stuck (collapsing as it goes) while scrolling the table
and then releases exactly at the table's bottom, freeing the meteograms.
The strip itself is hidden on md+ (the desktop cards above take over). -->
<div>
{#if fetchedDaily}
<DailyStripSticky
daily={fetchedDaily}
{selectedDay}
units={params}
onSelectDay={switchDay}
canExtend={forecastDays < 15}
onExtend={() => (forecastDays = 15)}
canExtendPast={pastDays < 3}
onExtendPast={() => (pastDays = 3)}
/>
{/if}
{#if fetchedHourly && fetchedDaily}
<HourlyTable
data={fetchedHourly}
daily={fetchedDaily}
{selectedDay}
units={params}
locationName={location.name ?? ''}
onCustomize={() => (variableSidebarOpen = true)}
/>
{:else}
<!-- placeholder with the table's approximate height: no layout shift -->
<div
transition:fade={{ duration: 200 }}
class="h-107.5 animate-pulse rounded-2xl border border-border/70 bg-card"
></div>
{/if}
</div>
{#if fetchedHourly}
<MeteogramCharts data={fetchedHourly} {selectedDay} units={params} {loading} />
{:else}
<!-- reserve the exact chart area height before the first fetch resolves -->
<section class="mt-8" transition:fade={{ duration: 200 }}>
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
</section>
{/if}
<!-- Animated weather maps for the shown forecast window. The wrapper is
always present so it can be observed; the browser-only map bundle is
fetched only once it scrolls near the viewport, then each map follows
the selected model / location. -->
<div bind:this={mapSection}>
<section class="mt-8">
<h2 class="mb-3 text-lg font-semibold tracking-tight">Precipitation map</h2>
<div
class="relative h-[65vh] max-h-180 min-h-105 w-full overflow-hidden rounded-2xl border border-border"
>
{#if WeatherMap}
<WeatherMap variable="precipitation" title="Precipitation" accumulation />
{:else}
<div class="h-full w-full animate-pulse bg-muted"></div>
{Number((wd.daily.sunshine_duration.values(index) ?? 0) / 3600).toFixed(0)}h
</div>
<div class="mt-1 flex items-center justify-center">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="28px" height="28px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"
></use>
</svg>
</div>
</div>
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
1
)}{params.precipitation_unit === 'mm' ? 'mm' : "'"}
</div>
</div>
</button>
{/if}
</div>
</section>
{/each}
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
</div>
<div class="ml-22 md:ml-0">
<h3 class="text-xl font-bold">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
<small>
{selectedDay.getDate() === new Date(today.getTime() - 24 * 60 * 60 * 1000).getDate()
? ' (Yesterday)'
: ''}
{selectedDay.getDate() === today.getDate() ? ' (Today)' : ''}
{selectedDay.getDate() === new Date(today.getTime() + 24 * 60 * 60 * 1000).getDate()
? ' (Tomorrow)'
: ''}
</small>
</h3>
</div>
<div
bind:this={scrollDiv}
style=" height: {218 + entries * 27.5}px; "
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
>
<canvas
bind:this={canvasElement}
id="weather_week_canvas"
class="border border-border"
style="margin-top: 24px; margin-left: 110px; width: 5000px; height: 200px; "
height="500px"
width="10000px"
></canvas>
<table in:fade class="absolute bottom-0 border-b border-border">
<caption style="display:none"> Weather Week {location.name} </caption>
<tbody>
{#await weather then weather}
<tr>
<th
scope="row"
class="time"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Time</th
>
{#each weather.indexes as index, j (j)}
<td
class="time {weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
data-date={weather.hourlyTime[index].getDate()}
data-time={weather.hourlyTime[index].getHours() + ':00'}
style="font-size: 11px; position: absolute; bottom: {188 +
27 * entries}px; left:{111 +
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
(weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;"
>{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[
index
].getHours()}</td
>
{/each}
</tr>
<!-- icons -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Icons</th
>
{#each weather.indexes as index, j (j)}
{@const now =
weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()}
<td
style="position: absolute; bottom: {27.5 * entries -
24 +
0.8 * 200 -
0.54 *
200 *
((maxTemp! - weather.entries[0].values![index]) / diffTemp!)}px; left:{116 +
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
(weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;"
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
6 && weather.hourlyTime[index].getHours() < 21
? 'day'
: 'night'}-{weatherCodes[weatherCodesHourly![index] as number]}.svg#Layer_1"
></use>
</svg></td
>
{/each}
</tr>
<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>
<!-- min / max -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Temp graph</th
>
{#each weather.indexes as index, j (j)}
{@const temp = weather.entries?.[0]?.values?.[index]}
{#if temp !== undefined && !isNaN(temp)}
<td
class={weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}
style="position: absolute; bottom: {27.5 * entries -
49 +
0.8 * 200 -
0.55 * 200 * ((maxTemp! - temp!) / diffTemp!)}px; left:{111 +
(5000 / (weather.entriesLength || 1)) * index}px; min-width: {5000 /
(weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;">{temp?.toFixed(0)}</td
>
{/if}
{/each}
</tr>
{#each weather.entries as entry, i (i)}
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>{entry.title}</th
>
{#each weather.indexes as index, j (j)}
{#if entry.values && !isNaN(entry.values[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;
{entry.name === 'temperature_2m'
? 'background: ' +
getColor(
Math.round(weather.entries[0].values![index]),
params.temperature_unit
)
: ''};
{entry.name === 'temperature_2m'
? 'color: ' +
(weather.entries[0].values![index] <
(params.temperature_unit === 'celsius' ? -13 : 7) ||
weather.entries[0].values![index] >=
(params.temperature_unit === 'celsius' ? 40 : 104)
? 'white'
: 'black')
: ''};
{entry.name === 'precipitation_probability'
? 'background: rgba(0, 0, 230,' +
weather.entries[2].values![index] / 120 +
')'
: ''};
{entry.name === 'precipitation_probability'
? 'color: ' +
(weather.entries[2].values![index] > 50
? 'white'
: 'hsl(var(--foreground)')
: ''};
{entry.name === 'relative_humidity_2m'
? 'background: rgba(0, 240, 240,' +
weather.entries[4].values![index] ** 3.8 / 10 ** 8.2 +
')'
: ''};"
>{entry.name === 'precipitation' || entry.name === 'temperature_2m'
? entry.values![index].toFixed(1)
: entry.values![index]}</td
>
{/if}
{/each}
</tr>
{/each}
{#if winddir}
<!-- winddir -->
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="z-index: 20; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Wind Dir.</th
>
{#each weather.indexes as index, j (j)}
{#if weather.windDirections && !isNaN(weather.windDirections[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="transform: rotate({weather.windDirections![
index
]}deg);min-width: {5000 / (weather.entriesLength || 1)}px; max-width: {5000 /
(weather.entriesLength || 1)}px;"
><svg class="fill-foreground" width="25px" height="25px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg></td
>
{/if}
{/each}
</tr>
{/if}
{/await}
</tbody>
</table>
</div>
</div>
{#await weatherDaily then wd}
{@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
{@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
<div class="mt-6">
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
</svg>Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}
</div>
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
</svg>
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
</div>
</div>
{/await}
<div>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">
{#if params.models && params.models.length > 0}
{@const modelValue = params.models[0]}
<Select.Root
name="model_selection"
type="single"
value={modelValue}
onValueChange={(val) => {
if (params.models && val) {
params.models = [val];
}
}}
>
<Select.Trigger
aria-label="Forecast days input"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
>
<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>
{/if}
</div>
</div>
</div>
</div>
<style>
.now {
font-weight: bold;
}
td {
text-align: center;
font-size: 13px;
}
.weather-week-icon {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #0061a5;
margin: 5px 0;
border-radius: 5px;
}
</style>
+86 -8
View File
@@ -1,13 +1,91 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import { error, redirect } from '@sveltejs/kit';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/week/',
event
});
export const prerender = true;
return { location };
export const load: PageLoad = async (event) => {
const urlLocation = event.params.location;
let urlLocationSplit, urlLocationName, urlLocationId;
if (urlLocation.includes('_')) {
urlLocationSplit = urlLocation.split('_');
urlLocationName = urlLocationSplit[0];
urlLocationId = urlLocationSplit[1];
} else if (/^\d+$/.test(urlLocation)) {
// only numbers in location, must be geonames id
urlLocationName = '';
urlLocationId = urlLocation;
} else if (/^[a-zA-Z]/.test(urlLocation)) {
// only letters in location, must be geonames query
urlLocationName = urlLocation;
urlLocationId = undefined;
}
let location: GeoLocation;
// lat, long coordinates
if (urlLocation.includes('N') && urlLocation.includes('E')) {
urlLocationSplit = urlLocation.split(/N|E/);
const latitude = parseFloat(urlLocationSplit[0]);
const longitude = parseFloat(urlLocationSplit[1]);
location = {
id: 0,
name: `${latitude}${longitude}`,
latitude: latitude,
longitude: longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
};
} else {
if (urlLocationId) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
);
location = await res.json();
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
);
const geocodingResponse = await res.json();
if (geocodingResponse.results) {
location = geocodingResponse.results[0];
} else {
error(404, 'Location not found');
}
}
const locationRoute = geoLocationNameToRoute(location.name || '');
if (location.population && location.population > 543000) {
// 1000 biggest cities
if (event.url.pathname !== `/weather/week/${locationRoute}`) {
throw redirect(303, `/weather/week/${locationRoute}`);
}
} else {
if (event.url.pathname !== `/weather/week/${locationRoute + '_' + location.id}`) {
throw redirect(303, `/weather/week/${locationRoute + '_' + location.id}`);
}
}
}
storedLocation.set(location);
return { location: location };
};
@@ -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}
@@ -1,436 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { getTempStyle } from '../../utils/colors';
import { getWeatherIconName } from '../../utils/weather-codes';
import { type FetchedDaily, type WeatherUnits, getWindArrowRotation } from './types';
interface Props {
daily: FetchedDaily | null;
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;
});
});
function getDaylightSeconds(index: number): number {
if (!daily) return 0;
const sunriseTs = daily.daily.sunrise[index];
const sunsetTs = daily.daily.sunset[index];
if (!sunriseTs || !sunsetTs) return 0;
return Math.max(0, sunsetTs - sunriseTs);
}
function getSunshinePercent(sunshineSeconds: number | null, daylightSeconds: number): number {
if (!sunshineSeconds || daylightSeconds <= 0) return 0;
return Math.min(100, (sunshineSeconds / daylightSeconds) * 100);
}
function getSunshineColor(sunshineSeconds: number | null, daylightSeconds: number): string {
if (daylightSeconds <= 0) return '#d1d5db';
const ratio = (sunshineSeconds ?? 0) / daylightSeconds;
if (ratio >= 0.7) return '#f59e0b';
if (ratio >= 0.45) return '#fbbf24';
if (ratio >= 0.1) 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}
>
{#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]}
{@const tempMin = daily.daily.temperature_2m_min[index]}
{@const wCode = daily.daily.weather_code[index]}
{@const sunDuration = daily.daily.sunshine_duration[index]}
{@const daylightSec = getDaylightSeconds(index)}
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
{@const precipSum = daily.daily.precipitation_sum[index]}
{@const windMax = daily.daily.windspeed_10m_max[index]}
{@const gustMax = daily.daily.windgusts_10m_max[index]}
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
{@const unit = String(units.temperature_unit)}
{@const maxStyle = getTempStyle(tempMax, unit)}
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
<button
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
{selected
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-[0_5px_14px_-4px_rgba(0,0,0,0.4)] ring-2 ring-primary/60 md:shadow-xl'
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
>
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span
class="-mt-1 text-[11px] {selected
? 'font-medium text-primary/80'
: 'text-muted-foreground'}"
>
{getRelativeDayLabel(time, daily.timezone)}
</span>
<!-- Weather icon: large day with a night badge in the corner -->
<div class="relative my-1 px-3 -ml-2.5">
<svg
class="day-icon fill-foreground"
width="100px"
height="100px"
style="filter: url(#thin-day-icon)"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(wCode, true)}.svg#Layer_1"
></use>
</svg>
<svg
class="night-icon absolute -right-2 -bottom-1 rounded-full bg-card fill-foreground/60 p-0.5 ring-1 ring-border/60"
width="42px"
height="42px"
>
<use
xlink:href="/images/weather-icons/{getWeatherIconName(
wCode,
false
)}.svg#Layer_1"
></use>
</svg>
</div>
<!-- Temperature max/min -->
<div class="flex items-baseline gap-1.5">
<span
class="ml-1 rounded-xl px-5 py-1.5 text-xl font-extrabold tabular-nums"
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
>
{tempMax.toFixed(0)}°
</span>
<span class="text-lg font-semibold tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</span>
</div>
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5 {lowSun ? 'opacity-45' : ''}">
<svg class="shrink-0" width="20px" height="20px" style="fill: {sunColor}">
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
</svg>
<div class="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
</span>
</div>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<span
class="inline-flex items-center gap-0.5 {lowPrecip
? 'text-muted-foreground/50'
: ''}"
>
<svg
class="shrink-0 {lowPrecip
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="23px"
height="23px"
>
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
<span
class="inline-flex items-center gap-0.5 {lowWind
? 'text-muted-foreground/50'
: ''}"
>
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0 -mr-2"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg
class={lowWind ? 'fill-muted-foreground/40' : 'fill-foreground/70'}
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"
></use>
</svg>
</span>
{:else}
<svg
class="shrink-0 -mr-2 {lowWind
? 'fill-muted-foreground/40'
: 'fill-foreground/70'}"
width="40px"
height="40px"
>
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
</svg>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="opacity-70"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
</span>
</div>
</div>
</button>
{/if}
{/each}
<!-- Mobile: the "load more" card is the last item in the SAME flex row
as the cards (reached by scrolling); keeping it in this container
avoids the zoom/flex mis-position seen when it was a sibling. On
md+ it's hidden here and the pinned sibling below is shown. -->
{#if canExtend && onExtend}
<button
type="button"
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
onclick={onExtend}
aria-label="Load the longer-range forecast"
>
<svg
class="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
</svg>
<span class="text-center text-[11px] leading-tight font-semibold">
Load<br />15 days
</span>
</button>
{/if}
</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;
}
}
</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,912 +0,0 @@
<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 {
type FetchedDaily,
type FetchedHourly,
type WeatherUnits,
getPrecipUnit,
getTempUnit,
getWindArrowRotation,
getWindUnit
} from './types';
interface Props {
data: FetchedHourly;
daily: FetchedDaily;
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();
// Must match MeteogramCharts' CHART_GROUP so hovering the time row drives the
// meteogram crosshairs.
const METEOGRAM_GROUP = 'week-meteogram';
// Scrubbing the time row moves the shared meteogram cursor to the hovered
// time (interpolated across the row so it feels continuous), and clears it on
// leave.
function hoverTimeRow(e: MouseEvent) {
const el = e.currentTarget as HTMLElement;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || cellData.length === 0) return;
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
const stepMs = (is3h ? 3 : 1) * 3600 * 1000;
const first = cellData[0].date.getTime();
const last = cellData[cellData.length - 1].date.getTime() + stepMs;
setGroupHover(METEOGRAM_GROUP, (first + frac * (last - first)) / 1000);
}
function clearTimeRowHover() {
setGroupHover(METEOGRAM_GROUP, null);
}
// persisted 1h / 3h preference
let hourlyInterval = $derived($storedHourlyInterval);
// Row visibility, controlled from the Variables sidebar (missing keys
// from older stored prefs default to visible)
let showRow = $derived(
(key: string): boolean =>
$storedVariablePrefs.table?.[key] ?? defaultVariablePrefs.table[key] ?? true
);
const today = new Date();
const tempUnit = $derived(getTempUnit(units));
const windUnit = $derived(getWindUnit(units));
const precipUnit = $derived(getPrecipUnit(units));
let sunTimes = $derived(getSunTimes());
const PRECIP_MAX_MM = 10;
const PRECIP_MAX_INCH = 0.4;
let precipAbsMax = $derived(units.precipitation_unit === 'mm' ? PRECIP_MAX_MM : PRECIP_MAX_INCH);
function getSelectedDayDailyIndex(): number {
return findDailyIndex(selectedDay);
}
function getSunTimes(): { sunrise: Date; sunset: Date } | null {
const di = getSelectedDayDailyIndex();
if (di < 0) return null;
const rise = daily.daily.sunrise[di];
const set = daily.daily.sunset[di];
if (!rise || !set) return null;
return {
sunrise: new Date(rise * 1000),
sunset: new Date(set * 1000)
};
}
function timeToFraction(date: Date): number {
const tz = data.timezone;
const hour = getZonedHour(date, tz);
const minutes = parseInt(formatZoned(date, tz, 'mm'), 10);
const totalMinutes = hour * 60 + minutes;
const firstHour = getZonedHour(cellData[0].date, tz);
const firstMin = firstHour * 60;
const step = is3h ? 3 : 1;
const lastHour = getZonedHour(cellData[cellData.length - 1].date, tz);
const lastMin = lastHour * 60 + step * 60;
const range = lastMin - firstMin;
if (range <= 0) return 0;
return Math.max(0, Math.min(1, (totalMinutes - firstMin) / range));
}
function formatTime(date: Date): string {
return formatZoned(date, data.timezone, 'HH:mm');
}
let timezoneLabel = $derived(formatUtcOffset(data.utc_offset_seconds));
function findDailyIndex(date: Date): number {
return daily.dailyDates.findIndex((dd) => isSameDayInZone(dd, date, data.timezone));
}
function isDaytime(hourDate: Date): boolean {
const di = findDailyIndex(hourDate);
if (di < 0) return true;
const sunrise = daily.daily.sunrise[di];
const sunset = daily.daily.sunset[di];
if (!sunrise || !sunset) return true;
const ts = Math.floor(hourDate.getTime() / 1000);
return ts >= sunrise && ts < sunset;
}
function getDayIndices(dates: Date[], day: Date): number[] {
const tz = data.timezone;
return dates.reduce<number[]>((acc, d, i) => {
if (isSameDayInZone(d, day, tz) && (hourlyInterval === 1 || getZonedHour(d, tz) % 3 === 0)) {
acc.push(i);
}
return acc;
}, []);
}
function getPrecipBarHeight(val: number): number {
if (!val || val <= 0) return 0;
return Math.min(100, (val / precipAbsMax) * 100);
}
function getPrecipProbBg(prob: number): string {
if (!prob || prob <= 0) return 'transparent';
return `rgba(30, 100, 220, ${(Math.round(prob / 10) / 100) * 10 * 0.45})`;
}
function getCloudOpacity(cover: number): number {
return Math.min(0.55, (cover ?? 0) / 150);
}
function getHumidityBg(hum: number): string {
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}%`);
if (precip != null && precip > 0) parts.push(`Amount: ${precip.toFixed(1)} ${precipUnit}`);
return parts.join('\n');
}
function formatTemp(temp: number | null): string {
return temp != null ? `${temp.toFixed(0)}°` : '-';
}
function formatValue(val: number | null): string {
return val != null ? val.toFixed(0) : '-';
}
let dayIdx = $derived(getDayIndices(data.hourlyDates, selectedDay));
let is3h = $derived(hourlyInterval === 3);
let daytimeFlags = $derived(dayIdx.map((idx) => isDaytime(data.hourlyDates[idx])));
let cellData = $derived(
dayIdx.map((idx, i) => {
const date = data.hourlyDates[idx];
const tz = data.timezone;
const isNow =
formatZoned(date, tz, 'yyyy-MM-dd HH') === formatZoned(today, tz, 'yyyy-MM-dd HH');
return {
idx,
date,
isNow,
isDaytime: daytimeFlags[i]
};
})
);
let sunrisePercent = $derived(
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunrise) * 100 : null
);
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)}
<svg class="inline-block fill-foreground" width={size} height={size}>
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
</svg>
{/snippet}
{#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">
{#if iconName}
{@render weatherIcon(iconName, 18)}
{/if}
{#if label}
<span class="text-[11px] font-semibold">{label}</span>
{/if}
{#if unit}
<span class="text-[10px] font-medium 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>
</h3>
<div class="flex items-center gap-2">
{#if onCustomize}
<button
class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
onclick={onCustomize}
aria-label="Customize variables"
>
<!-- sliders icon -->
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<path
stroke-linecap="round"
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
/>
</svg>
<span class="hidden sm:inline">Variables</span>
</button>
{/if}
<div
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval ===
interval
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => storedHourlyInterval.set(interval as 1 | 3)}
>
{interval}h
</button>
{/each}
</div>
</div>
</div>
<!-- Below the min-width the table scrolls sideways instead of squeezing;
1h needs far more room than 3h (24 vs 8 columns) -->
<div class="overflow-x-auto" bind:this={tableScrollEl} onscroll={onTableScroll}>
<div
class="relative {is3h ? 'min-w-[560px]' : 'min-w-[1100px]'}"
bind:clientWidth={tableWidth}
>
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
<col class="w-16 md:w-20" />
{#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>
</th>
<td
colspan={cellData.length}
class="relative h-12 cursor-default overflow-visible p-0"
onmousemove={hoverTimeRow}
onmouseleave={clearTimeRowHover}
>
<!-- 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"
style="width:{sunrisePercent}%"
></div>
<div
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-300/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"
style="width:{100 - sunsetPercent}%"
></div>
<!-- Sunrise marker + label -->
<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"
>
<svg
class="inline-block fill-foreground"
width="12px"
height="12px"
aria-hidden="true"
>
<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}%"
>
<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"
>
<svg
class="inline-block fill-foreground"
width="12px"
height="12px"
aria-hidden="true"
>
<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' : ''}"
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>
{: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
>
</span>
{/if}
</span>
{/each}
</td>
</tr>
<!-- Weather Icons -->
{#if showRow('icons')}
<tr class="row">
{@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: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}
>
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
</td>
{/each}
</tr>
{/if}
<!-- Temperature -->
{#if showRow('temperature')}
<tr class="row">
{@render rowHeader('wi-thermometer', tempUnit, 'Temp')}
{#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]'}"
style="background-color:{style.bg};color:{style.fg}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Feels Like (short row) -->
{#if showRow('feels')}
<tr class="row">
{@render rowHeader(undefined, tempUnit, 'Feels')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.apparent_temperature[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Dew Point (short row; off by default) -->
{#if showRow('dew_point')}
<tr class="row">
{@render rowHeader('wi-raindrop', tempUnit, 'Dew')}
{#each cellData as cell (cell.idx)}
{@const temp = hourly.dew_point_2m?.[cell.idx]}
<td
class="cell h-12 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}"
>
{formatTemp(temp)}
</td>
{/each}
</tr>
{/if}
<!-- Wind -->
{#if showRow('wind')}
<tr class="row">
{@render rowHeader('wi-strong-wind', windUnit, 'Wind')}
{#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">
{#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)}"
>
{@render weatherIcon('wi-direction-down', 40)}
</span>
{/if}
<span class="mt-5 block font-semibold {is3h ? 'text-[13px]' : 'text-[11px]'}">
{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')}
{#each cellData as cell (cell.idx)}
{@const hum = hourly.relative_humidity_2m[cell.idx]}
<td class="cell h-12" style="background:{getHumidityBg(hum ?? 0)}">
{formatValue(hum)}
</td>
{/each}
</tr>
{/if}
<!-- Cloud Cover -->
{#if showRow('clouds')}
<tr class="row">
{@render rowHeader('wi-cloud', '%', 'Clouds')}
{#each cellData as cell (cell.idx)}
{@const cloud = hourly.cloud_cover[cell.idx]}
<td
class="cell h-12"
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')}
{#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"
style="background:{getPrecipProbBg(prob ?? 0)}"
title={formatPrecipTooltip(precip, prob)}
>
{#if precip > 0}
<div class="precip-bar" style="height:{getPrecipBarHeight(precip)}%"></div>
<span class="precip-label {is3h ? 'text-[13px]' : 'text-[10px]'}">
{precip.toFixed(1)}
</span>
{/if}
</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);
}
.cell {
padding: 2px;
text-align: center;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
overflow: hidden;
}
.cell + .cell {
border-left: 1px solid color-mix(in oklab, var(--color-border) 35%, transparent);
}
/* ── 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;
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);
white-space: nowrap;
overflow: hidden;
}
/* ── Precipitation ──────────────────────────────────────────── */
.precip-cell {
position: relative;
padding: 0;
}
.precip-bar {
position: absolute;
bottom: 0;
left: 15%;
right: 15%;
min-height: 3px;
border-radius: 2px 2px 0 0;
background: linear-gradient(to top, rgba(30, 120, 220, 0.5), rgba(30, 120, 220, 0.9));
pointer-events: none;
}
.precip-label {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-weight: 700;
color: rgba(20, 60, 160, 0.9);
pointer-events: none;
}
:global(.dark) .precip-label {
color: rgba(140, 190, 255, 0.95);
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 768px) {
.hdr {
padding: 3px 2px;
font-size: 10px;
}
.cell {
font-size: 11px;
}
}
</style>
@@ -1,345 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
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';
interface Props {
data: FetchedHourly;
selectedDay: Date;
units: WeatherUnits;
loading: boolean;
onResetZoom?: () => void;
}
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
const CHART_GROUP = 'week-meteogram';
const SECONDS_PER_DAY = 24 * 3600;
const CHART_HEIGHT = 300;
let customizerOpen = $state(false);
let downloadingPng = $state(false);
async function downloadPng(): Promise<void> {
if (liveCharts.length === 0 || downloadingPng) return;
downloadingPng = true;
try {
// pair each chart with its panel title so the export is labelled
const items = renderPanels.map((p, i) => ({ chart: chartComponents[i], title: p.title }));
await downloadChartsPng(items, 'week-forecast');
} finally {
setTimeout(() => (downloadingPng = false), 500);
}
}
// Charts persist across data refetches; entries are null while unmounted.
let chartComponents: (CanvasChart | null)[] = $state([]);
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);
}
function resetZoom(): void {
liveCharts[0]?.resetRange();
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);
}
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;
});
// 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;
});
// True while the shared group is zoomed in (not the full range).
let zoomActive = $derived(groupRange(CHART_GROUP) != null);
// ─── Panel definitions ──────────────────────────────────────────────────────
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 };
})
);
// Uniform sizing across every panel: reserve the right-axis gutter and the
// tallest icon-row count so all meteograms share one plot rectangle.
let anyRightAxis = $derived(renderPanels.some((p) => p.def.unitRight != null));
let maxTopRows = $derived(
Math.max(
0,
...renderPanels.map((p) => (p.def.hasPictograms ? 1 : 0) + (p.def.hasWindArrows ? 1 : 0))
)
);
</script>
<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)}
>
<svg
class="h-3.5 w-3.5"
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" />
</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
>.
</div>
{:else}
<!-- one full-bleed card until lg / contained card on lg+, graphs stacked
tightly so they read as one fluent meteogram -->
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
{#each renderPanels as panel, i (panel.id)}
<div
class="px-0 pt-0.5 pb-0 lg:px-4 lg:pt-2 lg:pb-1 {i > 0
? 'border-t border-border/50'
: 'lg:pt-4'} {i === renderPanels.length - 1 ? 'pb-1 lg:pb-4' : ''}"
>
<div class="mb-0 flex items-center justify-between px-3 lg:mb-0.5 lg:px-0">
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
<span class="hidden lg:inline">{panel.title}</span>
<span class="lg:hidden">{panel.titleShort}</span>
</h4>
</div>
<ChartContainer
{loading}
chartCount={1}
chartHeight={CHART_HEIGHT}
minWidth={520}
bleed={false}
>
<CanvasChart
bind:this={chartComponents[i]}
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>
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
@@ -1,108 +0,0 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { type WeatherModelGroup, modelGroups } 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 model = $derived(
groups.flatMap((group) => group.models).find((mo) => mo.value === selectedModel)
);
let modelLabel = $derived(model?.label ?? selectedModel);
let modelMeta = $derived.by(() => {
if (model?.resolution && model.resolution !== 'varies') {
return model.update ? `${model.resolution} · updated ${model.update}` : model.resolution;
}
return selectedModel === 'best_match'
? 'Automatically picks the best model for this location'
: '';
});
</script>
<Select.Root
name="model_selection"
type="single"
value={selectedModel}
onValueChange={(val) => {
if (val) onModelChange(val);
}}
>
<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"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary sm:size-9"
>
<!-- layered-globe icon: weather model -->
<svg
class="size-4.5 sm:size-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="1.75"
>
<circle cx="12" cy="12" r="9" />
<path
stroke-linecap="round"
d="M3.6 9h16.8M3.6 15h16.8M12 3a15 15 0 0 1 0 18a15 15 0 0 1 0-18"
/>
</svg>
</div>
<div class="flex min-w-0 flex-1 flex-col items-start gap-0 overflow-hidden text-left">
<span class="text-[11px] font-semibold tracking-wide text-primary uppercase">
{label}
</span>
<span class="max-w-full truncate text-[13px] font-bold text-foreground sm:text-sm"
>{modelLabel}</span
>
{#if modelMeta}
<!-- the meta line is dropped on mobile to keep the trigger compact -->
<span
class="hidden max-w-full truncate text-[11px] leading-tight text-muted-foreground sm:block"
>
{modelMeta}
</span>
{/if}
</div>
</Select.Trigger>
<Select.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>
{/each}
</Select.Content>
</Select.Root>
@@ -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}
@@ -1,60 +0,0 @@
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
export interface WeatherUnits {
temperature_unit: string;
wind_speed_unit: string;
precipitation_unit: string;
}
export interface FetchedHourly {
hourly: WeekHourlyData;
utc_offset_seconds: number;
timezone: string;
timestamps: number[];
hourlyDates: Date[];
daylightBands: DaylightBand[];
}
export interface FetchedDaily {
daily: WeekDailyData;
timezone: string;
dailyDates: Date[];
}
export const getTempUnit = (units: WeatherUnits): '°C' | '°F' => {
return units.temperature_unit === 'celsius' ? '°C' : '°F';
};
export const getWindUnit = (units: WeatherUnits): string => {
return units.wind_speed_unit === 'kmh' ? 'km/h' : units.wind_speed_unit;
};
export const getPrecipUnit = (units: WeatherUnits): 'mm' | 'in' => {
return units.precipitation_unit === 'mm' ? 'mm' : 'in';
};
export const getWindArrowRotation = (deg: number): string => {
return `rotate(${deg}deg)`;
};
export const getWindDirectionLabel = (deg: number): string => {
const dirs = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW'
];
return dirs[Math.round(deg / 22.5) % 16];
};

Some files were not shown because too many files have changed in this diff Show More