Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
690d04f96b | ||
|
|
6872cac00e | ||
|
|
f2371ec5ce | ||
|
|
be0f7f822d | ||
|
|
3955fe5686 | ||
|
|
6ede9b5620 | ||
|
|
6ffef099cd | ||
|
|
f0eb425ec6 | ||
|
|
2a8a8b9746 | ||
|
|
793918eeff | ||
|
|
ba9834a3eb | ||
|
|
298c8ef597 | ||
|
|
1e434e0a84 | ||
|
|
9b39255e64 |
@@ -1,4 +1,4 @@
|
||||
# Drizzli
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"userWords": ["ConfigInterface"]
|
||||
}
|
||||
Generated
+1737
-1238
File diff suppressed because it is too large
Load Diff
+35
-32
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "drizzli",
|
||||
"name": "open-meteo-weather",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
@@ -14,44 +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/sdk": "^1.23.0",
|
||||
"highcharts": "^12.4.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"openmeteo": "^1.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 +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
@@ -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] }));
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Canvas Charts — Barrel Export
|
||||
*
|
||||
* Usage:
|
||||
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
|
||||
*/
|
||||
|
||||
export { default as CanvasChart, setGroupHover, groupRange } 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,151 +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-x: auto;
|
||||
}
|
||||
|
||||
.chart-bleed.no-bleed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-width: var(--chart-min-width);
|
||||
}
|
||||
|
||||
/* Mobile: fit the chart to the viewport instead of forcing a min-width
|
||||
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
|
||||
@media (max-width: 767px) {
|
||||
.chart-container {
|
||||
min-width: 0;
|
||||
}
|
||||
.chart-bleed {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.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,228 +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">
|
||||
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
||||
export interface DownloadableChart {
|
||||
getPngDataUrl(): string | null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
/** Chart components available for download (undefined entries are skipped) */
|
||||
charts?: Array<DownloadableChart | 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(img);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
return bg || '#ffffff';
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, name: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDownload(): Promise<void> {
|
||||
if (!hasCharts || downloading) return;
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
const dataUrls = charts
|
||||
.filter((chart): chart is DownloadableChart => chart != null)
|
||||
.map((chart) => chart.getPngDataUrl())
|
||||
.filter((url): url is string => url !== null);
|
||||
if (dataUrls.length === 0) return;
|
||||
|
||||
const images = (await Promise.all(dataUrls.map(loadImage))).filter(
|
||||
(img) => img.naturalWidth > 0
|
||||
);
|
||||
if (images.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
||||
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = totalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
let y = 0;
|
||||
for (const img of images) {
|
||||
ctx.drawImage(img, 0, y);
|
||||
y += img.naturalHeight;
|
||||
}
|
||||
|
||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||
} 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,11 +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';
|
||||
@@ -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>
|
||||
|
||||
@@ -1,165 +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';
|
||||
|
||||
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 {
|
||||
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="flex h-8 w-8 items-center justify-center rounded-md transition-colors hover:bg-black/5 md:hidden dark:hover:bg-white/10"
|
||||
onclick={onMenuToggle}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<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},
|
||||
{/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 />
|
||||
|
||||
<!-- 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>
|
||||
@@ -2,44 +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: '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) => {
|
||||
@@ -47,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">
|
||||
Drizzli
|
||||
</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 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,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} />
|
||||
@@ -1,95 +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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
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>
|
||||
<span class="hidden text-xs font-semibold sm:inline">Units</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-64 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#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>
|
||||
@@ -1,805 +0,0 @@
|
||||
/**
|
||||
* Weather Data Service
|
||||
*
|
||||
* Centralized, type-safe weather data fetching using the Open-Meteo SDK
|
||||
* with protobuf (FlatBuffers) transport for efficient data transfer.
|
||||
*
|
||||
* All weather data fetching flows through this service, providing:
|
||||
* - Type-safe request parameters and response structures
|
||||
* - Automatic retries with exponential backoff (via the SDK)
|
||||
* - Efficient binary protobuf transport instead of JSON
|
||||
* - Consistent timestamp and unit handling
|
||||
*/
|
||||
import { Unit } from '@openmeteo/sdk/unit';
|
||||
import { fetchWeatherApi } from 'openmeteo';
|
||||
|
||||
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
|
||||
|
||||
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
||||
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
|
||||
const ENSEMBLE_URL = 'https://ensemble-api.open-meteo.com/v1/ensemble';
|
||||
|
||||
// ─── Core Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an array of numbers from start (inclusive) to stop (exclusive) with the given step.
|
||||
* Used to reconstruct timestamp arrays from the protobuf time/timeEnd/interval fields.
|
||||
*/
|
||||
export function range(start: number, stop: number, step: number): number[] {
|
||||
return Array.from(
|
||||
{ length: Math.max(0, Math.ceil((stop - start) / step)) },
|
||||
(_, i) => start + i * step
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts timestamp array (in milliseconds, with UTC offset applied) from a VariablesWithTime block.
|
||||
*/
|
||||
export function getTimestamps(timeBlock: VariablesWithTime): number[] {
|
||||
const start = Number(timeBlock.time());
|
||||
const end = Number(timeBlock.timeEnd());
|
||||
const interval = timeBlock.interval();
|
||||
return range(start, end, interval).map((t) => t * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Date array (with UTC offset applied) from a VariablesWithTime block.
|
||||
*/
|
||||
export function getDates(timeBlock: VariablesWithTime): Date[] {
|
||||
return getTimestamps(timeBlock).map((t) => new Date(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a Float32Array of values from a VariableWithValues, returning a regular number[].
|
||||
* Falls back to an empty array if no values are present.
|
||||
*/
|
||||
export function getValues(variable: VariableWithValues): number[] {
|
||||
const arr = variable.valuesArray();
|
||||
if (!arr) return [];
|
||||
return Array.from(arr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts Int64 (BigInt) values from a VariableWithValues, converting to number[].
|
||||
* Used for variables stored as unix timestamps (e.g. sunrise, sunset).
|
||||
*/
|
||||
export function getInt64Values(variable: VariableWithValues): number[] {
|
||||
const len = variable.valuesInt64Length();
|
||||
const result: number[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const val = variable.valuesInt64(i);
|
||||
result.push(val !== null ? Number(val) : 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the SDK Unit enum to a human-readable display string.
|
||||
*/
|
||||
export function unitToDisplayString(unit: Unit): string {
|
||||
switch (unit) {
|
||||
case Unit.celsius:
|
||||
return '°C';
|
||||
case Unit.fahrenheit:
|
||||
return '°F';
|
||||
case Unit.millimetre:
|
||||
return 'mm';
|
||||
case Unit.inch:
|
||||
return 'in';
|
||||
case Unit.kilometres_per_hour:
|
||||
return 'km/h';
|
||||
case Unit.metre_per_second:
|
||||
return 'm/s';
|
||||
case Unit.miles_per_hour:
|
||||
return 'mph';
|
||||
case Unit.knots:
|
||||
return 'kn';
|
||||
case Unit.percentage:
|
||||
return '%';
|
||||
case Unit.hectopascal:
|
||||
return 'hPa';
|
||||
case Unit.degree_direction:
|
||||
return '°';
|
||||
case Unit.wmo_code:
|
||||
return 'wmo code';
|
||||
case Unit.seconds:
|
||||
return 's';
|
||||
case Unit.hours:
|
||||
return 'h';
|
||||
case Unit.watt_per_square_metre:
|
||||
return 'W/m²';
|
||||
case Unit.megajoule_per_square_metre:
|
||||
return 'MJ/m²';
|
||||
case Unit.joule_per_kilogram:
|
||||
return 'J/kg';
|
||||
case Unit.metre:
|
||||
return 'm';
|
||||
case Unit.centimetre:
|
||||
return 'cm';
|
||||
case Unit.kilogram_per_square_metre:
|
||||
return 'kg/m²';
|
||||
case Unit.kilopascal:
|
||||
return 'kPa';
|
||||
case Unit.pascal:
|
||||
return 'Pa';
|
||||
case Unit.fraction:
|
||||
return '';
|
||||
case Unit.dimensionless:
|
||||
return '';
|
||||
case Unit.dimensionless_integer:
|
||||
return '';
|
||||
case Unit.unix_time:
|
||||
return 'unixtime';
|
||||
case Unit.grains_per_cubic_metre:
|
||||
return 'grains/m³';
|
||||
case Unit.micrograms_per_cubic_metre:
|
||||
return 'µg/m³';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Shared Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WeatherLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface WeatherUnitParams {
|
||||
temperature_unit?: 'celsius' | 'fahrenheit';
|
||||
wind_speed_unit?: 'kmh' | 'ms' | 'mph' | 'kn';
|
||||
precipitation_unit?: 'mm' | 'inch';
|
||||
}
|
||||
|
||||
export type { DaylightBand };
|
||||
|
||||
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
model?: string;
|
||||
forecast_days?: number;
|
||||
past_days?: number;
|
||||
/** Hourly API variables to request; defaults to the full core set */
|
||||
hourlyVariables?: string[];
|
||||
}
|
||||
|
||||
export interface WeekHourlyData {
|
||||
temperature_2m: number[];
|
||||
precipitation: number[];
|
||||
precipitation_probability: number[];
|
||||
weather_code: number[];
|
||||
windspeed_10m: number[];
|
||||
winddirection_10m: number[];
|
||||
cloud_cover: number[];
|
||||
relative_humidity_2m: number[];
|
||||
apparent_temperature: number[];
|
||||
dew_point_2m: number[];
|
||||
// Additional popular variables available for the customizable meteograms
|
||||
wind_gusts_10m: number[];
|
||||
pressure_msl: number[];
|
||||
surface_pressure: number[];
|
||||
rain: number[];
|
||||
showers: number[];
|
||||
snowfall: number[];
|
||||
cloud_cover_low: number[];
|
||||
cloud_cover_mid: number[];
|
||||
cloud_cover_high: number[];
|
||||
uv_index: number[];
|
||||
visibility: number[];
|
||||
cape: number[];
|
||||
}
|
||||
|
||||
export interface WeekDailyData {
|
||||
weather_code: number[];
|
||||
temperature_2m_max: number[];
|
||||
temperature_2m_min: number[];
|
||||
sunrise: number[];
|
||||
sunset: number[];
|
||||
sunshine_duration: number[];
|
||||
precipitation_sum: number[];
|
||||
windspeed_10m_max: number[];
|
||||
windgusts_10m_max: number[];
|
||||
winddirection_10m_dominant: number[];
|
||||
}
|
||||
|
||||
export interface WeekForecastResult {
|
||||
hourly: WeekHourlyData;
|
||||
daily: WeekDailyData;
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
hourlyTimestamps: number[];
|
||||
hourlyDates: Date[];
|
||||
dailyDates: Date[];
|
||||
daylightBands: DaylightBand[];
|
||||
}
|
||||
|
||||
// ─── Model Comparison Types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ModelCompareParams extends WeatherLocation, WeatherUnitParams {
|
||||
hourlyVariables: string[];
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export interface ModelSeriesData {
|
||||
modelName: string;
|
||||
variables: Record<string, number[]>;
|
||||
}
|
||||
|
||||
export interface ModelCompareResult {
|
||||
models: ModelSeriesData[];
|
||||
timestamps: number[];
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
daylightBands: DaylightBand[];
|
||||
sunrise: number[];
|
||||
sunset: number[];
|
||||
units: Record<string, string>;
|
||||
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
||||
hourlyFlat: Record<string, number[]>;
|
||||
hourlyUnitsFlat: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Ensemble Forecast Types ────────────────────────────────────────────────────
|
||||
|
||||
export interface EnsembleForecastParams extends WeatherLocation, WeatherUnitParams {
|
||||
hourlyVariables: string[];
|
||||
models: string[];
|
||||
forecast_days?: number;
|
||||
}
|
||||
|
||||
export interface EnsembleVariableData {
|
||||
members: number[][];
|
||||
average: number[];
|
||||
min: number[];
|
||||
max: number[];
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface EnsembleForecastResult {
|
||||
variables: Record<string, EnsembleVariableData>;
|
||||
timestamps: number[];
|
||||
utcOffsetSeconds: number;
|
||||
timezone: string;
|
||||
daylightBands: DaylightBand[];
|
||||
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
||||
hourlyFlat: Record<string, number[]>;
|
||||
hourlyUnitsFlat: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
||||
|
||||
// Fallback set when the caller does not specify which hourly variables it
|
||||
// needs. Callers normally pass an explicit list so only shown variables are
|
||||
// requested.
|
||||
const WEEK_HOURLY_VARS = [
|
||||
'temperature_2m',
|
||||
'precipitation',
|
||||
'precipitation_probability',
|
||||
'weather_code',
|
||||
'wind_speed_10m',
|
||||
'wind_direction_10m',
|
||||
'cloud_cover',
|
||||
'relative_humidity_2m',
|
||||
'apparent_temperature',
|
||||
'dew_point_2m'
|
||||
] as const;
|
||||
|
||||
const WEEK_DAILY_VARS = [
|
||||
'weather_code',
|
||||
'temperature_2m_max',
|
||||
'temperature_2m_min',
|
||||
'sunrise',
|
||||
'sunset',
|
||||
'sunshine_duration',
|
||||
'precipitation_sum',
|
||||
'wind_speed_10m_max',
|
||||
'wind_gusts_10m_max',
|
||||
'wind_direction_10m_dominant'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Fetches the 7-day (week) weather forecast for a single location and model.
|
||||
* Returns typed hourly and daily data structures.
|
||||
*/
|
||||
export async function fetchWeekForecast(params: WeekForecastParams): Promise<WeekForecastResult> {
|
||||
const forecastDays = params.forecast_days ?? 6;
|
||||
const pastDays = params.past_days ?? 0;
|
||||
const modelParam = params.model && params.model !== 'best_match' ? params.model : undefined;
|
||||
|
||||
// Request only the variables the caller needs; fall back to the core set.
|
||||
const hourlyVars =
|
||||
params.hourlyVariables && params.hourlyVariables.length > 0
|
||||
? [...new Set(params.hourlyVariables)]
|
||||
: [...WEEK_HOURLY_VARS];
|
||||
|
||||
const apiParams: Record<string, string | number | undefined> = {
|
||||
latitude: params.latitude,
|
||||
longitude: params.longitude,
|
||||
hourly: hourlyVars.join(','),
|
||||
daily: WEEK_DAILY_VARS.join(','),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
forecast_days: forecastDays,
|
||||
past_days: pastDays,
|
||||
models: modelParam,
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
const cleanParams: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(apiParams)) {
|
||||
if (value !== undefined) {
|
||||
cleanParams[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
const responses = await fetchWeatherApi(FORECAST_URL, cleanParams);
|
||||
const response = responses[0];
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const timezone = response.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const hourlyBlock = response.hourly()!;
|
||||
const dailyBlock = response.daily()!;
|
||||
|
||||
// Hourly: variables are in the same order as WEEK_HOURLY_VARS
|
||||
const hourlyTimestamps = getTimestamps(hourlyBlock);
|
||||
const hourlyDates = hourlyTimestamps.map((t) => new Date(t));
|
||||
|
||||
// Values come back in the requested order; index them by API name so
|
||||
// variables that were not requested resolve to empty arrays.
|
||||
const byName: Record<string, number[]> = {};
|
||||
hourlyVars.forEach((name, i) => {
|
||||
const variable = hourlyBlock.variables(i);
|
||||
byName[name] = variable ? getValues(variable) : [];
|
||||
});
|
||||
const g = (name: string): number[] => byName[name] ?? [];
|
||||
|
||||
const hourly: WeekHourlyData = {
|
||||
temperature_2m: g('temperature_2m'),
|
||||
precipitation: g('precipitation'),
|
||||
precipitation_probability: g('precipitation_probability'),
|
||||
weather_code: g('weather_code'),
|
||||
windspeed_10m: g('wind_speed_10m'),
|
||||
winddirection_10m: g('wind_direction_10m'),
|
||||
cloud_cover: g('cloud_cover'),
|
||||
relative_humidity_2m: g('relative_humidity_2m'),
|
||||
apparent_temperature: g('apparent_temperature'),
|
||||
dew_point_2m: g('dew_point_2m'),
|
||||
wind_gusts_10m: g('wind_gusts_10m'),
|
||||
pressure_msl: g('pressure_msl'),
|
||||
surface_pressure: g('surface_pressure'),
|
||||
rain: g('rain'),
|
||||
showers: g('showers'),
|
||||
snowfall: g('snowfall'),
|
||||
cloud_cover_low: g('cloud_cover_low'),
|
||||
cloud_cover_mid: g('cloud_cover_mid'),
|
||||
cloud_cover_high: g('cloud_cover_high'),
|
||||
uv_index: g('uv_index'),
|
||||
visibility: g('visibility'),
|
||||
cape: g('cape')
|
||||
};
|
||||
|
||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
||||
const dailyDates = getDates(dailyBlock);
|
||||
|
||||
const sunriseVar = dailyBlock.variables(3)!;
|
||||
const sunsetVar = dailyBlock.variables(4)!;
|
||||
|
||||
const daily: WeekDailyData = {
|
||||
weather_code: getValues(dailyBlock.variables(0)!),
|
||||
temperature_2m_max: getValues(dailyBlock.variables(1)!),
|
||||
temperature_2m_min: getValues(dailyBlock.variables(2)!),
|
||||
sunrise: getInt64Values(sunriseVar),
|
||||
sunset: getInt64Values(sunsetVar),
|
||||
sunshine_duration: getValues(dailyBlock.variables(5)!),
|
||||
precipitation_sum: getValues(dailyBlock.variables(6)!),
|
||||
windspeed_10m_max: getValues(dailyBlock.variables(7)!),
|
||||
windgusts_10m_max: getValues(dailyBlock.variables(8)!),
|
||||
winddirection_10m_dominant: getValues(dailyBlock.variables(9)!)
|
||||
};
|
||||
|
||||
const daylightBands = buildDaylightBands(daily.sunrise, daily.sunset);
|
||||
|
||||
return {
|
||||
hourly,
|
||||
daily,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
hourlyTimestamps,
|
||||
hourlyDates,
|
||||
dailyDates,
|
||||
daylightBands
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Model Comparison Fetch ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches forecast data for multiple models for comparison.
|
||||
* Also fetches daily sunrise/sunset for daylight mark areas.
|
||||
*
|
||||
* Returns both a typed model array structure and a flat record structure
|
||||
* compatible with existing chart utilities.
|
||||
*/
|
||||
export async function fetchModelComparison(
|
||||
params: ModelCompareParams
|
||||
): Promise<ModelCompareResult> {
|
||||
const forecastApiParams: Record<string, string | number | undefined> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
hourly: params.hourlyVariables.join(','),
|
||||
models: params.models.join(','),
|
||||
daily: 'sunrise,sunset',
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const responses = await fetchWeatherApi(FORECAST_URL, forecastApiParams);
|
||||
|
||||
// With multiple models, we get one response per model
|
||||
const firstResponse = responses[0];
|
||||
const utcOffsetSeconds = firstResponse.utcOffsetSeconds();
|
||||
const timezone = firstResponse.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const hourlyBlock = firstResponse.hourly()!;
|
||||
const timestamps = getTimestamps(hourlyBlock);
|
||||
|
||||
// Extract sunrise/sunset from the first response's daily block
|
||||
let daylightBands: DaylightBand[] = [];
|
||||
let sunrise: number[] = [];
|
||||
let sunset: number[] = [];
|
||||
const dailyBlock = firstResponse.daily();
|
||||
if (dailyBlock) {
|
||||
const sunriseVar = dailyBlock.variables(0)!;
|
||||
const sunsetVar = dailyBlock.variables(1)!;
|
||||
sunrise = getInt64Values(sunriseVar);
|
||||
sunset = getInt64Values(sunsetVar);
|
||||
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||
}
|
||||
|
||||
// Process each model's response
|
||||
const models: ModelSeriesData[] = [];
|
||||
const hourlyFlat: Record<string, number[]> = {};
|
||||
const hourlyUnitsFlat: Record<string, string> = {};
|
||||
const units: Record<string, string> = {};
|
||||
|
||||
// Add time to flat record
|
||||
const timeInUnixSeconds = range(
|
||||
Number(hourlyBlock.time()),
|
||||
Number(hourlyBlock.timeEnd()),
|
||||
hourlyBlock.interval()
|
||||
);
|
||||
hourlyFlat['time'] = timeInUnixSeconds;
|
||||
|
||||
for (const response of responses) {
|
||||
const modelHourly = response.hourly();
|
||||
if (!modelHourly) continue;
|
||||
|
||||
// Determine model name from the response
|
||||
const modelEnum = response.model();
|
||||
const modelName = modelEnumToString(modelEnum);
|
||||
|
||||
const modelData: ModelSeriesData = {
|
||||
modelName,
|
||||
variables: {}
|
||||
};
|
||||
|
||||
for (let vi = 0; vi < params.hourlyVariables.length; vi++) {
|
||||
const varName = params.hourlyVariables[vi];
|
||||
const variable = modelHourly.variables(vi);
|
||||
if (!variable) continue;
|
||||
|
||||
const values = getValues(variable);
|
||||
modelData.variables[varName] = values;
|
||||
|
||||
// Build flat key like "temperature_2m_icon_seamless"
|
||||
const flatKey = `${varName}_${modelName}`;
|
||||
hourlyFlat[flatKey] = values;
|
||||
|
||||
// Record unit
|
||||
const unitStr = unitToDisplayString(variable.unit());
|
||||
units[varName] = unitStr;
|
||||
hourlyUnitsFlat[flatKey] = unitStr;
|
||||
}
|
||||
|
||||
models.push(modelData);
|
||||
}
|
||||
|
||||
return {
|
||||
models,
|
||||
timestamps,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
daylightBands,
|
||||
sunrise,
|
||||
sunset,
|
||||
units,
|
||||
hourlyFlat,
|
||||
hourlyUnitsFlat
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Ensemble Forecast Fetch ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches ensemble forecast data from the ensemble API.
|
||||
* Separately fetches daily sunrise/sunset from the standard forecast API.
|
||||
*
|
||||
* Returns typed ensemble data with per-variable member arrays, averages, and spreads,
|
||||
* plus a flat record structure for compatibility with existing chart utilities.
|
||||
*/
|
||||
export async function fetchEnsembleForecast(
|
||||
params: EnsembleForecastParams
|
||||
): Promise<EnsembleForecastResult> {
|
||||
const forecastDays = params.forecast_days ?? 14;
|
||||
|
||||
const ensembleParams: Record<string, string | number | undefined> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
hourly: params.hourlyVariables.join(','),
|
||||
models: params.models.join(','),
|
||||
forecast_days: String(forecastDays),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
};
|
||||
|
||||
const dailyParams: Record<string, string> = {
|
||||
latitude: String(params.latitude),
|
||||
longitude: String(params.longitude),
|
||||
daily: 'sunrise,sunset',
|
||||
forecast_days: String(forecastDays),
|
||||
temperature_unit: params.temperature_unit ?? 'celsius'
|
||||
};
|
||||
|
||||
// Fetch ensemble and daily data in parallel
|
||||
const [ensembleResponses, dailyResponses] = await Promise.all([
|
||||
fetchWeatherApi(ENSEMBLE_URL, ensembleParams),
|
||||
fetchWeatherApi(FORECAST_URL, dailyParams)
|
||||
]);
|
||||
|
||||
const ensembleResponse = ensembleResponses[0];
|
||||
const utcOffsetSeconds = ensembleResponse.utcOffsetSeconds();
|
||||
const timezone = ensembleResponse.timezone() ?? params.timezone ?? 'UTC';
|
||||
|
||||
const hourlyBlock = ensembleResponse.hourly()!;
|
||||
const timestamps = getTimestamps(hourlyBlock);
|
||||
const timeLength = timestamps.length;
|
||||
|
||||
// Extract sunrise/sunset for daylight bands
|
||||
let daylightBands: DaylightBand[] = [];
|
||||
if (dailyResponses.length > 0) {
|
||||
const dailyResponse = dailyResponses[0];
|
||||
const dailyBlock = dailyResponse.daily();
|
||||
if (dailyBlock) {
|
||||
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
||||
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
||||
daylightBands = buildDaylightBands(sunrise, sunset);
|
||||
}
|
||||
}
|
||||
|
||||
// Process ensemble variables
|
||||
// Each requested variable will have multiple entries in the variables list (one per ensemble member)
|
||||
const variables: Record<string, EnsembleVariableData> = {};
|
||||
const hourlyFlat: Record<string, number[]> = {};
|
||||
const hourlyUnitsFlat: Record<string, string> = {};
|
||||
|
||||
// Add time to flat record
|
||||
const timeInUnixSeconds = range(
|
||||
Number(hourlyBlock.time()),
|
||||
Number(hourlyBlock.timeEnd()),
|
||||
hourlyBlock.interval()
|
||||
);
|
||||
hourlyFlat['time'] = timeInUnixSeconds;
|
||||
|
||||
// Group variables by their requested variable name
|
||||
// The SDK provides variables indexed sequentially:
|
||||
// For N requested variables and M ensemble members, we get N*M variables
|
||||
// ordered as: var0_member0, var0_member1, ..., var0_memberM-1, var1_member0, ...
|
||||
const totalVariables = hourlyBlock.variablesLength();
|
||||
const numRequestedVars = params.hourlyVariables.length;
|
||||
|
||||
if (totalVariables > 0 && numRequestedVars > 0) {
|
||||
const membersPerVar = Math.floor(totalVariables / numRequestedVars);
|
||||
|
||||
for (let vi = 0; vi < numRequestedVars; vi++) {
|
||||
const varName = params.hourlyVariables[vi];
|
||||
const members: number[][] = [];
|
||||
let unitStr = '';
|
||||
|
||||
for (let mi = 0; mi < membersPerVar; mi++) {
|
||||
const varIdx = vi * membersPerVar + mi;
|
||||
const variable = hourlyBlock.variables(varIdx);
|
||||
if (!variable) continue;
|
||||
|
||||
const values = getValues(variable);
|
||||
members.push(values);
|
||||
|
||||
if (mi === 0) {
|
||||
unitStr = unitToDisplayString(variable.unit());
|
||||
}
|
||||
|
||||
// Build flat key compatible with JSON API format
|
||||
const memberStr = String(mi).padStart(2, '0');
|
||||
const flatKey = `${varName}_member${memberStr}`;
|
||||
hourlyFlat[flatKey] = values;
|
||||
hourlyUnitsFlat[flatKey] = unitStr;
|
||||
}
|
||||
|
||||
// Calculate average, min, max across members
|
||||
const average = new Array<number>(timeLength).fill(0);
|
||||
const min = new Array<number>(timeLength).fill(Infinity);
|
||||
const max = new Array<number>(timeLength).fill(-Infinity);
|
||||
|
||||
for (let t = 0; t < timeLength; t++) {
|
||||
let count = 0;
|
||||
for (const memberValues of members) {
|
||||
const val = memberValues[t];
|
||||
if (val !== null && val !== undefined && !isNaN(val)) {
|
||||
average[t] += val;
|
||||
count++;
|
||||
if (val < min[t]) min[t] = val;
|
||||
if (val > max[t]) max[t] = val;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
average[t] = Math.round((average[t] / count) * 10) / 10;
|
||||
}
|
||||
if (min[t] === Infinity) min[t] = 0;
|
||||
if (max[t] === -Infinity) max[t] = 0;
|
||||
}
|
||||
|
||||
variables[varName] = {
|
||||
members,
|
||||
average,
|
||||
min,
|
||||
max,
|
||||
unit: unitStr
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
variables,
|
||||
timestamps,
|
||||
utcOffsetSeconds,
|
||||
timezone,
|
||||
daylightBands,
|
||||
hourlyFlat,
|
||||
hourlyUnitsFlat
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Model Enum Mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps the SDK Model enum integer to a string model name.
|
||||
* This table must stay in sync with the @openmeteo/sdk Model enum.
|
||||
*/
|
||||
function modelEnumToString(modelEnum: number): string {
|
||||
const modelMap: Record<number, string> = {
|
||||
0: 'undefined',
|
||||
1: 'best_match',
|
||||
2: 'gfs_seamless',
|
||||
3: 'gfs_global',
|
||||
4: 'gfs_hrrr',
|
||||
5: 'meteofrance_seamless',
|
||||
6: 'meteofrance_arpege_seamless',
|
||||
7: 'meteofrance_arpege_world',
|
||||
8: 'meteofrance_arpege_europe',
|
||||
9: 'meteofrance_arome_seamless',
|
||||
10: 'meteofrance_arome_france',
|
||||
11: 'meteofrance_arome_france_hd',
|
||||
12: 'jma_seamless',
|
||||
13: 'jma_msm',
|
||||
14: 'jms_gsm',
|
||||
15: 'jma_gsm',
|
||||
16: 'gem_seamless',
|
||||
17: 'gem_global',
|
||||
18: 'gem_regional',
|
||||
19: 'gem_hrdps_continental',
|
||||
20: 'icon_seamless',
|
||||
21: 'icon_global',
|
||||
22: 'icon_eu',
|
||||
23: 'icon_d2',
|
||||
24: 'ecmwf_ifs04',
|
||||
25: 'metno_nordic',
|
||||
26: 'era5_seamless',
|
||||
27: 'era5',
|
||||
28: 'cerra',
|
||||
29: 'era5_land',
|
||||
30: 'ecmwf_ifs',
|
||||
31: 'gwam',
|
||||
32: 'ewam',
|
||||
33: 'glofas_seamless_v3',
|
||||
34: 'glofas_forecast_v3',
|
||||
35: 'glofas_consolidated_v3',
|
||||
36: 'glofas_seamless_v4',
|
||||
37: 'glofas_forecast_v4',
|
||||
38: 'glofas_consolidated_v4',
|
||||
39: 'gfs025',
|
||||
40: 'gfs05',
|
||||
41: 'CMCC_CM2_VHR4',
|
||||
42: 'FGOALS_f3_H_highresSST',
|
||||
43: 'FGOALS_f3_H',
|
||||
44: 'HiRAM_SIT_HR',
|
||||
45: 'MRI_AGCM3_2_S',
|
||||
46: 'EC_Earth3P_HR',
|
||||
47: 'MPI_ESM1_2_XR',
|
||||
48: 'NICAM16_8S',
|
||||
49: 'cams_europe',
|
||||
50: 'cams_global',
|
||||
51: 'cfsv2',
|
||||
52: 'era5_ocean',
|
||||
53: 'cma_grapes_global',
|
||||
54: 'bom_access_global',
|
||||
55: 'bom_access_global_ensemble',
|
||||
56: 'arpae_cosmo_seamless',
|
||||
57: 'arpae_cosmo_2i',
|
||||
58: 'arpae_cosmo_2i_ruc',
|
||||
59: 'arpae_cosmo_5m',
|
||||
60: 'ecmwf_ifs025',
|
||||
61: 'ecmwf_aifs025',
|
||||
62: 'gfs013',
|
||||
63: 'gfs_graphcast025',
|
||||
64: 'ecmwf_wam025',
|
||||
65: 'meteofrance_wave',
|
||||
66: 'meteofrance_currents',
|
||||
67: 'ecmwf_wam025_ensemble',
|
||||
68: 'ncep_gfswave025',
|
||||
69: 'ncep_gefswave025',
|
||||
70: 'knmi_seamless',
|
||||
71: 'knmi_harmonie_arome_europe',
|
||||
72: 'knmi_harmonie_arome_netherlands',
|
||||
73: 'dmi_seamless',
|
||||
74: 'dmi_harmonie_arome_europe',
|
||||
75: 'metno_seamless',
|
||||
76: 'era5_ensemble',
|
||||
77: 'ecmwf_ifs_analysis',
|
||||
78: 'ecmwf_ifs_long_window',
|
||||
79: 'ecmwf_ifs_analysis_long_window',
|
||||
80: 'ukmo_global_deterministic_10km',
|
||||
81: 'ukmo_uk_deterministic_2km',
|
||||
82: 'ukmo_seamless',
|
||||
83: 'ncep_gfswave016',
|
||||
84: 'ncep_nbm_conus',
|
||||
85: 'ukmo_global_ensemble_20km',
|
||||
86: 'ecmwf_aifs025_single',
|
||||
87: 'jma_jaxa_himawari',
|
||||
88: 'eumetsat_sarah3',
|
||||
89: 'eumetsat_lsa_saf_msg',
|
||||
90: 'eumetsat_lsa_saf_iodc',
|
||||
91: 'satellite_radiation_seamless',
|
||||
92: 'kma_gdps',
|
||||
93: 'kma_ldps',
|
||||
94: 'kma_seamless',
|
||||
95: 'italia_meteo_arpae_icon_2i',
|
||||
96: 'ukmo_uk_ensemble_2km',
|
||||
97: 'meteofrance_arome_france_hd_15min',
|
||||
98: 'meteofrance_arome_france_15min',
|
||||
99: 'meteoswiss_icon_ch1',
|
||||
100: 'meteoswiss_icon_ch2',
|
||||
101: 'meteoswiss_icon_ch1_ensemble',
|
||||
102: 'meteoswiss_icon_ch2_ensemble',
|
||||
103: 'meteoswiss_icon_seamless',
|
||||
104: 'ncep_nam_conus',
|
||||
105: 'icon_d2_ruc',
|
||||
106: 'ecmwf_seas5',
|
||||
107: 'ecmwf_ec46',
|
||||
108: 'ecmwf_seasonal_seamless',
|
||||
109: 'ecmwf_ifs_seamless',
|
||||
110: 'jma_jaxa_mtg_fci',
|
||||
111: 'gem_hrdps_west'
|
||||
};
|
||||
return modelMap[modelEnum] ?? `model_${modelEnum}`;
|
||||
}
|
||||
+18
-98
@@ -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,83 +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
|
||||
},
|
||||
charts: {
|
||||
temperature: true,
|
||||
cloud_cover: true,
|
||||
precipitation: true,
|
||||
precipitation_probability: true,
|
||||
wind: true,
|
||||
humidity: true
|
||||
}
|
||||
};
|
||||
|
||||
export const storedVariablePrefs = persisted<VariablePrefs>('variable_prefs', defaultVariablePrefs);
|
||||
|
||||
/**
|
||||
* 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: ['weather_icons', 'temperature', 'cloud_cover'] },
|
||||
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
|
||||
{ id: 'panel-3', variables: ['wind', 'wind_direction', 'humidity'] }
|
||||
];
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
export const defaultUnits: UnitPrefs = {
|
||||
temperature_unit: 'celsius',
|
||||
wind_speed_unit: 'kmh',
|
||||
precipitation_unit: 'mm'
|
||||
};
|
||||
|
||||
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)}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export function geoLocationNameToRoute(name: string): string {
|
||||
// Placeholder implementation
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-*|-*$/g, '');
|
||||
}
|
||||
@@ -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,46 +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 md:px-8 md:py-6'}
|
||||
>
|
||||
{#if fullBleed}
|
||||
{@render children()}
|
||||
{:else}
|
||||
<!-- cap the content width on very large screens -->
|
||||
<div class="mx-auto w-full max-w-[1536px]">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<main class="min-h-screen">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('Drizzli');
|
||||
const heading = page.getByRole('heading', { level: 1 });
|
||||
await expect.element(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}¤t=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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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,357 +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',
|
||||
'relative_humidity_2m',
|
||||
'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[];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Min/max spread band + mean, instead of every individual member
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
name: 'Max',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.max,
|
||||
width: 1,
|
||||
fill: true,
|
||||
fillOpacity: 0.25,
|
||||
bandTo: varData.min,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Mean',
|
||||
type: isColumn ? 'bar' : 'line',
|
||||
color: CHART_COLORS.average,
|
||||
data: varData.average,
|
||||
width: 3,
|
||||
dashed: !isColumn,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
},
|
||||
{
|
||||
name: 'Min',
|
||||
type: 'line',
|
||||
color: BAND_COLOR,
|
||||
data: varData.min,
|
||||
width: 1,
|
||||
format: (v) => `${v.toFixed(1)} ${unit}`
|
||||
}
|
||||
];
|
||||
|
||||
const isFirst = vi === 0;
|
||||
const isLast = vi === variables.length - 1;
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
return defs;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ─── Page hero: location + ensemble model selection ─────────────────────── -->
|
||||
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<div class="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="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}
|
||||
|
||||
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={fetchedData.timezone}
|
||||
series={def.series}
|
||||
bands={fetchedData.daylightBands}
|
||||
unit={def.unit}
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
showCredit={def.showCredit}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</ChartContainer>
|
||||
|
||||
<!-- ─── 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 };
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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} / {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} / {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,513 +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', 'relative_humidity_2m', '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
|
||||
});
|
||||
|
||||
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="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<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="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 -->
|
||||
<ChartContainer
|
||||
{loading}
|
||||
chartCount={params.hourly?.filter((v) => v !== 'weather_code').length || 1}
|
||||
chartHeight={300}
|
||||
>
|
||||
{#if fetchedData}
|
||||
{#each chartDefs as def, i (i)}
|
||||
<CanvasChart
|
||||
bind:this={chartComponents[i]}
|
||||
timestamps={timestampsSec}
|
||||
timezone={fetchedData.timezone}
|
||||
series={def.series}
|
||||
bands={fetchedData.daylightBands}
|
||||
unit={def.unit}
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
showCredit={def.showCredit}
|
||||
{showLegend}
|
||||
height={300}
|
||||
group={CHART_GROUP}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</ChartContainer>
|
||||
|
||||
{#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} / {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} / {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
@@ -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'
|
||||
};
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
interface ConfigInterface {
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
deltaX: number;
|
||||
minTemp: number;
|
||||
maxTemp: number;
|
||||
diffTemp: number;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
"kinshasa",
|
||||
"shenzhen",
|
||||
"shanghai",
|
||||
"guangzhou",
|
||||
"chengdu",
|
||||
"beijing",
|
||||
"mumbai",
|
||||
"lagos",
|
||||
"lahore",
|
||||
"istanbul"
|
||||
]
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -1,235 +1,686 @@
|
||||
<script lang="ts">
|
||||
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 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)
|
||||
);
|
||||
|
||||
// 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 winddir = true;
|
||||
entries = 6;
|
||||
|
||||
const selectedDay = new SvelteDate();
|
||||
let scrollDiv: HTMLElement | undefined = $state();
|
||||
let tableCells;
|
||||
|
||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
||||
const switchDay = (date: Date, index: number) => {
|
||||
selectedDay = date;
|
||||
|
||||
// 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());
|
||||
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, behavior: 'smooth' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectedDayIndex = index;
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
// preselect the persisted model (client-only so prerendered HTML stays stable)
|
||||
params.models = [get(storedModel)];
|
||||
mounted = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}, 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>Drizzli | 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="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-3">
|
||||
<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="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
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
{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}
|
||||
{/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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<VariableSidebar open={variableSidebarOpen} onClose={() => (variableSidebarOpen = false)} />
|
||||
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
Failed to load weather data: {loadError}
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
<DailyCards
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
onSelectDay={switchDay}
|
||||
canExtend={forecastDays < 15}
|
||||
onExtend={() => (forecastDays = 15)}
|
||||
canExtendPast={pastDays < 3}
|
||||
onExtendPast={() => (pastDays = 3)}
|
||||
/>
|
||||
|
||||
{#if fetchedHourly && fetchedDaily}
|
||||
<HourlyTable
|
||||
data={fetchedHourly}
|
||||
daily={fetchedDaily}
|
||||
{selectedDay}
|
||||
units={params}
|
||||
locationName={location.name ?? ''}
|
||||
onCustomize={() => (variableSidebarOpen = true)}
|
||||
/>
|
||||
{:else}
|
||||
<!-- placeholder with the table's approximate height: no layout shift -->
|
||||
<div class="h-[430px] animate-pulse rounded-2xl border border-border/70 bg-card"></div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} chartHeight={300} />
|
||||
</section>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -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}N° ${longitude}E°`,
|
||||
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,400 +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 off to the left
|
||||
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="mb-6 min-h-[260px]">
|
||||
<!-- negative margin + matching padding: the scroll box gains room so a
|
||||
lifted/scaled/shadowed card is never clipped, while the first card still
|
||||
lines up with the page content edge -->
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class="day-scroll -mx-3 flex gap-2 overflow-x-auto px-3 pt-5 pb-11"
|
||||
class:scrolling
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if daily}
|
||||
{#if canExtendPast && onExtendPast}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
onclick={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). On md+ we also reserve room so the "load more" button stays
|
||||
visible; on mobile it's simply reached by scrolling (never clipped). -->
|
||||
<div
|
||||
bind:this={cardsWrapEl}
|
||||
class="cards-fill flex gap-2"
|
||||
style="--fill-reserve: {canExtend ? '6.5rem' : '0rem'}"
|
||||
>
|
||||
{#each daily.dailyDates as time, index (index)}
|
||||
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
|
||||
{@const tempMax = daily.daily.temperature_2m_max[index]}
|
||||
{@const tempMin = daily.daily.temperature_2m_min[index]}
|
||||
{@const wCode = daily.daily.weather_code[index]}
|
||||
{@const sunDuration = daily.daily.sunshine_duration[index]}
|
||||
{@const daylightSec = getDaylightSeconds(index)}
|
||||
{@const sunColor = getSunshineColor(sunDuration, daylightSec)}
|
||||
{@const sunPct = getSunshinePercent(sunDuration, daylightSec)}
|
||||
{@const precipSum = daily.daily.precipitation_sum[index]}
|
||||
{@const windMax = daily.daily.windspeed_10m_max[index]}
|
||||
{@const gustMax = daily.daily.windgusts_10m_max[index]}
|
||||
{@const windDir = daily.daily.winddirection_10m_dominant[index]}
|
||||
{@const unit = String(units.temperature_unit)}
|
||||
{@const maxStyle = getTempStyle(tempMax, unit)}
|
||||
{@const lowSun = !sunIsSignificant(sunDuration, daylightSec)}
|
||||
{@const lowPrecip = !precipIsSignificant(precipSum, String(units.precipitation_unit))}
|
||||
{@const lowWind = !windIsSignificant(windMax, gustMax, String(units.wind_speed_unit))}
|
||||
{#if tempMax != null && !isNaN(tempMax) && !(tempMax === 0 && tempMin === 0)}
|
||||
<button
|
||||
class="day-card group relative flex w-35 shrink-0 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200 ease-out will-change-transform motion-reduce:transition-none
|
||||
{selected
|
||||
? 'z-10 -translate-y-1 scale-[1.04] border-primary bg-primary/10 shadow-xl ring-2 ring-primary/60'
|
||||
: 'border-border/60 bg-card shadow-xs hover:z-10 hover:-translate-y-1 hover:scale-[1.02] hover:border-border hover:shadow-lg'}"
|
||||
aria-pressed={selected}
|
||||
onclick={() => onSelectDay(time, index)}
|
||||
>
|
||||
<!-- Day label -->
|
||||
<span
|
||||
class="text-[13px] font-semibold tracking-wider {selected ? 'text-primary' : ''}"
|
||||
>
|
||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
class="-mt-1 text-[11px] {selected
|
||||
? 'font-medium text-primary/80'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{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}
|
||||
</div>
|
||||
|
||||
{#if canExtend && onExtend}
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8 7V3m8 4V3M4 11h16M5 21h14a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Load<br />15 days
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
.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,665 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { storedVariablePrefs } from '$lib/stores/settings';
|
||||
|
||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import { 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);
|
||||
}
|
||||
|
||||
let hourlyInterval = $state<1 | 3>(3);
|
||||
|
||||
// Row visibility, controlled from the Variables sidebar (missing keys
|
||||
// from older stored prefs default to visible)
|
||||
let showRow = $derived((key: string): boolean => $storedVariablePrefs.table?.[key] ?? true);
|
||||
|
||||
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})`;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
</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={() => (hourlyInterval = 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">
|
||||
<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-11 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 leading-0 {is3h ? 'h-14' : 'h-11'}"
|
||||
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-10 font-bold {is3h ? 'text-lg' : 'text-[15px]'}"
|
||||
style="background-color:{style.bg};color:{style.fg}"
|
||||
>
|
||||
{formatTemp(temp)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Feels Like -->
|
||||
{#if showRow('feels')}
|
||||
<tr class="row">
|
||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
||||
<td class="cell h-8 text-muted-foreground {is3h ? 'text-[13px]' : 'text-[11px]'}">
|
||||
{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="cell h-13 align-middle leading-tight">
|
||||
{#if windDir != null && !isNaN(windDir)}
|
||||
<span
|
||||
class="inline-block leading-0"
|
||||
style="transform:{getWindArrowRotation(windDir)}"
|
||||
>
|
||||
{@render weatherIcon('wi-direction-down', 22)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="block font-semibold {is3h ? 'text-sm' : 'text-xs'}">
|
||||
{formatValue(wind)}
|
||||
</span>
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/if}
|
||||
|
||||
<!-- Humidity -->
|
||||
{#if showRow('humidity')}
|
||||
<tr class="row">
|
||||
{@render rowHeader('wi-humidity', '%', 'Humidity')}
|
||||
{#each cellData as cell (cell.idx)}
|
||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
||||
<td class="cell h-8" style="background:{getHumidityBg(hum ?? 0)}">
|
||||
{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-8"
|
||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
||||
>
|
||||
{formatValue(cloud)}
|
||||
</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 {is3h ? 'h-14' : 'h-11'}"
|
||||
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}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- "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,296 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { type ChartPanel, storedChartLayout } from '$lib/stores/settings';
|
||||
|
||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
||||
|
||||
import { ChartContainer, ChartToolbar } 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);
|
||||
|
||||
// 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 rangePresets = [
|
||||
{ label: 'Today', apply: () => setRangeDays(new Date(), 1) },
|
||||
{ label: 'Selected day', apply: () => setRangeDays(selectedDay, 1) },
|
||||
{ label: '3 days', apply: () => setRangeDays(new Date(), 3) },
|
||||
{ label: '5 days', apply: () => setRangeDays(new Date(), 5) },
|
||||
{ label: 'All', apply: () => resetZoom() }
|
||||
];
|
||||
|
||||
// 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>
|
||||
{#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>
|
||||
<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>
|
||||
</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 on mobile / contained card on md+, graphs stacked
|
||||
tightly so they read as one fluent meteogram -->
|
||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm md:mx-0 md:rounded-2xl md:border">
|
||||
{#each renderPanels as panel, i (panel.id)}
|
||||
<div
|
||||
class="px-0 pt-2 pb-1 md:px-4 {i > 0 ? 'border-t border-border/50' : 'md:pt-4'} {i ===
|
||||
renderPanels.length - 1
|
||||
? 'pb-3 md:pb-4'
|
||||
: ''}"
|
||||
>
|
||||
<div class="mb-0.5 flex items-center justify-between px-3 md:px-0">
|
||||
<h4 class="truncate text-xs font-bold tracking-wide text-muted-foreground uppercase">
|
||||
<span class="hidden md:inline">{panel.title}</span>
|
||||
<span class="md: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>
|
||||
|
||||
<div class="mt-6 md:mt-10">
|
||||
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<ChartCustomizer open={customizerOpen} onClose={() => (customizerOpen = false)} />
|
||||
@@ -1,97 +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-14 min-w-0 flex-1 cursor-pointer gap-3 rounded-xl border-2 border-primary/35 bg-card py-2 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-w-72 sm:flex-none"
|
||||
>
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/12 text-primary"
|
||||
>
|
||||
<!-- layered-globe icon: weather model -->
|
||||
<svg class="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-sm font-bold text-foreground">{modelLabel}</span>
|
||||
{#if modelMeta}
|
||||
<span class="max-w-full truncate text-[11px] leading-tight text-muted-foreground">
|
||||
{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,117 +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: 'wind', label: 'Wind' },
|
||||
{ key: 'humidity', label: 'Humidity' },
|
||||
{ key: 'clouds', label: 'Cloud cover' },
|
||||
{ key: 'precipitation', label: 'Precipitation' }
|
||||
];
|
||||
|
||||
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];
|
||||
};
|
||||
@@ -1,490 +0,0 @@
|
||||
/**
|
||||
* Registry of every variable that can be plotted on the customizable
|
||||
* meteograms. Each entry carries the metadata needed to build a chart series
|
||||
* (data field, render style, colour, unit family) so the panels can be
|
||||
* assembled dynamically from a user-defined layout.
|
||||
*/
|
||||
import { getColor } from '../../utils/colors';
|
||||
import {
|
||||
type WeatherUnits,
|
||||
getPrecipUnit,
|
||||
getTempUnit,
|
||||
getWindDirectionLabel,
|
||||
getWindUnit
|
||||
} from './types';
|
||||
|
||||
import type { ChartSeries } from '$lib/charts';
|
||||
import type { WeekHourlyData } from '$lib/services/weather';
|
||||
|
||||
/** Families of variables that share a y-axis and unit. */
|
||||
export type UnitKind =
|
||||
'temp' | 'precip' | 'snow' | 'wind' | 'percent' | 'pressure' | 'uv' | 'distance' | 'energy';
|
||||
|
||||
export interface ChartVariableDef {
|
||||
/** Stable id used in the persisted layout */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Short label for the tooltip / legend */
|
||||
short: string;
|
||||
/** Data array on the hourly response */
|
||||
field: keyof WeekHourlyData;
|
||||
/** Open-Meteo API variable name (defaults to `field` when identical) */
|
||||
api?: string;
|
||||
type: 'line' | 'bar';
|
||||
kind: UnitKind;
|
||||
color: string;
|
||||
dashed?: boolean;
|
||||
fill?: boolean;
|
||||
fillOpacity?: number;
|
||||
width?: number;
|
||||
/** Stroke the line coloured by the temperature scale */
|
||||
colorScale?: boolean;
|
||||
/** Draw a contrasting halo under the line */
|
||||
outline?: boolean;
|
||||
/** Annotate local minima / maxima with their value */
|
||||
extrema?: boolean;
|
||||
/** Draw weather-code pictograms across the top of the chart */
|
||||
pictograms?: boolean;
|
||||
/** Draw wind-direction arrows across the top of the chart */
|
||||
windArrows?: boolean;
|
||||
/** Marker-only variable (icons / arrows): contributes no plotted series */
|
||||
marker?: boolean;
|
||||
/** Render as a puffy cloud band hanging from the top (0-100 → 0-40px) */
|
||||
cloudBand?: boolean;
|
||||
/** Transform raw values before plotting (e.g. m → km) */
|
||||
transform?: (v: number) => number;
|
||||
/** Preset range to use when this variable lands on a shared right axis */
|
||||
rightPreset?: { min: number; max: number; invert?: boolean };
|
||||
}
|
||||
|
||||
export const CHART_VARIABLES: ChartVariableDef[] = [
|
||||
{
|
||||
key: 'temperature',
|
||||
label: 'Temperature',
|
||||
short: 'Temp',
|
||||
field: 'temperature_2m',
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#ef6c00',
|
||||
width: 9,
|
||||
colorScale: true,
|
||||
outline: true,
|
||||
extrema: true
|
||||
},
|
||||
{
|
||||
key: 'weather_icons',
|
||||
label: 'Weather icons',
|
||||
short: 'Icons',
|
||||
field: 'weather_code',
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#94a3b8',
|
||||
pictograms: true,
|
||||
marker: true
|
||||
},
|
||||
{
|
||||
key: 'apparent_temperature',
|
||||
label: 'Apparent Temp',
|
||||
short: 'Feels',
|
||||
field: 'apparent_temperature',
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#c2410c',
|
||||
width: 2,
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
key: 'dew_point',
|
||||
label: 'Dew Point',
|
||||
short: 'Dew',
|
||||
field: 'dew_point_2m',
|
||||
type: 'line',
|
||||
kind: 'temp',
|
||||
color: '#0e7490',
|
||||
width: 2
|
||||
},
|
||||
{
|
||||
key: 'cloud_cover',
|
||||
label: 'Cloud Cover',
|
||||
short: 'Cloud',
|
||||
field: 'cloud_cover',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: 'rgb(150, 155, 165)',
|
||||
cloudBand: true
|
||||
},
|
||||
{
|
||||
key: 'cloud_cover_low',
|
||||
label: 'Cloud Cover Low',
|
||||
short: 'Low',
|
||||
field: 'cloud_cover_low',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: '#94a3b8',
|
||||
width: 2
|
||||
},
|
||||
{
|
||||
key: 'cloud_cover_mid',
|
||||
label: 'Cloud Cover Mid',
|
||||
short: 'Mid',
|
||||
field: 'cloud_cover_mid',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: '#64748b',
|
||||
width: 2
|
||||
},
|
||||
{
|
||||
key: 'cloud_cover_high',
|
||||
label: 'Cloud Cover High',
|
||||
short: 'High',
|
||||
field: 'cloud_cover_high',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: '#cbd5e1',
|
||||
width: 2
|
||||
},
|
||||
{
|
||||
key: 'precipitation',
|
||||
label: 'Precipitation',
|
||||
short: 'Precip',
|
||||
field: 'precipitation',
|
||||
type: 'bar',
|
||||
kind: 'precip',
|
||||
color: 'rgba(30, 136, 229, 0.8)'
|
||||
},
|
||||
{
|
||||
key: 'precipitation_probability',
|
||||
label: 'Precip. Probability',
|
||||
short: 'PoP',
|
||||
field: 'precipitation_probability',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: '#5c6bc0',
|
||||
width: 2,
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
key: 'rain',
|
||||
label: 'Rain',
|
||||
short: 'Rain',
|
||||
field: 'rain',
|
||||
type: 'bar',
|
||||
kind: 'precip',
|
||||
color: 'rgba(37, 99, 235, 0.75)'
|
||||
},
|
||||
{
|
||||
key: 'showers',
|
||||
label: 'Showers',
|
||||
short: 'Shwr',
|
||||
field: 'showers',
|
||||
type: 'bar',
|
||||
kind: 'precip',
|
||||
color: 'rgba(6, 182, 212, 0.75)'
|
||||
},
|
||||
{
|
||||
key: 'snowfall',
|
||||
label: 'Snowfall',
|
||||
short: 'Snow',
|
||||
field: 'snowfall',
|
||||
type: 'bar',
|
||||
kind: 'snow',
|
||||
color: 'rgba(147, 197, 253, 0.9)'
|
||||
},
|
||||
{
|
||||
key: 'wind',
|
||||
label: 'Wind Speed',
|
||||
short: 'Wind',
|
||||
field: 'windspeed_10m',
|
||||
api: 'wind_speed_10m',
|
||||
type: 'line',
|
||||
kind: 'wind',
|
||||
color: '#26a69a',
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.15
|
||||
},
|
||||
{
|
||||
key: 'wind_direction',
|
||||
label: 'Wind direction',
|
||||
short: 'Dir',
|
||||
field: 'winddirection_10m',
|
||||
api: 'wind_direction_10m',
|
||||
type: 'line',
|
||||
kind: 'wind',
|
||||
color: '#14b8a6',
|
||||
windArrows: true,
|
||||
marker: true
|
||||
},
|
||||
{
|
||||
key: 'wind_gusts',
|
||||
label: 'Wind Gusts',
|
||||
short: 'Gusts',
|
||||
field: 'wind_gusts_10m',
|
||||
type: 'line',
|
||||
kind: 'wind',
|
||||
color: '#0d9488',
|
||||
width: 2,
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
key: 'humidity',
|
||||
label: 'Humidity',
|
||||
short: 'RH',
|
||||
field: 'relative_humidity_2m',
|
||||
type: 'line',
|
||||
kind: 'percent',
|
||||
color: '#8d6e63',
|
||||
width: 2,
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
key: 'pressure_msl',
|
||||
label: 'Pressure (MSL)',
|
||||
short: 'MSLP',
|
||||
field: 'pressure_msl',
|
||||
type: 'line',
|
||||
kind: 'pressure',
|
||||
color: '#7c3aed',
|
||||
width: 2
|
||||
},
|
||||
{
|
||||
key: 'surface_pressure',
|
||||
label: 'Surface Pressure',
|
||||
short: 'Psfc',
|
||||
field: 'surface_pressure',
|
||||
type: 'line',
|
||||
kind: 'pressure',
|
||||
color: '#a855f7',
|
||||
width: 2,
|
||||
dashed: true
|
||||
},
|
||||
{
|
||||
key: 'uv_index',
|
||||
label: 'UV Index',
|
||||
short: 'UV',
|
||||
field: 'uv_index',
|
||||
type: 'line',
|
||||
kind: 'uv',
|
||||
color: '#eab308',
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.15
|
||||
},
|
||||
{
|
||||
key: 'visibility',
|
||||
label: 'Visibility',
|
||||
short: 'Vis',
|
||||
field: 'visibility',
|
||||
type: 'line',
|
||||
kind: 'distance',
|
||||
color: '#0891b2',
|
||||
width: 2,
|
||||
transform: (v) => v / 1000
|
||||
},
|
||||
{
|
||||
key: 'cape',
|
||||
label: 'CAPE',
|
||||
short: 'CAPE',
|
||||
field: 'cape',
|
||||
type: 'line',
|
||||
kind: 'energy',
|
||||
color: '#dc2626',
|
||||
width: 2,
|
||||
fill: true,
|
||||
fillOpacity: 0.12
|
||||
}
|
||||
];
|
||||
|
||||
export const VARIABLE_BY_KEY: Map<string, ChartVariableDef> = new Map(
|
||||
CHART_VARIABLES.map((v) => [v.key, v])
|
||||
);
|
||||
|
||||
/** Open-Meteo API variable name for a registry entry. */
|
||||
export function apiNameOf(def: ChartVariableDef): string {
|
||||
return def.api ?? def.field;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of API hourly variables needed to render the current table rows and
|
||||
* chart layout, so the fetch requests only what is actually shown.
|
||||
*/
|
||||
export function neededHourlyApiVars(
|
||||
tablePrefs: Record<string, boolean> | undefined,
|
||||
layoutKeys: string[]
|
||||
): string[] {
|
||||
const on = (key: string): boolean => tablePrefs?.[key] ?? true;
|
||||
const s = new Set<string>();
|
||||
|
||||
// Hourly table rows
|
||||
if (on('icons')) s.add('weather_code');
|
||||
if (on('temperature')) s.add('temperature_2m');
|
||||
if (on('feels')) s.add('apparent_temperature');
|
||||
if (on('wind')) {
|
||||
s.add('wind_speed_10m');
|
||||
s.add('wind_direction_10m');
|
||||
}
|
||||
if (on('humidity')) s.add('relative_humidity_2m');
|
||||
if (on('clouds')) s.add('cloud_cover');
|
||||
if (on('precipitation')) {
|
||||
s.add('precipitation');
|
||||
s.add('precipitation_probability');
|
||||
}
|
||||
|
||||
// Meteogram variables
|
||||
for (const key of layoutKeys) {
|
||||
const def = VARIABLE_BY_KEY.get(key);
|
||||
if (!def) continue;
|
||||
s.add(apiNameOf(def));
|
||||
if (def.pictograms) s.add('weather_code');
|
||||
if (def.windArrows || def.key === 'wind') s.add('wind_direction_10m');
|
||||
}
|
||||
|
||||
return [...s];
|
||||
}
|
||||
|
||||
/** Unit label for a variable family, honouring the user's unit settings. */
|
||||
export function unitForKind(kind: UnitKind, units: WeatherUnits): string {
|
||||
switch (kind) {
|
||||
case 'temp':
|
||||
return getTempUnit(units);
|
||||
case 'precip':
|
||||
return getPrecipUnit(units);
|
||||
case 'snow':
|
||||
return 'cm';
|
||||
case 'wind':
|
||||
return getWindUnit(units);
|
||||
case 'percent':
|
||||
return '%';
|
||||
case 'pressure':
|
||||
return 'hPa';
|
||||
case 'uv':
|
||||
return '';
|
||||
case 'distance':
|
||||
return 'km';
|
||||
case 'energy':
|
||||
return 'J/kg';
|
||||
}
|
||||
}
|
||||
|
||||
/** Families whose axis should always start at zero. */
|
||||
export function isZeroBased(kind: UnitKind): boolean {
|
||||
return kind !== 'temp' && kind !== 'pressure';
|
||||
}
|
||||
|
||||
/** Decimal places for on-chart extrema labels (kept coarse, like the cards). */
|
||||
export function decimalsForKind(kind: UnitKind): number {
|
||||
switch (kind) {
|
||||
case 'precip':
|
||||
case 'snow':
|
||||
case 'uv':
|
||||
case 'distance':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decimal places for the hover tooltip — finer than the cards / extrema labels
|
||||
* so the meteogram reveals more detail. Percentages stay whole numbers.
|
||||
*/
|
||||
export function tooltipDecimalsForKind(kind: UnitKind): number {
|
||||
switch (kind) {
|
||||
case 'percent':
|
||||
case 'energy':
|
||||
return 0;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PanelDef {
|
||||
series: ChartSeries[];
|
||||
unit: string;
|
||||
unitRight?: string;
|
||||
yMin?: number;
|
||||
yMinRight?: number;
|
||||
yMaxRight?: number;
|
||||
/** Whether the left axis should include zero (false for pressure) */
|
||||
zeroBaseLeft: boolean;
|
||||
hasPictograms: boolean;
|
||||
hasWindArrows: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a chart definition for one panel: turns its ordered variable keys into
|
||||
* series and works out the left / right axis units. The first variable's family
|
||||
* owns the left axis; the first differing family gets the right axis.
|
||||
*/
|
||||
export function buildPanelDef(
|
||||
variableKeys: string[],
|
||||
hourly: WeekHourlyData,
|
||||
units: WeatherUnits
|
||||
): PanelDef {
|
||||
const allDefs = variableKeys
|
||||
.map((k) => VARIABLE_BY_KEY.get(k))
|
||||
.filter((d): d is ChartVariableDef => d != null);
|
||||
|
||||
// Marker-only variables (weather icons) render no series, just a top row.
|
||||
const defs = allDefs.filter((d) => !d.marker);
|
||||
|
||||
// Cloud-band variables float above the plot and don't claim an axis.
|
||||
const axisDefs = defs.filter((d) => !d.cloudBand);
|
||||
const kinds: UnitKind[] = [];
|
||||
for (const d of axisDefs) if (!kinds.includes(d.kind)) kinds.push(d.kind);
|
||||
const leftKind = kinds[0];
|
||||
const rightKind = kinds.find((k) => k !== leftKind);
|
||||
|
||||
const series: ChartSeries[] = defs.map((d) => {
|
||||
const raw = (hourly[d.field] as number[]) ?? [];
|
||||
const data: (number | null)[] = d.transform
|
||||
? raw.map((v) => (v == null || !isFinite(v) ? null : d.transform!(v)))
|
||||
: raw;
|
||||
const kindUnit = unitForKind(d.kind, units);
|
||||
const dec = decimalsForKind(d.kind);
|
||||
const tipDec = tooltipDecimalsForKind(d.kind);
|
||||
const axis: 'left' | 'right' = d.cloudBand || d.kind === leftKind ? 'left' : 'right';
|
||||
|
||||
return {
|
||||
name: d.label,
|
||||
shortName: d.short,
|
||||
type: d.type,
|
||||
color: d.color,
|
||||
data,
|
||||
width: d.width,
|
||||
fill: d.fill,
|
||||
fillOpacity: d.fillOpacity,
|
||||
dashed: d.dashed,
|
||||
axis,
|
||||
cloudBand: d.cloudBand,
|
||||
segmentColor: d.colorScale ? (v: number) => getColor(v, units.temperature_unit) : undefined,
|
||||
outline: d.outline,
|
||||
labelExtrema: d.extrema,
|
||||
labelFormat: d.extrema
|
||||
? (v: number) => (d.kind === 'temp' ? `${v.toFixed(0)}°` : `${v.toFixed(dec)}${kindUnit}`)
|
||||
: undefined,
|
||||
format:
|
||||
d.key === 'wind'
|
||||
? (v: number, i: number) => {
|
||||
const dir = hourly.winddirection_10m?.[i];
|
||||
const dl = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
|
||||
return `${v.toFixed(tipDec)} ${kindUnit}${dl}`;
|
||||
}
|
||||
: (v: number) => `${v.toFixed(tipDec)}${kindUnit ? ' ' + kindUnit : ''}`
|
||||
} satisfies ChartSeries;
|
||||
});
|
||||
|
||||
const rightZero = rightKind ? isZeroBased(rightKind) : false;
|
||||
return {
|
||||
series,
|
||||
unit: leftKind ? unitForKind(leftKind, units) : '',
|
||||
unitRight: rightKind ? unitForKind(rightKind, units) : undefined,
|
||||
yMin: leftKind && isZeroBased(leftKind) ? 0 : undefined,
|
||||
// pressure sits far from zero, so its axis is derived from the data
|
||||
zeroBaseLeft: leftKind !== 'pressure',
|
||||
yMinRight: rightKind && rightZero ? 0 : undefined,
|
||||
yMaxRight: rightKind === 'percent' ? 100 : undefined,
|
||||
hasPictograms: allDefs.some((d) => d.pictograms),
|
||||
hasWindArrows: allDefs.some((d) => d.windArrows)
|
||||
};
|
||||
}
|
||||
+4
-33
@@ -1,45 +1,16 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// fallback: the SPA shell served for routes that were not prerendered
|
||||
// (unlisted cities, GPS coordinate routes); the universal load then
|
||||
// resolves the location client-side. Most static hosts serve 404.html
|
||||
// for unknown paths automatically.
|
||||
adapter: adapter({ fallback: '404.html' }),
|
||||
// Pregenerate city pages to improve SEO during static build
|
||||
prerender: {
|
||||
// dynamic per-location routes (14-day, compare, unlisted cities) are
|
||||
// served by the SPA fallback instead of being prerendered
|
||||
handleUnseenRoutes: 'ignore',
|
||||
// a transient geocoding failure for one city should skip that page
|
||||
// (the fallback still serves it), not abort the whole build
|
||||
handleHttpError: ({ path, message }) => {
|
||||
console.warn(`prerender skipped ${path}: ${message}`);
|
||||
},
|
||||
entries: (() => {
|
||||
try {
|
||||
const citiesPath = path.resolve('src/routes/weather/locations/city-names100.json');
|
||||
const raw = fs.readFileSync(citiesPath, 'utf-8');
|
||||
const cities = JSON.parse(raw);
|
||||
if (Array.isArray(cities)) {
|
||||
const cityEntries = cities.map((c) => `/weather/week/${c}`);
|
||||
// Keep the default wildcard to include other routes
|
||||
return ['*', ...cityEntries];
|
||||
}
|
||||
} catch {
|
||||
// If anything goes wrong, fall back to default behavior
|
||||
}
|
||||
return ['*'];
|
||||
})()
|
||||
}
|
||||
adapter: adapter({
|
||||
fallback: '404.html'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+1
-27
@@ -3,34 +3,8 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { Plugin, PreviewServer, ViteDevServer } from 'vite';
|
||||
|
||||
const addHeaders = (res: ServerResponse) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
};
|
||||
|
||||
const viteServerConfig = (): Plugin => ({
|
||||
name: 'add-headers',
|
||||
configureServer: (server: ViteDevServer) => {
|
||||
server.middlewares.use((_req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||
addHeaders(res);
|
||||
next();
|
||||
});
|
||||
},
|
||||
configurePreviewServer: (server: PreviewServer) => {
|
||||
server.middlewares.use((_req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||
addHeaders(res);
|
||||
next();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
|
||||
test: {
|
||||
expect: { requireAssertions: true },
|
||||
|
||||
Reference in New Issue
Block a user