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 @@
|
|||||||
# OMbrella
|
# 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/).
|
An open-source, high-performance weather forecast website built with SvelteKit and powered by the [Open-Meteo APIs](https://open-meteo.com/).
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ Our objective is to provide a comprehensive, user-friendly weather platform for
|
|||||||
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
- **Framework**: [SvelteKit](https://kit.svelte.dev/)
|
||||||
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
|
- **Data Source**: [Open-Meteo API](https://open-meteo.com/)
|
||||||
- **Visualization**: [Apache Echarts](https://echarts.apache.org)
|
- **Visualization**: [Highcharts](https://www.highcharts.com/) (Current, transitioning to a more flexible charting library in the future)
|
||||||
|
|
||||||
## Developing
|
## Developing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"userWords": ["ConfigInterface"]
|
||||||
|
}
|
||||||
Generated
+542
-665
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -14,7 +14,7 @@
|
|||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test": "npm run test:unit -- --run",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -49,10 +49,11 @@
|
|||||||
"typescript-eslint": "^8.48.1",
|
"typescript-eslint": "^8.48.1",
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.15",
|
"vitest": "^4.0.15",
|
||||||
"vitest-browser-svelte": "^2.0.1",
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
"date-fns": "^4.1.0",
|
},
|
||||||
"date-fns-tz": "^3.2.0",
|
"dependencies": {
|
||||||
"echarts": "^6.0.0",
|
"@openmeteo/sdk": "^1.23.0",
|
||||||
|
"highcharts": "^12.4.0",
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"openmeteo": "^1.2.3"
|
"openmeteo": "^1.2.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +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}
|
|
||||||
<EChart option={chart.option} />
|
|
||||||
{/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;
|
|
||||||
/** 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,
|
|
||||||
class: className = '',
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="chart-container relative {className}"
|
|
||||||
style:min-height="{minHeight}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-accent 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>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.chart-container {
|
|
||||||
/* Negative horizontal margin on mobile to use full width, reset on md+ */
|
|
||||||
margin-left: -1.5rem;
|
|
||||||
margin-right: -1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.chart-container {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-content {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smooth transition for the loading overlay */
|
|
||||||
.loading-overlay {
|
|
||||||
will-change: opacity;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<!--
|
|
||||||
ChartToolbar.svelte — Chart action bar with download and display controls
|
|
||||||
|
|
||||||
Provides a toolbar row with:
|
|
||||||
- Download full meteogram as PNG button
|
|
||||||
- Download full meteogram as SVG button
|
|
||||||
- Slot for additional custom controls (e.g. legend toggle)
|
|
||||||
|
|
||||||
When multiple charts are provided, they are stitched into a single
|
|
||||||
combined image on download.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
<ChartToolbar
|
|
||||||
charts={chartInstances}
|
|
||||||
fileName="model-comparison"
|
|
||||||
>
|
|
||||||
{#snippet controls()}
|
|
||||||
<Switch bind:checked={showLegend} />
|
|
||||||
{/snippet}
|
|
||||||
</ChartToolbar>
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { downloadMeteogram } from '$lib/utils/echarts/download';
|
|
||||||
|
|
||||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Array of ECharts instances available for download */
|
|
||||||
charts?: echarts.ECharts[];
|
|
||||||
/** Base file name for downloaded images (without extension) */
|
|
||||||
fileName?: string;
|
|
||||||
/** Pixel ratio for PNG exports (default: 2) */
|
|
||||||
pixelRatio?: number;
|
|
||||||
/** Optional CSS class for the outer container */
|
|
||||||
class?: string;
|
|
||||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
|
||||||
controls?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
charts = [],
|
|
||||||
fileName = 'open-meteo-chart',
|
|
||||||
pixelRatio = 2,
|
|
||||||
class: className = '',
|
|
||||||
controls
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// ─── State ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let downloadingFormat: ExportFormat | null = $state(null);
|
|
||||||
|
|
||||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let hasCharts = $derived(charts.length > 0);
|
|
||||||
|
|
||||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleDownload(format: ExportFormat): Promise<void> {
|
|
||||||
if (!hasCharts || downloadingFormat) return;
|
|
||||||
|
|
||||||
downloadingFormat = format;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
downloadMeteogram(charts, { fileName, format, pixelRatio });
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
downloadingFormat = null;
|
|
||||||
}, 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 buttons -->
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<!-- Download as PNG -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toolbar-btn"
|
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
|
||||||
onclick={() => handleDownload('png')}
|
|
||||||
title="Download meteogram as PNG image"
|
|
||||||
>
|
|
||||||
{#if downloadingFormat === 'png'}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Download as SVG -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toolbar-btn"
|
|
||||||
disabled={!hasCharts || downloadingFormat !== null}
|
|
||||||
onclick={() => handleDownload('svg')}
|
|
||||||
title="Download meteogram as SVG vector image"
|
|
||||||
>
|
|
||||||
{#if downloadingFormat === 'svg'}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4 animate-spin"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
||||||
<polyline points="7 10 12 15 17 10" />
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<span>SVG</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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,165 +0,0 @@
|
|||||||
<!--
|
|
||||||
EChart.svelte — Reusable ECharts wrapper component
|
|
||||||
|
|
||||||
Handles chart lifecycle (init, update, dispose), responsive resize via
|
|
||||||
ResizeObserver, and exposes the ECharts instance for programmatic access
|
|
||||||
(e.g. export/download).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
<EChart
|
|
||||||
option={chartOption}
|
|
||||||
height="300px"
|
|
||||||
renderer="canvas"
|
|
||||||
onChartReady={(chart) => { ... }}
|
|
||||||
/>
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** The ECharts option object to render */
|
|
||||||
option: Record<string, unknown>;
|
|
||||||
/** CSS height of the chart container (default: '300px') */
|
|
||||||
height?: string;
|
|
||||||
/** CSS width of the chart container (default: '100%') */
|
|
||||||
width?: string;
|
|
||||||
/** Renderer type: 'canvas' or 'svg' (default: 'canvas') */
|
|
||||||
renderer?: 'canvas' | 'svg';
|
|
||||||
/** Whether to merge options on update (true) or replace them (false) */
|
|
||||||
notMerge?: boolean;
|
|
||||||
/** Whether to delay update until next animation frame */
|
|
||||||
lazyUpdate?: boolean;
|
|
||||||
/** Optional CSS class for the outer container */
|
|
||||||
class?: string;
|
|
||||||
/** Callback fired once the chart instance is initialized */
|
|
||||||
onChartReady?: (chart: echarts.ECharts) => void;
|
|
||||||
/** Callback fired when the chart is disposed */
|
|
||||||
onChartDisposed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
option,
|
|
||||||
height = '300px',
|
|
||||||
width = '100%',
|
|
||||||
renderer = 'canvas',
|
|
||||||
notMerge = false,
|
|
||||||
lazyUpdate = false,
|
|
||||||
class: className = '',
|
|
||||||
onChartReady,
|
|
||||||
onChartDisposed
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// ─── Internal State ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
let chartInstance: echarts.ECharts | null = null;
|
|
||||||
let resizeObserver: ResizeObserver | null = null;
|
|
||||||
|
|
||||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the underlying ECharts instance, or null if not yet initialized.
|
|
||||||
*/
|
|
||||||
export function getChart(): echarts.ECharts | null {
|
|
||||||
return chartInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the chart has been initialized and is not disposed.
|
|
||||||
*/
|
|
||||||
export function isReady(): boolean {
|
|
||||||
return chartInstance !== null && !chartInstance.isDisposed();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a manual resize of the chart.
|
|
||||||
* Useful after layout changes that the ResizeObserver might miss.
|
|
||||||
*/
|
|
||||||
export function resize(): void {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes the chart instance and cleans up observers.
|
|
||||||
* Called automatically on component destroy, but can be invoked manually.
|
|
||||||
*/
|
|
||||||
export function dispose(): void {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
initChart();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Reactivity: Update option when it changes ──────────────────────────────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed() && option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Init & Cleanup ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function initChart(): void {
|
|
||||||
if (!containerEl) return;
|
|
||||||
|
|
||||||
// Dispose any existing instance (e.g. from HMR)
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
chartInstance = echarts.init(containerEl, null, { renderer });
|
|
||||||
|
|
||||||
if (option) {
|
|
||||||
chartInstance.setOption(option, notMerge, lazyUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up responsive resize
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
|
||||||
if (chartInstance && !chartInstance.isDisposed()) {
|
|
||||||
chartInstance.resize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
resizeObserver.observe(containerEl);
|
|
||||||
|
|
||||||
onChartReady?.(chartInstance);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanup(): void {
|
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
resizeObserver = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chartInstance) {
|
|
||||||
if (!chartInstance.isDisposed()) {
|
|
||||||
chartInstance.dispose();
|
|
||||||
}
|
|
||||||
chartInstance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
onChartDisposed?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div bind:this={containerEl} class="echart-wrapper {className}" style:width style:height></div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.echart-wrapper {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
ECharts Global Styles — Open-Meteo Weather
|
|
||||||
|
|
||||||
Shared CSS for all ECharts chart instances across the application.
|
|
||||||
Provides consistent theming, tooltip styling, and responsive behavior
|
|
||||||
that integrates with the application's design system (Tailwind + shadcn).
|
|
||||||
═══════════════════════════════════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.echart-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Chart Container (legacy class support) ───────────────────────────────── */
|
|
||||||
|
|
||||||
.echarts-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure canvas background is always transparent so our page bg shows through */
|
|
||||||
.echart-wrapper canvas,
|
|
||||||
.echarts-container canvas {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Override ECharts' default tooltip to match the application's popover design */
|
|
||||||
.echarts-tooltip {
|
|
||||||
background: hsl(var(--popover)) !important;
|
|
||||||
border: 1px solid hsl(var(--border)) !important;
|
|
||||||
border-radius: var(--radius, 0.5rem) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
|
||||||
0 2px 4px -2px rgb(0 0 0 / 0.05) !important;
|
|
||||||
padding: 0.625rem 0.75rem !important;
|
|
||||||
font-size: 0.8125rem !important;
|
|
||||||
line-height: 1.4 !important;
|
|
||||||
max-width: min(90vw, 480px) !important;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.echarts-tooltip-content {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip marker dots — make them slightly larger and rounded */
|
|
||||||
.echarts-tooltip .echarts-tooltip-marker,
|
|
||||||
.echarts-tooltip span[style*="border-radius"] {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Loading Mask ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.echarts-loading-mask {
|
|
||||||
background: hsl(var(--background) / 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
[data-theme='light'] .echart-wrapper,
|
|
||||||
[data-theme='light'] .echarts-container,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echart-wrapper,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echarts-container {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='light'] .echarts-tooltip,
|
|
||||||
:root:not(.dark):not([data-theme='dark']) .echarts-tooltip {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.dark .echart-wrapper,
|
|
||||||
[data-theme='dark'] .echart-wrapper,
|
|
||||||
.dark .echarts-container,
|
|
||||||
[data-theme='dark'] .echarts-container {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .echarts-tooltip,
|
|
||||||
[data-theme='dark'] .echarts-tooltip {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 0.3),
|
|
||||||
0 2px 4px -2px rgb(0 0 0 / 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Make sure the toolbox icons are visually subtle until hovered */
|
|
||||||
.echart-wrapper [class*="toolbox"],
|
|
||||||
.echarts-container [class*="toolbox"] {
|
|
||||||
opacity: 0.6;
|
|
||||||
transition: opacity 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.echart-wrapper:hover [class*="toolbox"],
|
|
||||||
.echarts-container:hover [class*="toolbox"] {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Chart Spacing ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Add consistent vertical spacing between stacked chart instances */
|
|
||||||
.chart-content .echart-wrapper + .echart-wrapper {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Responsive Sizing ────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.echarts-container {
|
|
||||||
min-height: 240px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Slightly smaller tooltips on mobile */
|
|
||||||
.echarts-tooltip {
|
|
||||||
font-size: 0.75rem !important;
|
|
||||||
padding: 0.5rem 0.625rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) and (max-width: 1024px) {
|
|
||||||
.echarts-container {
|
|
||||||
min-height: 280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Print Styles ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.echart-wrapper,
|
|
||||||
.echarts-container {
|
|
||||||
break-inside: avoid;
|
|
||||||
page-break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide interactive elements when printing */
|
|
||||||
.echart-wrapper [class*="toolbox"],
|
|
||||||
.echarts-container [class*="toolbox"],
|
|
||||||
.chart-toolbar {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Accessibility ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/* Respect reduced motion preferences */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.echart-wrapper *,
|
|
||||||
.echarts-container * {
|
|
||||||
animation-duration: 0.01ms !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chart Components — Barrel Export
|
|
||||||
*
|
|
||||||
* Re-exports all chart-related Svelte components from a single entry point.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { default as EChart } from './EChart.svelte';
|
|
||||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
|
||||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||||
|
|
||||||
import { type GeoLocation } from '$lib/stores/settings';
|
import { type GeoLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import * as Alert from '$lib/components/ui/alert';
|
import * as Alert from '$lib/components/ui/alert';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { Input } from '$lib/components/ui/input';
|
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...';
|
export let placeholder: string = 'Enter city name...';
|
||||||
|
|
||||||
interface ResultSet {
|
interface ResultSet {
|
||||||
@@ -18,32 +18,26 @@
|
|||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
|
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let popoverOpen = false;
|
|
||||||
let searchInputEl: HTMLInputElement | null = null;
|
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearTimeout(debounceTimeout);
|
clearInterval(debounceTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
const closePopover = () => {
|
let scrollY: number | undefined;
|
||||||
popoverOpen = false;
|
|
||||||
|
const closeModal = () => {
|
||||||
|
dialogOpen = false;
|
||||||
|
if (scrollY) {
|
||||||
|
window.scrollTo({ top: scrollY, behavior: 'instant' });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectLocation = (location: GeoLocation) => {
|
const selectLocation = (location: GeoLocation) => {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
closePopover();
|
closeModal();
|
||||||
dispatch('location', location);
|
dispatch('location', location);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function focusInput() {
|
|
||||||
await tick();
|
|
||||||
searchInputEl?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (popoverOpen) {
|
|
||||||
focusInput();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: results = (async () => {
|
$: results = (async () => {
|
||||||
if (debounceTimeout) {
|
if (debounceTimeout) {
|
||||||
clearTimeout(debounceTimeout);
|
clearTimeout(debounceTimeout);
|
||||||
@@ -97,59 +91,70 @@
|
|||||||
|
|
||||||
return (await result.json()) as ResultSet;
|
return (await result.json()) as ResultSet;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
let dialogOpen = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open={popoverOpen}>
|
<Dialog.Root bind:open={dialogOpen}>
|
||||||
<Popover.Trigger
|
<Dialog.Trigger
|
||||||
class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary"
|
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
|
<svg
|
||||||
class="h-3.5 w-3.5 shrink-0 opacity-50"
|
class="mr-3 h-5 w-5 text-gray-400 transition-colors group-hover:text-blue-500"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
<path d="m21 21-4.3-4.3" />
|
<path d="m21 21-4.3-4.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
|
<span
|
||||||
</Popover.Trigger>
|
class="text-gray-600 transition-colors group-hover:text-gray-900 dark:text-gray-300 dark:group-hover:text-white"
|
||||||
|
|
||||||
<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">
|
{label}
|
||||||
<div class="p-3">
|
</span>
|
||||||
<div class="flex gap-2">
|
</Dialog.Trigger>
|
||||||
|
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="bg-black/20 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
<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">
|
<div class="flex-1">
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
{placeholder}
|
{placeholder}
|
||||||
class="h-9"
|
class="h-12 text-lg"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
aria-label="Search Location"
|
aria-label="Search Location"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
bind:ref={searchInputEl}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="lg"
|
||||||
class="h-9 px-2.5"
|
class="px-4"
|
||||||
title="Use GPS Location"
|
title="Use GPS Location"
|
||||||
onclick={() => (searchQuery = 'GPS')}
|
onclick={() => (searchQuery = 'GPS')}
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -167,43 +172,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[min(400px,50vh)] overflow-y-auto px-3 pb-3">
|
<div class="flex-1 overflow-y-auto px-6 pb-6">
|
||||||
{#await results}
|
{#await results}
|
||||||
<div class="flex h-20 items-center justify-center">
|
<div class="flex h-32 items-center justify-center">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
|
<div class="h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
<span class="text-sm text-muted-foreground">Searching...</span>
|
<span class="text-gray-600 dark:text-gray-300">Searching...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:then results}
|
{:then results}
|
||||||
{#if results.results && results.results.length === 0}
|
{#if results.results && results.results.length === 0}
|
||||||
{#if searchQuery.length < 2}
|
{#if searchQuery.length < 2}
|
||||||
<div
|
<Alert.Root class="border-blue-200 bg-blue-50 dark:bg-blue-900/20">
|
||||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="mt-0.5 h-3.5 w-3.5 shrink-0"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="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"
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-xs">
|
<Alert.Description class="text-blue-700 dark:text-blue-300">
|
||||||
Start typing to search or use GPS to detect your position
|
Start typing to search for locations or use GPS to detect your current position
|
||||||
</span>
|
</Alert.Description>
|
||||||
</div>
|
</Alert.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<Alert.Root
|
<Alert.Root class="border-orange-200 bg-orange-50 dark:bg-orange-900/20">
|
||||||
class="border-orange-200 bg-orange-50 dark:border-orange-800 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">
|
<Alert.Description class="text-orange-700 dark:text-orange-300">
|
||||||
No locations found for "{searchQuery}". Try a different term.
|
No locations found for "{searchQuery}". Try a different search term.
|
||||||
</Alert.Description>
|
</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -212,33 +216,39 @@
|
|||||||
<Alert.Description>No locations found</Alert.Description>
|
<Alert.Description>No locations found</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-2">
|
||||||
{#each results.results || [] as location, i (i)}
|
{#each results.results || [] as location, i (i)}
|
||||||
<button
|
<button
|
||||||
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
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)}
|
onclick={() => selectLocation(location)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex flex-1 items-center space-x-4">
|
||||||
<img
|
<img
|
||||||
class="h-7 w-7 rounded-full"
|
class="h-10 w-10 rounded-full shadow-md"
|
||||||
src="/images/country-flags/{(
|
src="/images/country-flags/{(
|
||||||
location.country_code || 'united_nations'
|
location.country_code || 'united_nations'
|
||||||
).toLowerCase()}.svg"
|
).toLowerCase()}.svg"
|
||||||
alt={location.country}
|
alt={location.country}
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 text-left">
|
<div class="flex-1">
|
||||||
<div class="text-sm font-medium text-foreground">
|
<h3
|
||||||
|
class="font-semibold text-gray-900 group-hover:text-blue-600 dark:text-white dark:group-hover:text-blue-400"
|
||||||
|
>
|
||||||
{location.name}
|
{location.name}
|
||||||
</div>
|
</h3>
|
||||||
<div class="text-xs text-muted-foreground">
|
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
{location.admin1 || ''}
|
{location.admin1 || ''}
|
||||||
{location.country || ''}
|
{location.country || ''}
|
||||||
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
</p>
|
||||||
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<svg
|
||||||
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
class="h-5 w-5 text-gray-400 group-hover:text-blue-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -262,5 +272,6 @@
|
|||||||
{/await}
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Content>
|
</Dialog.Content>
|
||||||
</Popover.Root>
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -1,93 +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, storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onMenuToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onMenuToggle }: Props = $props();
|
|
||||||
|
|
||||||
let location = $state(get(storedLocation));
|
|
||||||
|
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
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 sm:flex">
|
|
||||||
<img
|
|
||||||
class="h-5 w-5 rounded-full"
|
|
||||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
|
||||||
alt={location.country}
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium text-foreground">
|
|
||||||
{location.name}
|
|
||||||
</span>
|
|
||||||
{#if location.admin1 || location.country}
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{#if location.admin1}{location.admin1},{/if}
|
|
||||||
{location.country}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Spacer -->
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- Location search -->
|
|
||||||
<div class="w-full max-w-sm md:max-w-md">
|
|
||||||
<LocationSearch
|
|
||||||
label="Search location..."
|
|
||||||
on:location={(event) => {
|
|
||||||
navigateToLocation(event.detail);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.topbar {
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,44 +2,30 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
interface Props {
|
import { Button } from '$lib/components/ui/button';
|
||||||
collapsed?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
onMobileClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { collapsed = false, onToggle, onMobileClose }: Props = $props();
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
title: '7-Day Forecast',
|
title: 'Current Weather',
|
||||||
url: '/weather/week' as const,
|
url: '/weather/week',
|
||||||
iconPaths: [
|
description: 'Current conditions and overview',
|
||||||
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'
|
icon: '🌡️'
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Model Comparison',
|
title: 'Model Comparison',
|
||||||
url: '/weather/compare' as const,
|
url: '/weather/compare',
|
||||||
iconPaths: ['M13 7h8m0 0v8m0-8l-8 8-4-4-6 6']
|
description: 'Compare multiple weather models',
|
||||||
|
icon: '📊'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '14-Day Forecast',
|
title: '14 Day Forecast',
|
||||||
url: '/weather/14-day' as const,
|
url: '/weather/14-day',
|
||||||
iconPaths: [
|
description: 'Extended forecast with uncertainty',
|
||||||
'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'
|
icon: '📅'
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let mobileNavOpened = $state(false);
|
||||||
let currentPath = $derived($page.url.pathname);
|
let currentPath = $derived($page.url.pathname);
|
||||||
|
|
||||||
const isActive = (url: string) => {
|
const isActive = (url: string) => {
|
||||||
@@ -47,132 +33,111 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<nav
|
||||||
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
|
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"
|
||||||
class:w-55={!collapsed}
|
|
||||||
class:w-14={collapsed}
|
|
||||||
>
|
|
||||||
<!-- Sidebar header -->
|
|
||||||
<div class="flex items-center border-b border-sidebar-border px-3 py-4">
|
|
||||||
{#if !collapsed}
|
|
||||||
<a
|
|
||||||
href={resolve('/weather/week')}
|
|
||||||
class="flex items-center gap-2.5 px-1"
|
|
||||||
onclick={onMobileClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
>
|
||||||
|
<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
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="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"
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-semibold whitespace-nowrap text-sidebar-foreground">
|
|
||||||
Open-Meteo
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
<a
|
||||||
href={resolve('/weather/week')}
|
href={resolve('/')}
|
||||||
class="mx-auto flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"
|
class="text-xl font-bold text-gray-900 transition-colors hover:text-blue-600 dark:text-white"
|
||||||
onclick={onMobileClose}
|
|
||||||
aria-label="Open-Meteo Home"
|
|
||||||
>
|
>
|
||||||
<svg
|
Open-Meteo Weather
|
||||||
class="h-4.5 w-4.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation links -->
|
<!-- Desktop Navigation -->
|
||||||
<nav class="flex-1 space-y-1 px-2 py-3">
|
<div class="hidden items-center space-x-2 md:flex">
|
||||||
{#each links as link (link.title)}
|
{#each links as link (link.title)}
|
||||||
{@const active = isActive(link.url)}
|
<Button
|
||||||
<a
|
href={link.url}
|
||||||
href={resolve(link.url)}
|
variant={isActive(link.url) ? 'default' : 'ghost'}
|
||||||
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
|
size="sm"
|
||||||
? 'bg-sidebar-accent text-sidebar-primary! opacity-100! font-semibold! nav-active'
|
class="group relative px-4 py-2 {isActive(link.url)
|
||||||
: ''}"
|
? 'bg-blue-600 text-white shadow-md'
|
||||||
title={collapsed ? link.title : undefined}
|
: 'hover:bg-blue-50 dark:hover:bg-blue-900/20'}"
|
||||||
onclick={onMobileClose}
|
|
||||||
>
|
>
|
||||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
<span class="mr-1">{link.icon}</span>
|
||||||
<svg
|
{link.title}
|
||||||
class="h-4.5 w-4.5"
|
</Button>
|
||||||
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}
|
{/each}
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
{#if !collapsed}
|
|
||||||
<span class="ml-2.5 whitespace-nowrap">{link.title}</span>
|
<!-- 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}
|
{/if}
|
||||||
</a>
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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}
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Collapse toggle -->
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{#if !collapsed}
|
|
||||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.nav-active::before {
|
:global(.container) {
|
||||||
content: '';
|
max-width: 1200px;
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 3px;
|
|
||||||
height: 60%;
|
|
||||||
border-radius: 0 3px 3px 0;
|
|
||||||
background: var(--sidebar-primary);
|
|
||||||
}
|
}
|
||||||
</style>
|
</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,27 +0,0 @@
|
|||||||
export {
|
|
||||||
fetchWeekForecast,
|
|
||||||
fetchModelComparison,
|
|
||||||
fetchEnsembleForecast,
|
|
||||||
range,
|
|
||||||
getTimestamps,
|
|
||||||
getDates,
|
|
||||||
getValues,
|
|
||||||
getInt64Values,
|
|
||||||
unitToDisplayString
|
|
||||||
} from './weather';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
WeatherLocation,
|
|
||||||
WeatherUnitParams,
|
|
||||||
MarkArea,
|
|
||||||
WeekForecastParams,
|
|
||||||
WeekHourlyData,
|
|
||||||
WeekDailyData,
|
|
||||||
WeekForecastResult,
|
|
||||||
ModelCompareParams,
|
|
||||||
ModelSeriesData,
|
|
||||||
ModelCompareResult,
|
|
||||||
EnsembleForecastParams,
|
|
||||||
EnsembleVariableData,
|
|
||||||
EnsembleForecastResult
|
|
||||||
} from './weather';
|
|
||||||
@@ -1,760 +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 { buildDaylightMarkAreas } from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
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 MarkArea = [{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }];
|
|
||||||
|
|
||||||
// ─── Week Forecast Types ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface WeekForecastParams extends WeatherLocation, WeatherUnitParams {
|
|
||||||
model?: string;
|
|
||||||
forecast_days?: number;
|
|
||||||
past_days?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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[];
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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;
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
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;
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
/** Flat record compatible with existing chart utilities (keys like "temperature_2m_member00") */
|
|
||||||
hourlyFlat: Record<string, number[]>;
|
|
||||||
hourlyUnitsFlat: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Week Forecast Fetch ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const apiParams: Record<string, string | number | undefined> = {
|
|
||||||
latitude: params.latitude,
|
|
||||||
longitude: params.longitude,
|
|
||||||
hourly: WEEK_HOURLY_VARS.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));
|
|
||||||
|
|
||||||
const hourly: WeekHourlyData = {
|
|
||||||
temperature_2m: getValues(hourlyBlock.variables(0)!),
|
|
||||||
precipitation: getValues(hourlyBlock.variables(1)!),
|
|
||||||
precipitation_probability: getValues(hourlyBlock.variables(2)!),
|
|
||||||
weather_code: getValues(hourlyBlock.variables(3)!),
|
|
||||||
windspeed_10m: getValues(hourlyBlock.variables(4)!),
|
|
||||||
winddirection_10m: getValues(hourlyBlock.variables(5)!),
|
|
||||||
cloud_cover: getValues(hourlyBlock.variables(6)!),
|
|
||||||
relative_humidity_2m: getValues(hourlyBlock.variables(7)!),
|
|
||||||
apparent_temperature: getValues(hourlyBlock.variables(8)!),
|
|
||||||
dew_point_2m: getValues(hourlyBlock.variables(9)!)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Daily: variables are in the same order as WEEK_DAILY_VARS
|
|
||||||
const dailyDates = getDates(dailyBlock);
|
|
||||||
|
|
||||||
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 markAreas = buildDaylightMarkAreas(daily.sunrise, daily.sunset);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hourly,
|
|
||||||
daily,
|
|
||||||
utcOffsetSeconds,
|
|
||||||
timezone,
|
|
||||||
hourlyTimestamps,
|
|
||||||
hourlyDates,
|
|
||||||
dailyDates,
|
|
||||||
markAreas
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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 markAreas: MarkArea[] = [];
|
|
||||||
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);
|
|
||||||
markAreas = buildDaylightMarkAreas(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,
|
|
||||||
markAreas,
|
|
||||||
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 mark areas
|
|
||||||
let markAreas: MarkArea[] = [];
|
|
||||||
if (dailyResponses.length > 0) {
|
|
||||||
const dailyResponse = dailyResponses[0];
|
|
||||||
const dailyBlock = dailyResponse.daily();
|
|
||||||
if (dailyBlock) {
|
|
||||||
const sunrise = getInt64Values(dailyBlock.variables(0)!);
|
|
||||||
const sunset = getInt64Values(dailyBlock.variables(1)!);
|
|
||||||
markAreas = buildDaylightMarkAreas(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,
|
|
||||||
markAreas,
|
|
||||||
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
-18
@@ -1,24 +1,24 @@
|
|||||||
import { persisted } from 'svelte-persisted-store';
|
import { persisted } from 'svelte-persisted-store';
|
||||||
|
|
||||||
export interface GeoLocation {
|
export interface GeoLocation {
|
||||||
id: number;
|
id?: number;
|
||||||
name: string;
|
name?: string;
|
||||||
latitude: number;
|
latitude?: number;
|
||||||
longitude: number;
|
longitude?: number;
|
||||||
elevation: number;
|
elevation?: number;
|
||||||
feature_code: string;
|
feature_code?: string;
|
||||||
country_code: string | undefined;
|
country_code?: string;
|
||||||
admin1_id: number | undefined;
|
admin1_id?: number;
|
||||||
admin3_id?: number | undefined;
|
admin3_id?: number;
|
||||||
admin4_id?: number | undefined;
|
admin4_id?: number;
|
||||||
timezone: string;
|
timezone?: string;
|
||||||
population: number | undefined;
|
population?: number;
|
||||||
postcodes: string[] | undefined;
|
postcodes?: string[];
|
||||||
country_id: number | undefined;
|
country_id?: number;
|
||||||
country: string | undefined;
|
country?: string;
|
||||||
admin1: string | undefined;
|
admin1?: string;
|
||||||
admin3?: string | undefined;
|
admin3?: string;
|
||||||
admin4?: string | undefined;
|
admin4?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLocation: GeoLocation = {
|
export const defaultLocation: GeoLocation = {
|
||||||
|
|||||||
@@ -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 { formatInTimeZone, toZonedTime } from 'date-fns-tz';
|
|
||||||
import { isSameDay as isSameDayDateFns } from 'date-fns';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a UTC Date into a string for a specific timezone using date-fns patterns.
|
|
||||||
* 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,321 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Download Utilities
|
|
||||||
*
|
|
||||||
* Provides programmatic chart export functionality for downloading
|
|
||||||
* charts as PNG or SVG images. Supports stitching multiple chart
|
|
||||||
* instances into a single combined meteogram image.
|
|
||||||
*/
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type ExportFormat = 'png' | 'svg';
|
|
||||||
|
|
||||||
export interface DownloadOptions {
|
|
||||||
/** The file name (without extension) */
|
|
||||||
fileName?: string;
|
|
||||||
/** Export format: 'png' or 'svg' */
|
|
||||||
format?: ExportFormat;
|
|
||||||
/** Pixel ratio for PNG exports (default: 2 for retina quality) */
|
|
||||||
pixelRatio?: number;
|
|
||||||
/** Background color (default: '#ffffff' for PNG, 'none' for SVG) */
|
|
||||||
backgroundColor?: string;
|
|
||||||
/** Components to exclude from the export (e.g. ['toolbox']) */
|
|
||||||
excludeComponents?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_FILE_NAME = 'open-meteo-chart';
|
|
||||||
const DEFAULT_PIXEL_RATIO = 2;
|
|
||||||
|
|
||||||
// ─── Download Functions ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads a single ECharts instance as an image file.
|
|
||||||
*
|
|
||||||
* @param chart - The ECharts instance to export
|
|
||||||
* @param options - Download configuration options
|
|
||||||
*/
|
|
||||||
export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void {
|
|
||||||
const {
|
|
||||||
fileName = DEFAULT_FILE_NAME,
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
const dataUrl = chart.getDataURL({
|
|
||||||
type: format === 'svg' ? 'svg' : 'png',
|
|
||||||
pixelRatio: format === 'png' ? pixelRatio : 1,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
|
|
||||||
triggerDownload(dataUrl, `${fileName}.${format}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downloads multiple ECharts instances stitched into a single combined
|
|
||||||
* meteogram image. Charts are stacked vertically in the order provided.
|
|
||||||
*
|
|
||||||
* For a single chart, delegates to `downloadChart`.
|
|
||||||
*
|
|
||||||
* @param charts - Array of ECharts instances to combine
|
|
||||||
* @param options - Download configuration options
|
|
||||||
*/
|
|
||||||
export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void {
|
|
||||||
const validCharts = charts.filter((c) => c && !c.isDisposed());
|
|
||||||
if (validCharts.length === 0) return;
|
|
||||||
|
|
||||||
if (validCharts.length === 1) {
|
|
||||||
downloadChart(validCharts[0], options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
fileName = DEFAULT_FILE_NAME,
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
|
||||||
downloadMeteogramSvg(validCharts, {
|
|
||||||
fileName,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadMeteogramPng(validCharts, {
|
|
||||||
fileName,
|
|
||||||
pixelRatio,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the data URL of a chart without triggering a download.
|
|
||||||
* Useful for previewing or embedding chart images programmatically.
|
|
||||||
*
|
|
||||||
* @param chart - The ECharts instance to export
|
|
||||||
* @param options - Export configuration options
|
|
||||||
* @returns A base64-encoded data URL string
|
|
||||||
*/
|
|
||||||
export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string {
|
|
||||||
const {
|
|
||||||
format = 'png',
|
|
||||||
pixelRatio = DEFAULT_PIXEL_RATIO,
|
|
||||||
backgroundColor,
|
|
||||||
excludeComponents = ['toolbox']
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
|
||||||
|
|
||||||
return chart.getDataURL({
|
|
||||||
type: format === 'svg' ? 'svg' : 'png',
|
|
||||||
pixelRatio: format === 'png' ? pixelRatio : 1,
|
|
||||||
backgroundColor: resolvedBg,
|
|
||||||
excludeComponents
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal: PNG Meteogram ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface PngStitchOptions {
|
|
||||||
fileName: string;
|
|
||||||
pixelRatio: number;
|
|
||||||
backgroundColor: string;
|
|
||||||
excludeComponents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stitches multiple charts into a single PNG by rendering each chart's
|
|
||||||
* data URL onto an off-screen canvas, stacked vertically.
|
|
||||||
*/
|
|
||||||
function downloadMeteogramPng(charts: echarts.ECharts[], opts: PngStitchOptions): void {
|
|
||||||
const { fileName, pixelRatio, backgroundColor, excludeComponents } = opts;
|
|
||||||
|
|
||||||
const dataUrls = charts.map((chart) =>
|
|
||||||
chart.getDataURL({
|
|
||||||
type: 'png',
|
|
||||||
pixelRatio,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const images: HTMLImageElement[] = [];
|
|
||||||
let loadedCount = 0;
|
|
||||||
|
|
||||||
dataUrls.forEach((url, index) => {
|
|
||||||
const img = new Image();
|
|
||||||
images[index] = img;
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
loadedCount++;
|
|
||||||
if (loadedCount === dataUrls.length) {
|
|
||||||
composePngAndDownload(images, fileName, backgroundColor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = () => {
|
|
||||||
loadedCount++;
|
|
||||||
if (loadedCount === dataUrls.length) {
|
|
||||||
composePngAndDownload(images, fileName, backgroundColor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
img.src = url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function composePngAndDownload(
|
|
||||||
images: HTMLImageElement[],
|
|
||||||
fileName: string,
|
|
||||||
backgroundColor: string
|
|
||||||
): void {
|
|
||||||
const validImages = images.filter((img) => img.naturalWidth > 0);
|
|
||||||
if (validImages.length === 0) return;
|
|
||||||
|
|
||||||
const maxWidth = Math.max(...validImages.map((img) => img.naturalWidth));
|
|
||||||
const totalHeight = validImages.reduce((sum, img) => sum + img.naturalHeight, 0);
|
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = maxWidth;
|
|
||||||
canvas.height = totalHeight;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'none') {
|
|
||||||
ctx.fillStyle = backgroundColor;
|
|
||||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
let y = 0;
|
|
||||||
for (const img of validImages) {
|
|
||||||
ctx.drawImage(img, 0, y);
|
|
||||||
y += img.naturalHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataUrl = canvas.toDataURL('image/png');
|
|
||||||
triggerDownload(dataUrl, `${fileName}.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal: SVG Meteogram ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface SvgStitchOptions {
|
|
||||||
fileName: string;
|
|
||||||
backgroundColor: string;
|
|
||||||
excludeComponents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stitches multiple charts into a single SVG by extracting each chart's
|
|
||||||
* SVG markup and embedding them as nested groups with vertical offsets.
|
|
||||||
*/
|
|
||||||
function downloadMeteogramSvg(charts: echarts.ECharts[], opts: SvgStitchOptions): void {
|
|
||||||
const { fileName, backgroundColor, excludeComponents } = opts;
|
|
||||||
|
|
||||||
const svgStrings = charts.map((chart) =>
|
|
||||||
chart.getDataURL({
|
|
||||||
type: 'svg',
|
|
||||||
pixelRatio: 1,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const parser = new DOMParser();
|
|
||||||
const fragments: { svg: SVGSVGElement; width: number; height: number }[] = [];
|
|
||||||
|
|
||||||
for (const svgDataUrl of svgStrings) {
|
|
||||||
const svgContent = decodeSvgDataUrl(svgDataUrl);
|
|
||||||
if (!svgContent) continue;
|
|
||||||
|
|
||||||
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
|
|
||||||
const svg = doc.querySelector('svg');
|
|
||||||
if (!svg) continue;
|
|
||||||
|
|
||||||
const width = parseFloat(svg.getAttribute('width') || '0');
|
|
||||||
const height = parseFloat(svg.getAttribute('height') || '0');
|
|
||||||
|
|
||||||
if (width > 0 && height > 0) {
|
|
||||||
fragments.push({ svg, width, height });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fragments.length === 0) return;
|
|
||||||
|
|
||||||
const maxWidth = Math.max(...fragments.map((f) => f.width));
|
|
||||||
const totalHeight = fragments.reduce((sum, f) => sum + f.height, 0);
|
|
||||||
|
|
||||||
let combinedSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${maxWidth}" height="${totalHeight}" viewBox="0 0 ${maxWidth} ${totalHeight}">`;
|
|
||||||
|
|
||||||
if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') {
|
|
||||||
combinedSvg += `<rect width="${maxWidth}" height="${totalHeight}" fill="${backgroundColor}"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let yOffset = 0;
|
|
||||||
for (const fragment of fragments) {
|
|
||||||
combinedSvg += `<g transform="translate(0,${yOffset})">`;
|
|
||||||
combinedSvg += fragment.svg.innerHTML;
|
|
||||||
combinedSvg += `</g>`;
|
|
||||||
yOffset += fragment.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
combinedSvg += `</svg>`;
|
|
||||||
|
|
||||||
const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
triggerDownload(url, `${fileName}.svg`);
|
|
||||||
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeSvgDataUrl(dataUrl: string): string | null {
|
|
||||||
try {
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) {
|
|
||||||
return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length));
|
|
||||||
}
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml;base64,')) {
|
|
||||||
return atob(dataUrl.slice('data:image/svg+xml;base64,'.length));
|
|
||||||
}
|
|
||||||
if (dataUrl.startsWith('data:image/svg+xml,')) {
|
|
||||||
return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers a browser file download from a data URL or object URL.
|
|
||||||
* Creates a temporary anchor element, clicks it, and removes it.
|
|
||||||
*/
|
|
||||||
function triggerDownload(url: string, fileName: string): void {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = fileName;
|
|
||||||
link.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.body.removeChild(link);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Utilities — Barrel Export
|
|
||||||
*
|
|
||||||
* Re-exports all ECharts-related utilities from a single entry point.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts';
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Theme: dark/light detection, color palettes, theme color accessors
|
|
||||||
export {
|
|
||||||
SERIES_COLORS,
|
|
||||||
CHART_COLORS,
|
|
||||||
isDarkMode,
|
|
||||||
getThemeColors,
|
|
||||||
getTextColor,
|
|
||||||
getAxisLineColor,
|
|
||||||
getSplitLineColor
|
|
||||||
} from './theme';
|
|
||||||
export type { ThemeColors } from './theme';
|
|
||||||
|
|
||||||
// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer
|
|
||||||
export {
|
|
||||||
buildGrid,
|
|
||||||
buildTitle,
|
|
||||||
buildTooltip,
|
|
||||||
buildLegend,
|
|
||||||
buildTimeXAxis,
|
|
||||||
buildValueYAxis,
|
|
||||||
buildCreditGraphic,
|
|
||||||
buildToolbox,
|
|
||||||
composeChartOption,
|
|
||||||
isColumnUnit
|
|
||||||
} from './options';
|
|
||||||
export type {
|
|
||||||
GridOptions,
|
|
||||||
TitleOptions,
|
|
||||||
LegendOptions,
|
|
||||||
TooltipOptions,
|
|
||||||
AxisOptions,
|
|
||||||
CreditOptions,
|
|
||||||
BuildGridParams,
|
|
||||||
ToolboxOptions,
|
|
||||||
ChartOptionParams
|
|
||||||
} from './options';
|
|
||||||
|
|
||||||
// Series builders: model lines, averages, time markers, daylight bands, ensemble spread
|
|
||||||
export {
|
|
||||||
buildModelSeries,
|
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightMarkAreas,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildSpreadSeries,
|
|
||||||
calculateAverage,
|
|
||||||
calculateSpread,
|
|
||||||
findUnit
|
|
||||||
} from './series';
|
|
||||||
export type {
|
|
||||||
ModelSeriesParams,
|
|
||||||
AverageSeriesParams,
|
|
||||||
CurrentTimeSeriesParams,
|
|
||||||
DaylightSeriesParams,
|
|
||||||
SpreadSeriesParams,
|
|
||||||
AverageResult,
|
|
||||||
SpreadResult
|
|
||||||
} from './series';
|
|
||||||
|
|
||||||
// Download: export charts as PNG or SVG
|
|
||||||
export { downloadChart, downloadMeteogram, getChartDataUrl } from './download';
|
|
||||||
export type { ExportFormat, DownloadOptions } from './download';
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Option Builders
|
|
||||||
*
|
|
||||||
* Shared factory functions for constructing common ECharts option fragments.
|
|
||||||
* These builders ensure visual consistency across all chart pages and reduce
|
|
||||||
* boilerplate in page-level components.
|
|
||||||
*/
|
|
||||||
import { formatZoned } from '../date';
|
|
||||||
import { getThemeColors } from './theme';
|
|
||||||
|
|
||||||
import type { ThemeColors } from './theme';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface GridOptions {
|
|
||||||
left?: number;
|
|
||||||
right?: number;
|
|
||||||
top?: number;
|
|
||||||
bottom?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TitleOptions {
|
|
||||||
text: string;
|
|
||||||
subtext?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LegendOptions {
|
|
||||||
show: boolean;
|
|
||||||
data?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TooltipOptions {
|
|
||||||
unit: string;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AxisOptions {
|
|
||||||
unit?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreditOptions {
|
|
||||||
show: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Default Constants ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEFAULT_GRID: GridOptions = {
|
|
||||||
left: 60,
|
|
||||||
right: 16,
|
|
||||||
top: 40,
|
|
||||||
bottom: 40
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRID_WITH_TITLE: Partial<GridOptions> = {
|
|
||||||
top: 80
|
|
||||||
};
|
|
||||||
|
|
||||||
const GRID_WITH_LEGEND: Partial<GridOptions> = {
|
|
||||||
bottom: 60
|
|
||||||
};
|
|
||||||
|
|
||||||
// ─── Grid ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface BuildGridParams {
|
|
||||||
hasTitle?: boolean;
|
|
||||||
hasSubtitle?: boolean;
|
|
||||||
showLegend?: boolean;
|
|
||||||
overrides?: Partial<GridOptions>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a grid configuration with sensible defaults.
|
|
||||||
* Automatically adjusts top/bottom spacing for title and legend presence.
|
|
||||||
*/
|
|
||||||
export function buildGrid(params: BuildGridParams = {}): GridOptions {
|
|
||||||
const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...DEFAULT_GRID,
|
|
||||||
...(hasTitle ? GRID_WITH_TITLE : {}),
|
|
||||||
...(hasSubtitle ? { top: 90 } : {}),
|
|
||||||
...(showLegend ? GRID_WITH_LEGEND : {}),
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Title ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a title configuration. Pass `null` to hide the title.
|
|
||||||
*/
|
|
||||||
export function buildTitle(
|
|
||||||
options: TitleOptions | null,
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
if (!options) {
|
|
||||||
return { title: { show: false } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
text: options.text,
|
|
||||||
left: 'left',
|
|
||||||
textStyle: {
|
|
||||||
fontWeight: 'normal',
|
|
||||||
fontSize: 16,
|
|
||||||
color: c.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (options.subtext) {
|
|
||||||
result.subtext = options.subtext;
|
|
||||||
result.subtextStyle = {
|
|
||||||
fontWeight: 'normal',
|
|
||||||
fontSize: 12,
|
|
||||||
color: c.textMuted
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Tooltip ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a tooltip with cross-axis pointer and unit-aware value formatting.
|
|
||||||
*/
|
|
||||||
export function buildTooltip(
|
|
||||||
options: TooltipOptions,
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
const { unit, timezone } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
color: c.text,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: c.tooltipBg,
|
|
||||||
borderColor: c.tooltipBorder,
|
|
||||||
textStyle: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
formatter: timezone
|
|
||||||
? (
|
|
||||||
params: Array<{
|
|
||||||
axisValue: number;
|
|
||||||
seriesName: string;
|
|
||||||
marker: string;
|
|
||||||
value: number | number[] | null;
|
|
||||||
}>
|
|
||||||
) => {
|
|
||||||
if (!params || params.length === 0) return '';
|
|
||||||
const date = new Date(params[0].axisValue);
|
|
||||||
let html = `<b>${formatZoned(date, timezone, 'EEE d MMM HH:mm')}</b><br/>`;
|
|
||||||
params.forEach((item) => {
|
|
||||||
if (item.seriesName === 'Daylight' || item.seriesName === 'Current Time') return;
|
|
||||||
const val = Array.isArray(item.value) ? item.value[1] : item.value;
|
|
||||||
if (val === null || val === undefined) return;
|
|
||||||
html += `${item.marker} ${item.seriesName}: <b>${val.toFixed(1)} ${unit}</b><br/>`;
|
|
||||||
});
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
valueFormatter: (value: number) => {
|
|
||||||
if (value === null || value === undefined) return '-';
|
|
||||||
return value.toFixed(1) + ' ' + unit;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Legend ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a scrollable legend configuration pinned to the bottom.
|
|
||||||
*/
|
|
||||||
export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: options.show,
|
|
||||||
bottom: 0,
|
|
||||||
type: 'scroll',
|
|
||||||
...(options.data ? { data: options.data } : {}),
|
|
||||||
textStyle: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
pageTextStyle: {
|
|
||||||
color: c.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a time-based X axis with theme-aware styling and timezone-aware labels.
|
|
||||||
*/
|
|
||||||
export function buildTimeXAxis(timezone?: string, colors?: ThemeColors): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'time',
|
|
||||||
splitLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.axisLine
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisPointer: {
|
|
||||||
label: {
|
|
||||||
formatter: timezone
|
|
||||||
? (params: { value: number }) =>
|
|
||||||
formatZoned(new Date(params.value), timezone, 'EEE d MMM HH:mm')
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: c.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
formatter: timezone
|
|
||||||
? (value: number) => formatZoned(new Date(value), timezone, 'HH:mm')
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
axisTick: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.axisLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Y Axis (Value) ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a value-based Y axis with optional unit label.
|
|
||||||
*/
|
|
||||||
export function buildValueYAxis(
|
|
||||||
options: AxisOptions = {},
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'value',
|
|
||||||
...(options.unit ? { name: options.unit } : {}),
|
|
||||||
nameTextStyle: {
|
|
||||||
color: c.text,
|
|
||||||
padding: [0, 0, 0, 4]
|
|
||||||
},
|
|
||||||
axisLine: {
|
|
||||||
show: false
|
|
||||||
},
|
|
||||||
axisLabel: {
|
|
||||||
color: c.text
|
|
||||||
},
|
|
||||||
splitLine: {
|
|
||||||
lineStyle: {
|
|
||||||
color: c.splitLine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Credit Watermark ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the Open-Meteo.com credit watermark graphic element.
|
|
||||||
*/
|
|
||||||
export function buildCreditGraphic(colors?: ThemeColors): Record<string, unknown>[] {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
right: 10,
|
|
||||||
bottom: 5,
|
|
||||||
style: {
|
|
||||||
text: 'Open-Meteo.com',
|
|
||||||
fontSize: 10,
|
|
||||||
fill: c.text,
|
|
||||||
opacity: 0.4
|
|
||||||
},
|
|
||||||
onclick: function () {
|
|
||||||
window.open('https://open-meteo.com', '_blank');
|
|
||||||
},
|
|
||||||
cursor: 'pointer'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Toolbox (Download) ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ToolboxOptions {
|
|
||||||
/** Show the save-as-image button */
|
|
||||||
saveAsImage?: boolean;
|
|
||||||
/** File name prefix for downloaded images */
|
|
||||||
fileName?: string;
|
|
||||||
/** Export format: 'png' or 'svg' */
|
|
||||||
format?: 'png' | 'svg';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the ECharts toolbox with download functionality.
|
|
||||||
*/
|
|
||||||
export function buildToolbox(
|
|
||||||
options: ToolboxOptions = {},
|
|
||||||
colors?: ThemeColors
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const c = colors ?? getThemeColors();
|
|
||||||
const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
show: true,
|
|
||||||
right: 16,
|
|
||||||
top: 4,
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.textMuted
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.text
|
|
||||||
}
|
|
||||||
},
|
|
||||||
feature: {
|
|
||||||
...(saveAsImage
|
|
||||||
? {
|
|
||||||
saveAsImage: {
|
|
||||||
type: format,
|
|
||||||
name: fileName,
|
|
||||||
title: format === 'svg' ? 'Save as SVG' : 'Save as PNG',
|
|
||||||
pixelRatio: 2,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
excludeComponents: ['toolbox'],
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.textMuted
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
iconStyle: {
|
|
||||||
borderColor: c.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Full Option Composer ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ChartOptionParams {
|
|
||||||
title?: TitleOptions | null;
|
|
||||||
tooltip: TooltipOptions;
|
|
||||||
legend?: LegendOptions;
|
|
||||||
grid?: BuildGridParams;
|
|
||||||
yAxis?: AxisOptions;
|
|
||||||
series: Array<Record<string, unknown>>;
|
|
||||||
toolbox?: ToolboxOptions | false;
|
|
||||||
showCredit?: boolean;
|
|
||||||
colors?: ThemeColors;
|
|
||||||
timezone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composes a complete ECharts option object from individual builder params.
|
|
||||||
* This is the primary entry point for building chart options — it calls all
|
|
||||||
* the individual builders and merges the results into a single config object.
|
|
||||||
*/
|
|
||||||
export function composeChartOption(params: ChartOptionParams): Record<string, unknown> {
|
|
||||||
const colors = params.colors ?? getThemeColors();
|
|
||||||
const hasTitle = params.title != null && params.title.text !== '';
|
|
||||||
const showLegend = params.legend?.show ?? false;
|
|
||||||
|
|
||||||
const option: Record<string, unknown> = {
|
|
||||||
title: buildTitle(params.title ?? null, colors),
|
|
||||||
tooltip: buildTooltip(params.tooltip, colors),
|
|
||||||
legend: buildLegend(params.legend ?? { show: false }, colors),
|
|
||||||
grid: buildGrid({
|
|
||||||
...params.grid,
|
|
||||||
hasTitle,
|
|
||||||
hasSubtitle: hasTitle && !!params.title?.subtext,
|
|
||||||
showLegend
|
|
||||||
}),
|
|
||||||
xAxis: buildTimeXAxis(params.timezone, colors),
|
|
||||||
yAxis: buildValueYAxis(params.yAxis, colors),
|
|
||||||
series: params.series,
|
|
||||||
textStyle: {
|
|
||||||
color: colors.text
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add toolbox unless explicitly disabled
|
|
||||||
if (params.toolbox !== false) {
|
|
||||||
option.toolbox = buildToolbox(params.toolbox ?? {}, colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add credit watermark
|
|
||||||
if (params.showCredit) {
|
|
||||||
option.graphic = buildCreditGraphic(colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
return option;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Utility: Detect column-type variables ───────────────────────────────────
|
|
||||||
|
|
||||||
/** Units that should be rendered as bar/column charts instead of lines. */
|
|
||||||
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the given unit should be rendered as a bar chart.
|
|
||||||
*/
|
|
||||||
export function isColumnUnit(unit: string): boolean {
|
|
||||||
return COLUMN_UNITS.has(unit);
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Series Builders
|
|
||||||
*
|
|
||||||
* Factory functions for constructing common series patterns used across
|
|
||||||
* weather chart visualizations. These builders encapsulate the styling
|
|
||||||
* and configuration details so page-level code only needs to provide data.
|
|
||||||
*/
|
|
||||||
import { isColumnUnit } from './options';
|
|
||||||
import { CHART_COLORS } from './theme';
|
|
||||||
|
|
||||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ModelSeriesParams {
|
|
||||||
/** The series name (typically the model key from the API response) */
|
|
||||||
name: string;
|
|
||||||
/** Array of [timestamp, value] data points */
|
|
||||||
data: Array<[number, number | null]>;
|
|
||||||
/** The unit string, used to determine bar vs line rendering */
|
|
||||||
unit: string;
|
|
||||||
/** Optional line width override (default: 2) */
|
|
||||||
lineWidth?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AverageSeriesParams {
|
|
||||||
/** The variable name, used to construct the series name */
|
|
||||||
variable: string;
|
|
||||||
/** Array of [timestamp, value] data points */
|
|
||||||
data: Array<[number, number]>;
|
|
||||||
/** The unit string, used to determine bar vs line rendering */
|
|
||||||
unit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentTimeSeriesParams {
|
|
||||||
/** UTC offset in seconds from the API response */
|
|
||||||
utcOffsetSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaylightSeriesParams {
|
|
||||||
/** Array of mark area pairs: [[start, end], [start, end], ...] */
|
|
||||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpreadSeriesParams {
|
|
||||||
/** The variable name, used to construct series names */
|
|
||||||
variable: string;
|
|
||||||
/** Array of [timestamp, min, max] data points */
|
|
||||||
spreadData: Array<[number, number, number]>;
|
|
||||||
/** Optional color for the spread area (default: theme spread color) */
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MarkAreaEntry {
|
|
||||||
xAxis: number;
|
|
||||||
itemStyle?: { color: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Model Series ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a single model series (line or bar depending on the unit).
|
|
||||||
* Used on the Model Comparison page where each weather model gets its own series.
|
|
||||||
*/
|
|
||||||
export function buildModelSeries(params: ModelSeriesParams): Record<string, unknown> {
|
|
||||||
const { name, data, unit, lineWidth = 2 } = params;
|
|
||||||
const isColumn = isColumnUnit(unit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
type: isColumn ? 'bar' : 'line',
|
|
||||||
data,
|
|
||||||
smooth: !isColumn,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: {
|
|
||||||
width: lineWidth
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
lineStyle: {
|
|
||||||
width: lineWidth + 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 5
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Average Series ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the ensemble/model average series.
|
|
||||||
* Rendered as a dashed line (or bar) that stands out from individual model lines.
|
|
||||||
*/
|
|
||||||
export function buildAverageSeries(params: AverageSeriesParams): Record<string, unknown> {
|
|
||||||
const { variable, data, unit } = params;
|
|
||||||
const isColumn = isColumnUnit(unit);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: variable + '_average',
|
|
||||||
type: isColumn ? 'bar' : 'line',
|
|
||||||
data,
|
|
||||||
smooth: !isColumn,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: {
|
|
||||||
type: 'dashed',
|
|
||||||
width: 4,
|
|
||||||
color: CHART_COLORS.average
|
|
||||||
},
|
|
||||||
itemStyle: {
|
|
||||||
color: CHART_COLORS.average
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
lineStyle: {
|
|
||||||
width: 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 5,
|
|
||||||
z: 10
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Current Time Marker ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a helper series that renders a vertical red line at the current time.
|
|
||||||
* Uses an empty data series with a markLine to overlay onto the chart.
|
|
||||||
*/
|
|
||||||
export function buildCurrentTimeSeries(): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
name: 'Current Time',
|
|
||||||
type: 'line',
|
|
||||||
data: [],
|
|
||||||
markLine: {
|
|
||||||
silent: true,
|
|
||||||
symbol: 'none',
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
xAxis: Date.now(),
|
|
||||||
lineStyle: {
|
|
||||||
color: CHART_COLORS.currentTimeLine,
|
|
||||||
width: 2,
|
|
||||||
type: 'solid'
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
show: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Daylight Bands ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds mark area entries from sunrise/sunset arrays.
|
|
||||||
* Each entry is a pair of axis markers that ECharts renders as a shaded band.
|
|
||||||
*
|
|
||||||
* @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset)
|
|
||||||
* @param sunset - Array of sunset timestamps (unix seconds, without UTC offset)
|
|
||||||
* @param utcOffsetSeconds - UTC offset to apply (from the API response)
|
|
||||||
*/
|
|
||||||
export function buildDaylightMarkAreas(
|
|
||||||
sunrise: number[],
|
|
||||||
sunset: number[]
|
|
||||||
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
|
||||||
return sunrise.map((r: number, i: number) => [
|
|
||||||
{
|
|
||||||
xAxis: r * 1000,
|
|
||||||
itemStyle: {
|
|
||||||
color: CHART_COLORS.daylight
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
xAxis: sunset[i] * 1000
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a helper series that renders day/night shading bands via markArea.
|
|
||||||
* Returns null if no mark areas are provided (so callers can filter it out).
|
|
||||||
*/
|
|
||||||
export function buildDaylightSeries(params: DaylightSeriesParams): Record<string, unknown> | null {
|
|
||||||
if (params.markAreas.length === 0) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: 'Daylight',
|
|
||||||
type: 'line',
|
|
||||||
data: [],
|
|
||||||
markArea: {
|
|
||||||
silent: true,
|
|
||||||
data: params.markAreas
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Ensemble Spread (Min/Max Area) ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a pair of stacked area series that visualize the ensemble spread
|
|
||||||
* (min-to-max range). The lower bound is rendered invisibly and the upper
|
|
||||||
* bound delta is stacked on top with a translucent fill.
|
|
||||||
*
|
|
||||||
* Returns an array of two series that should be spread into the series list.
|
|
||||||
*/
|
|
||||||
export function buildSpreadSeries(params: SpreadSeriesParams): Array<Record<string, unknown>> {
|
|
||||||
const { variable, spreadData, color = CHART_COLORS.spreadArea } = params;
|
|
||||||
|
|
||||||
const lowerBound: Record<string, unknown> = {
|
|
||||||
name: variable + '_spread_lower',
|
|
||||||
type: 'line',
|
|
||||||
data: spreadData.map((d) => [d[0], d[1]]),
|
|
||||||
areaStyle: {
|
|
||||||
color,
|
|
||||||
origin: 'auto'
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
showSymbol: false,
|
|
||||||
stack: 'spread_' + variable,
|
|
||||||
smooth: true,
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const upperDelta: Record<string, unknown> = {
|
|
||||||
name: variable + '_spread_upper',
|
|
||||||
type: 'line',
|
|
||||||
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
|
|
||||||
areaStyle: {
|
|
||||||
color,
|
|
||||||
origin: 'auto'
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
width: 0
|
|
||||||
},
|
|
||||||
showSymbol: false,
|
|
||||||
stack: 'spread_' + variable,
|
|
||||||
smooth: true,
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
};
|
|
||||||
|
|
||||||
return [lowerBound, upperDelta];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AverageResult {
|
|
||||||
average: number[];
|
|
||||||
averageCount: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpreadResult {
|
|
||||||
minValues: (number | undefined)[];
|
|
||||||
maxValues: (number | undefined)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates per-timestep average and count from hourly model data.
|
|
||||||
* Shared between the Model Compare and 14-Day Forecast pages.
|
|
||||||
*
|
|
||||||
* @param hourlyData - The `data.hourly` object from the API response
|
|
||||||
* @param variable - The variable prefix to filter on (e.g. 'temperature_2m')
|
|
||||||
* @param timeLength - Number of timesteps
|
|
||||||
* @returns Object containing running average and count arrays
|
|
||||||
*/
|
|
||||||
export function calculateAverage(
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): AverageResult {
|
|
||||||
const average = new Array<number>(timeLength).fill(0);
|
|
||||||
const averageCount = new Array<number>(timeLength).fill(0);
|
|
||||||
|
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (!model.startsWith(variable)) continue;
|
|
||||||
|
|
||||||
for (const [index, val] of (values as number[]).entries()) {
|
|
||||||
if (val !== null && val !== undefined && isFinite(val)) {
|
|
||||||
average[index] += val;
|
|
||||||
averageCount[index]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize average values
|
|
||||||
for (let i = 0; i < timeLength; i++) {
|
|
||||||
if (averageCount[i] > 0) {
|
|
||||||
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { average, averageCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculates per-timestep min and max values from hourly ensemble data.
|
|
||||||
* Used by the 14-Day Forecast page to render the ensemble spread.
|
|
||||||
*
|
|
||||||
* @param hourlyData - The `data.hourly` object from the API response
|
|
||||||
* @param variable - The variable prefix to filter on
|
|
||||||
* @param timeLength - Number of timesteps
|
|
||||||
* @returns Object containing min and max value arrays
|
|
||||||
*/
|
|
||||||
export function calculateSpread(
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string,
|
|
||||||
timeLength: number
|
|
||||||
): SpreadResult {
|
|
||||||
const minValues = new Array<number | undefined>(timeLength).fill(undefined);
|
|
||||||
const maxValues = new Array<number | undefined>(timeLength).fill(undefined);
|
|
||||||
|
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (!model.startsWith(variable)) continue;
|
|
||||||
|
|
||||||
for (const [index, val] of (values as number[]).entries()) {
|
|
||||||
if (val !== null && val !== undefined) {
|
|
||||||
if (minValues[index] === undefined || val < minValues[index]!) {
|
|
||||||
minValues[index] = val;
|
|
||||||
}
|
|
||||||
if (maxValues[index] === undefined || val > maxValues[index]!) {
|
|
||||||
maxValues[index] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { minValues, maxValues };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the unit string for a given variable from the hourly_units map.
|
|
||||||
* Returns an empty string if the variable is not found.
|
|
||||||
*/
|
|
||||||
export function findUnit(
|
|
||||||
hourlyUnits: Record<string, string>,
|
|
||||||
hourlyData: Record<string, unknown>,
|
|
||||||
variable: string
|
|
||||||
): string {
|
|
||||||
for (const model of Object.keys(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (model.startsWith(variable) && hourlyUnits[model]) {
|
|
||||||
return hourlyUnits[model];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* ECharts Theme Utilities
|
|
||||||
*
|
|
||||||
* Centralized dark/light mode detection and color helpers for consistent
|
|
||||||
* chart theming across all ECharts visualizations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ─── Color Palette ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Default series color palette matching the application's design system */
|
|
||||||
export const SERIES_COLORS = [
|
|
||||||
'#5470c6',
|
|
||||||
'#91cc75',
|
|
||||||
'#fac858',
|
|
||||||
'#ee6666',
|
|
||||||
'#73c0de',
|
|
||||||
'#3ba272',
|
|
||||||
'#fc8452',
|
|
||||||
'#9a60b4',
|
|
||||||
'#ea7ccc',
|
|
||||||
'#4dc9f6'
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/** Semantic colors used for specific chart elements */
|
|
||||||
export const CHART_COLORS = {
|
|
||||||
average: '#5e5e5e',
|
|
||||||
currentTimeLine: '#ef4444',
|
|
||||||
daylight: 'rgba(255, 255, 194, 0.3)',
|
|
||||||
spreadArea: 'rgba(173, 216, 230, 0.3)',
|
|
||||||
creditText: { light: '#374151', dark: '#e5e7eb' }
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// ─── Dark Mode Detection ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects whether the application is currently in dark mode.
|
|
||||||
* Checks multiple sources: HTML class, data-theme attribute, and media query.
|
|
||||||
*/
|
|
||||||
export function isDarkMode(): boolean {
|
|
||||||
if (typeof document === 'undefined') return false;
|
|
||||||
|
|
||||||
const html = document.documentElement;
|
|
||||||
const dataTheme = html.getAttribute('data-theme');
|
|
||||||
|
|
||||||
// Explicit data-theme takes priority
|
|
||||||
if (dataTheme === 'dark') return true;
|
|
||||||
if (dataTheme === 'light') return false;
|
|
||||||
|
|
||||||
// Check for dark class (e.g. Tailwind dark mode)
|
|
||||||
if (html.classList.contains('dark')) return true;
|
|
||||||
|
|
||||||
// Fall back to system preference
|
|
||||||
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Theme Colors ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ThemeColors {
|
|
||||||
text: string;
|
|
||||||
textMuted: string;
|
|
||||||
axisLine: string;
|
|
||||||
splitLine: string;
|
|
||||||
background: string;
|
|
||||||
tooltipBg: string;
|
|
||||||
tooltipBorder: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LIGHT_COLORS: ThemeColors = {
|
|
||||||
text: '#374151',
|
|
||||||
textMuted: 'rgba(55, 65, 81, 0.6)',
|
|
||||||
axisLine: 'rgba(55, 65, 81, 0.3)',
|
|
||||||
splitLine: 'rgba(55, 65, 81, 0.1)',
|
|
||||||
background: 'transparent',
|
|
||||||
tooltipBg: '#ffffff',
|
|
||||||
tooltipBorder: '#e5e7eb'
|
|
||||||
};
|
|
||||||
|
|
||||||
const DARK_COLORS: ThemeColors = {
|
|
||||||
text: '#e5e7eb',
|
|
||||||
textMuted: 'rgba(229, 231, 235, 0.6)',
|
|
||||||
axisLine: 'rgba(229, 231, 235, 0.3)',
|
|
||||||
splitLine: 'rgba(229, 231, 235, 0.1)',
|
|
||||||
background: 'transparent',
|
|
||||||
tooltipBg: '#1f2937',
|
|
||||||
tooltipBorder: '#374151'
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the full set of theme colors based on current dark/light mode.
|
|
||||||
*/
|
|
||||||
export function getThemeColors(): ThemeColors {
|
|
||||||
return isDarkMode() ? DARK_COLORS : LIGHT_COLORS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shorthand helpers kept for backward compatibility and convenience */
|
|
||||||
export function getTextColor(): string {
|
|
||||||
return getThemeColors().text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAxisLineColor(): string {
|
|
||||||
return getThemeColors().axisLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSplitLineColor(): string {
|
|
||||||
return getThemeColors().splitLine;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
export * from './ui.ts';
|
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) => {
|
export const pad = (n: string | number) => {
|
||||||
if (n === null || n === undefined) {
|
if (n === null || n === undefined) {
|
||||||
@@ -6,3 +11,16 @@ export const pad = (n: string | number) => {
|
|||||||
}
|
}
|
||||||
return ('0' + n).slice(-2);
|
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,108 +0,0 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
export const geoLocationNameToRoute = (name: string) => {
|
|
||||||
const lowerCase = name.toLowerCase().replaceAll(' ', '-');
|
|
||||||
return lowerCase.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildLocationRoute(location: GeoLocation): string {
|
|
||||||
const locationRoute = geoLocationNameToRoute(location.name);
|
|
||||||
if (location.population && location.population > 543000) {
|
|
||||||
return locationRoute;
|
|
||||||
}
|
|
||||||
return locationRoute + '_' + location.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResolveLocationOptions {
|
|
||||||
urlLocation: string;
|
|
||||||
routePrefix: string;
|
|
||||||
event: {
|
|
||||||
fetch: typeof fetch;
|
|
||||||
url: URL;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveLocationFromRoute({
|
|
||||||
urlLocation,
|
|
||||||
routePrefix,
|
|
||||||
event
|
|
||||||
}: ResolveLocationOptions): Promise<GeoLocation> {
|
|
||||||
let location: GeoLocation;
|
|
||||||
|
|
||||||
if (urlLocation.includes('N') && urlLocation.includes('E')) {
|
|
||||||
const parts = urlLocation.split(/N|E/);
|
|
||||||
const latitude = parseFloat(parts[0]);
|
|
||||||
const longitude = parseFloat(parts[1]);
|
|
||||||
|
|
||||||
location = {
|
|
||||||
id: 0,
|
|
||||||
name: `${latitude}N° ${longitude}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
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let urlLocationName: string;
|
|
||||||
let urlLocationId: string | undefined;
|
|
||||||
|
|
||||||
if (urlLocation.includes('_')) {
|
|
||||||
const split = urlLocation.split('_');
|
|
||||||
urlLocationName = split[0];
|
|
||||||
urlLocationId = split[1];
|
|
||||||
} else if (urlLocation.includes('-')) {
|
|
||||||
urlLocationName = urlLocation.replace(/-/g, ' ');
|
|
||||||
} else if (/^\d+$/.test(urlLocation)) {
|
|
||||||
urlLocationName = '';
|
|
||||||
urlLocationId = urlLocation;
|
|
||||||
} else {
|
|
||||||
urlLocationName = urlLocation;
|
|
||||||
urlLocationId = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
const canonicalSuffix =
|
|
||||||
location.population && location.population > 543000
|
|
||||||
? locationRoute
|
|
||||||
: locationRoute + '_' + location.id;
|
|
||||||
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
|
|
||||||
|
|
||||||
if (event.url.pathname !== canonicalPath) {
|
|
||||||
throw redirect(303, canonicalPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
storedLocation.set(location);
|
|
||||||
return location;
|
|
||||||
}
|
|
||||||
@@ -1 +1,7 @@
|
|||||||
|
export function geoLocationNameToRoute(name: string): string {
|
||||||
|
// Placeholder implementation
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-*|-*$/g, '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,27 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
let sidebarCollapsed = $state(false);
|
|
||||||
let mobileMenuOpen = $state(false);
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
sidebarCollapsed = !sidebarCollapsed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMobileMenu = () => {
|
|
||||||
mobileMenuOpen = !mobileMenuOpen;
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeMobileMenu = () => {
|
|
||||||
mobileMenuOpen = false;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -30,33 +12,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden bg-background text-foreground">
|
<main class="min-h-screen">
|
||||||
<!-- 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"
|
|
||||||
onclick={closeMobileMenu}
|
|
||||||
onkeydown={closeMobileMenu}
|
|
||||||
></div>
|
|
||||||
<div class="relative z-1 h-full w-55 shadow-lg">
|
|
||||||
<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="flex-1 overflow-y-auto p-5 md:px-8 md:py-6">
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
+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>
|
<svelte:head>
|
||||||
<title>Open-Meteo Weather</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>
|
</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
-61
@@ -6,75 +6,71 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--background: oklch(0.985 0.002 90);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.205 0.02 60);
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
--card: oklch(1 0 0);
|
--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: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.205 0.02 60);
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
--primary: oklch(0.65 0.17 55);
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
--primary-foreground: oklch(1 0 0);
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--secondary: oklch(0.965 0.01 85);
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
--secondary-foreground: oklch(0.25 0.02 60);
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--muted: oklch(0.96 0.008 85);
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
--muted-foreground: oklch(0.5 0.02 60);
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
--accent: oklch(0.96 0.008 85);
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
--accent-foreground: oklch(1 0 0);
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.91 0.01 80);
|
--border: oklch(0.929 0.013 255.508);
|
||||||
--input: oklch(0.91 0.01 80);
|
--input: oklch(0.929 0.013 255.508);
|
||||||
--ring: oklch(0.65 0.17 55);
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
--chart-1: oklch(0.65 0.17 55);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.62 0.16 250);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.75 0.15 75);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.55 0.12 250);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.8 0.14 65);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar-foreground: oklch(0.3 0.02 60);
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-primary: oklch(0.65 0.17 55);
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
--sidebar-primary-foreground: oklch(1 0 0);
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-accent: oklch(0.95 0.03 70);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent-foreground: oklch(0.3 0.02 60);
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
--sidebar-border: oklch(0.92 0.01 80);
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-ring: oklch(0.65 0.17 55);
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
--sidebar: oklch(0.99 0.003 85);
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
--topbar-bg: oklch(1 0 0);
|
|
||||||
--topbar-border: oklch(0.92 0.01 80);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.17 0.015 60);
|
--background: oklch(0.129 0.042 264.695);
|
||||||
--foreground: oklch(0.96 0.005 85);
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
--card: oklch(0.22 0.015 60);
|
--card: oklch(0.208 0.042 265.755);
|
||||||
--card-foreground: oklch(0.96 0.005 85);
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
--popover: oklch(0.22 0.015 60);
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
--popover-foreground: oklch(0.96 0.005 85);
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
--primary: oklch(0.72 0.17 55);
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
--primary-foreground: oklch(0.15 0.02 60);
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
--secondary: oklch(0.26 0.015 60);
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
--secondary-foreground: oklch(0.96 0.005 85);
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--muted: oklch(0.26 0.015 60);
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
--muted-foreground: oklch(0.65 0.02 60);
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
--accent: oklch(0.65 0.16 250);
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
--accent-foreground: oklch(0.96 0.005 85);
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.72 0.17 55);
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
--chart-1: oklch(0.72 0.17 55);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.65 0.16 250);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.8 0.14 65);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.6 0.2 300);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.7 0.22 20);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar-foreground: oklch(0.96 0.005 85);
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
--sidebar-primary: oklch(0.72 0.17 55);
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-primary-foreground: oklch(0.15 0.02 60);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-accent: oklch(0.26 0.02 55);
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
--sidebar-accent-foreground: oklch(0.96 0.005 85);
|
--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-border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-ring: oklch(0.72 0.17 55);
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
--sidebar: oklch(0.19 0.015 60);
|
|
||||||
--topbar-bg: oklch(0.2 0.015 60);
|
|
||||||
--topbar-border: oklch(1 0 0 / 10%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -113,8 +109,6 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-topbar: var(--topbar-bg);
|
|
||||||
--color-topbar-border: var(--topbar-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from 'vitest-browser-svelte';
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
describe('/+page.svelte', () => {
|
describe('/+page.svelte', () => {
|
||||||
it('should render the redirect page with correct title', async () => {
|
it('should render h1', async () => {
|
||||||
render(Page);
|
render(Page);
|
||||||
|
|
||||||
const title = document.querySelector('title');
|
const heading = page.getByRole('heading', { level: 1 });
|
||||||
expect(title?.textContent).toBe('Open-Meteo Weather');
|
await expect.element(heading).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,216 @@
|
|||||||
<script lang="ts">
|
<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 {
|
interface Props {
|
||||||
children?: import('svelte').Snippet;
|
children?: import('svelte').Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
|
<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?.()}
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.container) {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { get } from 'svelte/store';
|
|||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from '././$types';
|
||||||
|
|
||||||
|
const location = get(storedLocation);
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
const location = get(storedLocation);
|
|
||||||
return {
|
return {
|
||||||
title: `Weather ${location.name}`,
|
title: `Weather ${location.name}`,
|
||||||
location: location
|
location: location
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async () => {
|
export const prerender = true;
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
throw redirect(303, '/weather/week/');
|
throw redirect(303, '/weather/week/');
|
||||||
}) satisfies PageLoad;
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { get } from 'svelte/store';
|
|||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import type { LayoutLoad } from './$types';
|
import type { LayoutLoad } from '././$types';
|
||||||
|
|
||||||
|
const location = get(storedLocation);
|
||||||
|
|
||||||
export const load: LayoutLoad = async () => {
|
export const load: LayoutLoad = async () => {
|
||||||
const location = get(storedLocation);
|
|
||||||
return {
|
return {
|
||||||
heroTitle: `14 Day Weather ${location.name}`,
|
heroTitle: `14 Day Weather ${location.name}`,
|
||||||
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
heroDescription: location.admin1 ?? '' + ' ' + location.country
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
|
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,15 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
const locationRoute = buildLocationRoute(location);
|
|
||||||
throw redirect(303, '/weather/14-day/' + locationRoute);
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildSpreadSeries,
|
|
||||||
composeChartOption,
|
|
||||||
getThemeColors
|
|
||||||
} from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type EnsembleForecastResult,
|
|
||||||
type MarkArea,
|
|
||||||
fetchEnsembleForecast
|
|
||||||
} from '$lib/services/weather';
|
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
|
||||||
|
|
||||||
let showLegend = $state(false);
|
|
||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
let params = $state({
|
|
||||||
...defaultParameters,
|
|
||||||
hourly: ['temperature_2m'],
|
|
||||||
models: ['gfs_seamless']
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface FetchedData {
|
|
||||||
ensembleResult: EnsembleForecastResult;
|
|
||||||
timestamps: number[];
|
|
||||||
timezone: string;
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
chartInstances = [];
|
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const hourlyVars = params.hourly;
|
|
||||||
const modelList = params.models;
|
|
||||||
|
|
||||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
|
||||||
|
|
||||||
const loc = location;
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
loading = true;
|
|
||||||
chartInstances = [];
|
|
||||||
chartComponents = [];
|
|
||||||
|
|
||||||
const result: EnsembleForecastResult = await 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
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchedData = {
|
|
||||||
ensembleResult: result,
|
|
||||||
timestamps: result.timestamps,
|
|
||||||
timezone: result.timezone,
|
|
||||||
markAreas: result.markAreas
|
|
||||||
};
|
|
||||||
console.log(fetchedData.timezone);
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!fetchedData) return;
|
|
||||||
|
|
||||||
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
|
|
||||||
const _showLegend = showLegend;
|
|
||||||
|
|
||||||
const colors = getThemeColors();
|
|
||||||
const variableCount = params.hourly?.length || 0;
|
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
|
||||||
const variable = params.hourly![vi];
|
|
||||||
const varData = ensembleResult.variables[variable];
|
|
||||||
if (!varData) continue;
|
|
||||||
|
|
||||||
const unit = varData.unit;
|
|
||||||
const { average, min: minValues, max: maxValues } = varData;
|
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
|
||||||
(minVal, index) =>
|
|
||||||
[timestamps[index], minVal ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
|
||||||
);
|
|
||||||
|
|
||||||
series.push(...buildSpreadSeries({ variable, spreadData }));
|
|
||||||
|
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
|
||||||
if (daylightSeries) {
|
|
||||||
series.push(daylightSeries);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
|
||||||
const isLast = vi === variableCount - 1;
|
|
||||||
|
|
||||||
const option = composeChartOption({
|
|
||||||
title: isFirst
|
|
||||||
? {
|
|
||||||
text: 'Model Spread',
|
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tooltip: { unit, timezone },
|
|
||||||
legend: {
|
|
||||||
show: _showLegend,
|
|
||||||
data: [variable + '_average']
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
|
||||||
colors,
|
|
||||||
timezone
|
|
||||||
});
|
|
||||||
|
|
||||||
newOptions.push(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
chartOptions = newOptions;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
|
||||||
|
|
||||||
<ChartContainer
|
|
||||||
{loading}
|
|
||||||
chartCount={params.hourly?.length || 0}
|
|
||||||
chartHeight={showLegend ? 400 : 300}
|
|
||||||
>
|
|
||||||
{#each chartOptions as option, i (i)}
|
|
||||||
<EChart
|
|
||||||
{option}
|
|
||||||
height={showLegend ? '400px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
|
||||||
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
|
|
||||||
{#snippet controls()}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
|
||||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
|
||||||
</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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
|
||||||
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
|
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,15 +0,0 @@
|
|||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
const location = get(storedLocation);
|
|
||||||
const locationRoute = buildLocationRoute(location);
|
|
||||||
throw redirect(303, '/weather/compare/' + locationRoute);
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildAverageSeries,
|
|
||||||
buildCurrentTimeSeries,
|
|
||||||
buildDaylightSeries,
|
|
||||||
buildModelSeries,
|
|
||||||
calculateAverage,
|
|
||||||
composeChartOption,
|
|
||||||
findUnit,
|
|
||||||
getThemeColors
|
|
||||||
} from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type MarkArea,
|
|
||||||
type ModelCompareResult,
|
|
||||||
fetchModelComparison
|
|
||||||
} from '$lib/services/weather';
|
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../../options';
|
|
||||||
import { defaultParameters } from '../../options';
|
|
||||||
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
|
||||||
|
|
||||||
const models = [modelsFlat];
|
|
||||||
|
|
||||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
|
||||||
|
|
||||||
let showLegend = $state(false);
|
|
||||||
|
|
||||||
// ─── Data Fetch State ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
let mounted = $state(false);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
|
||||||
storedLocation.subscribe((value) => {
|
|
||||||
location = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
let params = $state({
|
|
||||||
...defaultParameters,
|
|
||||||
hourly: [
|
|
||||||
'temperature_2m',
|
|
||||||
'rain',
|
|
||||||
'relative_humidity_2m',
|
|
||||||
'wind_speed_10m',
|
|
||||||
'wind_direction_10m'
|
|
||||||
],
|
|
||||||
models: [
|
|
||||||
'ecmwf_ifs',
|
|
||||||
'ecmwf_ifs025',
|
|
||||||
'meteofrance_seamless',
|
|
||||||
'ukmo_seamless',
|
|
||||||
'icon_seamless',
|
|
||||||
'gem_seamless',
|
|
||||||
'gfs_seamless'
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface FetchedData {
|
|
||||||
hourly: Record<string, unknown>;
|
|
||||||
hourly_units: Record<string, string>;
|
|
||||||
timezone: string;
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
timestamps: number[];
|
|
||||||
sunrise: number[];
|
|
||||||
sunset: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
|
||||||
|
|
||||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
mounted = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
chartInstances = [];
|
|
||||||
chartOptions = [];
|
|
||||||
chartComponents = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Data Fetching (only when params.hourly or params.models change) ───────
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const hourlyVars = params.hourly;
|
|
||||||
const modelList = params.models;
|
|
||||||
|
|
||||||
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
|
|
||||||
|
|
||||||
const loc = location;
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
loading = true;
|
|
||||||
chartInstances = [];
|
|
||||||
chartComponents = [];
|
|
||||||
|
|
||||||
const result: ModelCompareResult = await 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
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchedData = {
|
|
||||||
hourly: result.hourlyFlat,
|
|
||||||
hourly_units: result.hourlyUnitsFlat,
|
|
||||||
timezone: result.timezone,
|
|
||||||
markAreas: result.markAreas,
|
|
||||||
timestamps: result.timestamps,
|
|
||||||
sunrise: result.sunrise,
|
|
||||||
sunset: result.sunset
|
|
||||||
};
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!fetchedData) return;
|
|
||||||
|
|
||||||
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
|
|
||||||
const _showLegend = showLegend;
|
|
||||||
|
|
||||||
const colors = getThemeColors();
|
|
||||||
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
|
||||||
const variableCount = chartVariables.length;
|
|
||||||
const timeLength = timestamps.length;
|
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
|
||||||
const variable = chartVariables[vi];
|
|
||||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
|
||||||
|
|
||||||
for (const [model, values] of Object.entries(hourlyData)) {
|
|
||||||
if (model === 'time') continue;
|
|
||||||
if (!model.startsWith(variable)) continue;
|
|
||||||
|
|
||||||
const seriesData = (values as (number | null)[]).map(
|
|
||||||
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
|
||||||
);
|
|
||||||
|
|
||||||
series.push(
|
|
||||||
buildModelSeries({
|
|
||||||
name: model,
|
|
||||||
data: seriesData,
|
|
||||||
unit
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
|
||||||
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
|
||||||
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
|
||||||
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
|
|
||||||
const daylightSeries = buildDaylightSeries({ markAreas });
|
|
||||||
if (daylightSeries) {
|
|
||||||
series.push(daylightSeries);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFirst = vi === 0;
|
|
||||||
const isLast = vi === variableCount - 1;
|
|
||||||
|
|
||||||
const option = composeChartOption({
|
|
||||||
title: isFirst
|
|
||||||
? {
|
|
||||||
text: 'Model Compare',
|
|
||||||
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tooltip: { unit, timezone },
|
|
||||||
legend: { show: _showLegend },
|
|
||||||
grid: {
|
|
||||||
hasTitle: isFirst,
|
|
||||||
hasSubtitle: isFirst,
|
|
||||||
showLegend: _showLegend
|
|
||||||
},
|
|
||||||
yAxis: { unit },
|
|
||||||
series,
|
|
||||||
toolbox: false,
|
|
||||||
showCredit: isLast,
|
|
||||||
colors,
|
|
||||||
timezone
|
|
||||||
});
|
|
||||||
|
|
||||||
newOptions.push(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
chartOptions = newOptions;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
|
|
||||||
{#each chartOptions as option, i (i)}
|
|
||||||
<EChart
|
|
||||||
{option}
|
|
||||||
height={showLegend ? '400px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</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={chartInstances} fileName="model-comparison">
|
|
||||||
{#snippet controls()}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
|
||||||
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
|
|
||||||
</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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// Minimal page: no geolocation/state UI, just an embedded Open-Meteo map.
|
|
||||||
// const iframeSrc = 'https://maps.open-meteo.com/';
|
|
||||||
const iframeSrc =
|
|
||||||
'https://allow-cross-origin-loads.maps-5aj.pages.dev/?time=2026-02-21T1400#1.31/0/-0.1';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<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-viewport map. No surrounding UI or location state. -->
|
|
||||||
<div
|
|
||||||
style="position:relative; inset:0; margin:0; padding:0; height:100%; width:100%; background:#000;"
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
src={iframeSrc}
|
|
||||||
title="Open-Meteo Interactive Map"
|
|
||||||
loading="lazy"
|
|
||||||
allowfullscreen
|
|
||||||
referrerpolicy="no-referrer"
|
|
||||||
class="map-iframe"
|
|
||||||
style="border:0; width:100%; height:100%; display:block;"
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
|
|
||||||
></iframe>
|
|
||||||
</div>
|
|
||||||
@@ -7,52 +7,17 @@ export const defaultParameters = {
|
|||||||
|
|
||||||
export const models = [
|
export const models = [
|
||||||
{ value: 'best_match', label: 'Best match' },
|
{ value: 'best_match', label: 'Best match' },
|
||||||
{ value: 'ecmwf_ifs', label: 'ECMWF IFS' },
|
{ value: 'gfs_seamless', label: 'NCEP GFS Seamless' },
|
||||||
{ value: 'ecmwf_ifs025', label: 'ECMWF IFS 0.25' },
|
|
||||||
{ value: 'ecmwf_aifs025_single', label: 'ECMWF AIFS 0.25 Single' },
|
|
||||||
{ value: 'cma_grapes_global', label: 'CMA GRAPES Global' },
|
|
||||||
{ value: 'bom_access_global', label: 'BOM ACCESS Global' },
|
|
||||||
{ value: 'kma_seamless', label: 'KMA Seamless' },
|
|
||||||
{ value: 'kma_ldps', label: 'KMA LDPS' },
|
|
||||||
{ value: 'kma_gdps', label: 'KMA GDPS' },
|
|
||||||
{ value: 'meteofrance_seamless', label: 'Meteo-France Seamless' },
|
|
||||||
{ value: 'meteofrance_arpege_world', label: 'Meteo-France ARPEGE World' },
|
|
||||||
{ value: 'meteofrance_arpege_europe', label: 'Meteo-France ARPEGE Europe' },
|
|
||||||
{ value: 'meteofrance_arome_france', label: 'Meteo-France AROME France' },
|
|
||||||
{ value: 'meteofrance_arome_france_hd', label: 'Meteo-France AROME France HD' },
|
|
||||||
{ value: 'knmi_seamless', label: 'KNMI Seamless' },
|
|
||||||
{ value: 'knmi_harmonie_arome_europe', label: 'KNMI Harmonie Arome Europe' },
|
|
||||||
{ value: 'knmi_harmonie_arome_netherlands', label: 'KNMI Harmonie Arome Netherlands' },
|
|
||||||
{ value: 'dmi_seamless', label: 'DMI Seamless' },
|
|
||||||
{ value: 'dmi_harmonie_arome_europe', label: 'DMI Harmonie Arome Europe' },
|
|
||||||
{ value: 'ukmo_seamless', label: 'UKMO Seamless' },
|
|
||||||
{ value: 'ukmo_global_deterministic_10km', label: 'UKMO Global Deterministic 10km' },
|
|
||||||
{ value: 'ukmo_uk_deterministic_2km', label: 'UKMO UK Deterministic 2km' },
|
|
||||||
{ value: 'meteoswiss_icon_seamless', label: 'MeteoSwiss ICON Seamless' },
|
|
||||||
{ value: 'meteoswiss_icon_ch2', label: 'MeteoSwiss ICON CH2' },
|
|
||||||
{ value: 'meteoswiss_icon_ch1', label: 'MeteoSwiss ICON CH1' },
|
|
||||||
{ value: 'metno_nordic', label: 'MET Norway Nordic' },
|
|
||||||
{ value: 'metno_seamless', label: 'MET Norway Seamless' },
|
|
||||||
{ value: 'gem_hrdps_west', label: 'GEM HRDPS West' },
|
|
||||||
{ value: 'gem_regional', label: 'GEM Regional' },
|
|
||||||
{ value: 'gem_global', label: 'GEM Global' },
|
|
||||||
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
|
||||||
{ value: 'jma_seamless', label: 'JMA Seamless' },
|
{ value: 'jma_seamless', label: 'JMA Seamless' },
|
||||||
{ value: 'jma_msm', label: 'JMA MSM' },
|
{ value: 'kma_seamless', label: 'KMA Seamless' },
|
||||||
{ value: 'jma_gsm', label: 'JMA GSM' },
|
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
|
||||||
{ value: 'gfs_seamless', label: 'GFS Seamless' },
|
{ value: 'gem_seamless', label: 'GEM Seamless' },
|
||||||
{ value: 'gfs_global', label: 'GFS Global' },
|
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
|
||||||
{ value: 'gfs_hrrr', label: 'GFS HRRR' },
|
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
|
||||||
{ value: 'gfs_graphcast025', label: 'GFS Graphcast 0.25' },
|
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
|
||||||
{ value: 'ncep_nbm_conus', label: 'NCEP NBM CONUS' },
|
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
|
||||||
{ value: 'ncep_nam_conus', label: 'NCEP NAM CONUS' },
|
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
|
||||||
{ value: 'ncep_aigfs025', label: 'NCEP AIGFS 0.25' },
|
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
|
||||||
{ value: 'ncep_hgefs025_ensemble_mean', label: 'NCEP HG-EFS 0.25 Ensemble Mean' },
|
|
||||||
{ value: 'icon_seamless', label: 'ICON Seamless (DWD)' },
|
|
||||||
{ value: 'icon_global', label: 'ICON Global' },
|
|
||||||
{ value: 'icon_eu', label: 'ICON EU' },
|
|
||||||
{ value: 'icon_d2', label: 'ICON-D2' },
|
|
||||||
{ value: 'italia_meteo_arpae_icon_2i', label: 'Italia Meteo ARPAE ICON 2i' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const hourly = [
|
export const hourly = [
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import colorScaleHex from './color-scale-hex';
|
import colorScaleHex from './color-scale-hex';
|
||||||
|
|
||||||
const componentFromStr = (numStr: string, percent: number) => {
|
function componentFromStr(numStr: string, percent: number) {
|
||||||
const num = Math.max(0, parseInt(numStr, 10));
|
const num = Math.max(0, parseInt(numStr, 10));
|
||||||
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
|
||||||
};
|
}
|
||||||
|
|
||||||
export const rgbToHex = (rgb: string): string => {
|
export function rgbToHex(rgb: string) {
|
||||||
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
|
||||||
let result,
|
let result,
|
||||||
r,
|
r,
|
||||||
@@ -23,58 +23,57 @@ export const rgbToHex = (rgb: string): string => {
|
|||||||
return '355522';
|
return '355522';
|
||||||
}
|
}
|
||||||
return hex;
|
return hex;
|
||||||
};
|
|
||||||
|
|
||||||
export const hexToRgb = (hex: string): [number, number, number] => {
|
|
||||||
const h = hex.replace('#', '');
|
|
||||||
return [
|
|
||||||
parseInt(h.substring(0, 2), 16),
|
|
||||||
parseInt(h.substring(2, 4), 16),
|
|
||||||
parseInt(h.substring(4, 6), 16)
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getColor = (temperature: number, unit = 'celsius'): string => {
|
|
||||||
if (unit !== 'celsius') {
|
|
||||||
temperature = Math.round(((temperature - 32) * 5) / 9);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getColor = (value: number, unit = 'celsius'): string => {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
if (temperature <= -40) {
|
if (unit === 'celsius') {
|
||||||
|
if (value <= -40) {
|
||||||
index = 0;
|
index = 0;
|
||||||
} else if (temperature >= 60) {
|
} else if (value >= 60) {
|
||||||
index = colorScaleHex.length - 1;
|
index = colorScaleHex.length - 1;
|
||||||
} else {
|
} else {
|
||||||
index = Math.round(temperature) + 45;
|
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];
|
return colorScaleHex[index];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TempStyle {
|
export const textWhite = (hex: string): boolean => {
|
||||||
bg: string;
|
const cleaned = (hex || '').replace('#', '').trim().toLowerCase();
|
||||||
fg: 'white' | 'black';
|
if (!cleaned) {
|
||||||
}
|
|
||||||
|
|
||||||
export const getTempStyle = (temp: number, unit: string): TempStyle => {
|
|
||||||
const bg = getColor(temp, unit);
|
|
||||||
const fg = textWhite(hexToRgb(bg)) ? 'white' : 'black';
|
|
||||||
return { bg, fg };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const textWhite = (
|
|
||||||
[r, g, b, a]: [number, number, number, number] | [number, number, number],
|
|
||||||
dark?: boolean,
|
|
||||||
globalOpacity?: number
|
|
||||||
): boolean => {
|
|
||||||
const alpha = ((a || 1) * (globalOpacity || 100)) / 100;
|
|
||||||
if (alpha < 0.65) {
|
|
||||||
if (dark) {
|
|
||||||
return true;
|
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 {
|
} else {
|
||||||
return false;
|
throw new Error('Invalid color format');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
// check luminance
|
|
||||||
return r * 0.299 + g * 0.587 + b * 0.114 <= 150;
|
// 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
@@ -80,10 +80,4 @@ const weatherCodes: Record<number, string> = {
|
|||||||
99: 'tornado'
|
99: 'tornado'
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getWeatherIconName(code: number, daytime: boolean): string {
|
|
||||||
const prefix = daytime ? 'day' : 'night';
|
|
||||||
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
|
||||||
return `wi-${prefix}-${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default weatherCodes;
|
export default weatherCodes;
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { buildLocationRoute } from '$lib/utils/location';
|
import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||||
|
|
||||||
import type { PageLoad } from './$types';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load = (async () => {
|
export const prerender = true;
|
||||||
|
|
||||||
|
export const load: PageLoad = async () => {
|
||||||
const location = get(storedLocation);
|
const location = get(storedLocation);
|
||||||
const locationRoute = buildLocationRoute(location);
|
const locationRoute = geoLocationNameToRoute(location.name || '');
|
||||||
throw redirect(303, '/weather/week/' + locationRoute);
|
throw redirect(
|
||||||
}) satisfies PageLoad;
|
303,
|
||||||
|
'/weather/week/' +
|
||||||
|
(location.population
|
||||||
|
? location.population > 543000
|
||||||
|
? locationRoute
|
||||||
|
: locationRoute + '_' + location.id
|
||||||
|
: locationRoute + '_' + location.id)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
import { fetchWeatherApi } from 'openmeteo';
|
||||||
|
|
||||||
import { type WeekForecastResult, fetchWeekForecast } from '$lib/services/weather';
|
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
|
||||||
|
|
||||||
import { defaultParameters } from '../../options';
|
import { pad } from '$lib/utils/index';
|
||||||
import DailyCards from './DailyCards.svelte';
|
|
||||||
import HourlyTable from './HourlyTable.svelte';
|
|
||||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
|
||||||
import ModelSelector from './ModelSelector.svelte';
|
|
||||||
|
|
||||||
import type { GeoLocation } from '$lib/stores/settings';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import type { FetchedDaily, FetchedHourly } from './types';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
import type { ConfigInterface } from '../../config';
|
||||||
|
|
||||||
let params = $state({
|
let params = $state({
|
||||||
latitude: [$storedLocation.latitude],
|
latitude: [$storedLocation.latitude],
|
||||||
@@ -22,108 +30,657 @@
|
|||||||
...defaultParameters
|
...defaultParameters
|
||||||
});
|
});
|
||||||
|
|
||||||
let location = $state<GeoLocation>($storedLocation);
|
let location = $state($storedLocation);
|
||||||
storedLocation.subscribe((value) => {
|
storedLocation.subscribe((value) => {
|
||||||
location = value;
|
location = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
let mounted = $state(false);
|
let diffTemp: number | undefined = $state();
|
||||||
let loading = $state(true);
|
let maxTemp: number | undefined = $state();
|
||||||
|
|
||||||
const selectedDay = new SvelteDate();
|
let weatherCodesHourly: Float32Array | null | undefined = $state();
|
||||||
|
let canvasElement: HTMLCanvasElement | null | undefined = $state();
|
||||||
|
|
||||||
let fetchedHourly: FetchedHourly | null = $state(null);
|
const today = new Date();
|
||||||
let fetchedDaily: FetchedDaily | null = $state(null);
|
let selectedDay = $state(new Date());
|
||||||
|
let selectedDayIndex = $state(1);
|
||||||
|
|
||||||
let meteogramCharts: MeteogramCharts | undefined = $state();
|
let entries = $state(0);
|
||||||
|
|
||||||
const switchDay = (date: Date) => {
|
let weather = $derived(
|
||||||
selectedDay.setTime(date.getTime());
|
(async (location: GeoLocation) => {
|
||||||
meteogramCharts?.scrollToDay(date);
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
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()!;
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
|
||||||
|
let winddir = true;
|
||||||
|
entries = 6;
|
||||||
|
|
||||||
|
let scrollDiv: HTMLElement | undefined = $state();
|
||||||
|
let tableCells;
|
||||||
|
|
||||||
|
const switchDay = (date: Date, index: number) => {
|
||||||
|
selectedDay = date;
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
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(() => {
|
let modelSelected = $derived(models.find((mo) => String(mo.value) === params.models?.[0]));
|
||||||
const loc = location;
|
// let modelSelectedValue = $derived(params.models[0]);
|
||||||
const modelList = params.models;
|
//
|
||||||
|
|
||||||
if (!mounted || !loc || !modelList?.length) return;
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
loading = true;
|
|
||||||
|
|
||||||
const result: WeekForecastResult = await fetchWeekForecast({
|
|
||||||
latitude: loc.latitude!,
|
|
||||||
longitude: loc.longitude!,
|
|
||||||
model: modelList[0],
|
|
||||||
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: 7,
|
|
||||||
past_days: 0,
|
|
||||||
timezone: loc.timezone
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchedHourly = {
|
|
||||||
hourly: result.hourly,
|
|
||||||
utc_offset_seconds: result.utcOffsetSeconds,
|
|
||||||
timezone: result.timezone,
|
|
||||||
timestamps: result.hourlyTimestamps,
|
|
||||||
hourlyDates: result.hourlyDates,
|
|
||||||
markAreas: result.markAreas
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchedDaily = {
|
|
||||||
daily: result.daily,
|
|
||||||
timezone: result.timezone,
|
|
||||||
dailyDates: result.dailyDates
|
|
||||||
};
|
|
||||||
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Weather | Open-Meteo.com</title>
|
<title>Weather | Open-Meteo.com</title>
|
||||||
<link rel="canonical" href="https://open-meteo.com/weather" />
|
<link rel="canonical" href="https://open-meteo.com/weather" />
|
||||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
<meta name="description" content="segseg" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="week-page">
|
<div class="">
|
||||||
<div class="weather-content" style="min-height: 50vh">
|
<div class="weather-content" style="min-height: 50vh">
|
||||||
<DailyCards daily={fetchedDaily} {selectedDay} units={params} onSelectDay={switchDay} />
|
<div
|
||||||
|
in:fade
|
||||||
{#if fetchedHourly && fetchedDaily}
|
out:fade
|
||||||
<HourlyTable
|
style="min-height: 256px"
|
||||||
data={fetchedHourly}
|
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
|
||||||
daily={fetchedDaily}
|
>
|
||||||
{selectedDay}
|
{#await weatherDaily then wd}
|
||||||
units={params}
|
{#each wd.daily.time as time, index (index)}
|
||||||
locationName={location.name ?? ''}
|
{@const selected = time.getDate() === selectedDay.getDate()}
|
||||||
/>
|
{#if wd.daily.temperature_2m_max.values(index) != null && !isNaN(Number(wd.daily.temperature_2m_max
|
||||||
{/if}
|
.values(index)!
|
||||||
|
.toFixed(1)))}
|
||||||
{#if fetchedHourly}
|
<button
|
||||||
<MeteogramCharts
|
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
|
||||||
bind:this={meteogramCharts}
|
class="cursor-pointer"
|
||||||
data={fetchedHourly}
|
onclick={() => {
|
||||||
{selectedDay}
|
switchDay(time, index);
|
||||||
units={params}
|
|
||||||
{loading}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={params.models?.[0] ?? 'best_match'}
|
|
||||||
onModelChange={(model) => {
|
|
||||||
params.models = [model];
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
</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>
|
||||||
|
{/await}
|
||||||
|
<div>
|
||||||
|
<div class="mt-6 flex gap-6 md:mt-12">
|
||||||
|
<div class="relative w-1/2">
|
||||||
|
{#if params.models && params.models.length > 0}
|
||||||
|
{@const modelValue = params.models[0]}
|
||||||
|
<Select.Root
|
||||||
|
name="model_selection"
|
||||||
|
type="single"
|
||||||
|
value={modelValue}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
if (params.models && val) {
|
||||||
|
params.models = [val];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
aria-label="Forecast days input"
|
||||||
|
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
|
||||||
|
>
|
||||||
|
<Select.Content preventScroll={false} class="border-border">
|
||||||
|
{#each models as mo (mo.value)}
|
||||||
|
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
|
||||||
|
>Weather model</Label
|
||||||
|
>
|
||||||
|
</Select.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.now {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weather-week-icon {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #0061a5;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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';
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageLoad = async (event) => {
|
export const prerender = true;
|
||||||
const location = await resolveLocationFromRoute({
|
|
||||||
urlLocation: event.params.location,
|
|
||||||
routePrefix: '/weather/week/',
|
|
||||||
event
|
|
||||||
});
|
|
||||||
|
|
||||||
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,178 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
|
||||||
import weatherCodes 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { daily, selectedDay, units, onSelectDay }: Props = $props();
|
|
||||||
|
|
||||||
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.2) return '#fcd34d';
|
|
||||||
return '#d1d5db';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div in:fade out:fade class="mb-6 min-h-[260px]">
|
|
||||||
<div class="flex gap-1 overflow-x-auto pb-1" style="scrollbar-width: thin">
|
|
||||||
{#if daily}
|
|
||||||
{#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 minStyle = getTempStyle(tempMin, unit)}
|
|
||||||
{#if tempMax != null && !isNaN(tempMax)}
|
|
||||||
<button
|
|
||||||
class="group flex min-w-[108px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-0.5 rounded-xl border-2 px-1.5 py-2 transition-all duration-200
|
|
||||||
{selected
|
|
||||||
? 'scale-[1.03] border-primary bg-accent shadow-md'
|
|
||||||
: 'border-transparent bg-card hover:bg-accent'}"
|
|
||||||
onclick={() => onSelectDay(time, index)}
|
|
||||||
>
|
|
||||||
<!-- Day label -->
|
|
||||||
<span class="text-sm font-bold tracking-wide">
|
|
||||||
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] text-muted-foreground">
|
|
||||||
{getRelativeDayLabel(time, daily.timezone)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Weather icon -->
|
|
||||||
<div
|
|
||||||
class="my-1 flex w-full items-center justify-center rounded-lg py-1.5"
|
|
||||||
style="background: {sunColor}22"
|
|
||||||
>
|
|
||||||
<svg class="fill-foreground" width="48px" height="48px">
|
|
||||||
<use
|
|
||||||
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
|
|
||||||
wCode as keyof typeof weatherCodes
|
|
||||||
] ?? 'clear'}.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Temperature max/min -->
|
|
||||||
<div class="flex w-full flex-col">
|
|
||||||
<div
|
|
||||||
class="w-full rounded-t px-1 py-0.5 text-center text-[15px] font-bold"
|
|
||||||
style="background-color: {maxStyle.bg}; color: {maxStyle.fg}"
|
|
||||||
>
|
|
||||||
{tempMax.toFixed(0)}°
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="w-full rounded-b px-1 py-0.5 text-center text-xs font-semibold"
|
|
||||||
style="background-color: {minStyle.bg}; color: {minStyle.fg}"
|
|
||||||
>
|
|
||||||
{tempMin.toFixed(0)}°
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details section -->
|
|
||||||
<div class="mt-1 flex w-full flex-col items-center gap-0.5">
|
|
||||||
<!-- Sunshine bar -->
|
|
||||||
<div class="flex w-full items-center gap-1 px-1">
|
|
||||||
<svg class="shrink-0" width="14px" height="14px" style="fill: {sunColor}">
|
|
||||||
<use xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all"
|
|
||||||
style="width: {sunPct}%; background-color: {sunColor}"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<span class="text-[10px] font-medium text-muted-foreground">
|
|
||||||
{Number((sunDuration ?? 0) / 3600).toFixed(0)}h
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Precipitation -->
|
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
|
||||||
<svg class="fill-foreground shrink-0" width="14px" height="14px">
|
|
||||||
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<span>
|
|
||||||
{Number(precipSum ?? 0).toFixed(
|
|
||||||
precipSum >= 10 ? 0 : 1
|
|
||||||
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Wind with direction -->
|
|
||||||
<div class="flex items-center gap-1 text-[11px]">
|
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
|
||||||
<div
|
|
||||||
class="inline-flex shrink-0"
|
|
||||||
style="transform: {getWindArrowRotation(windDir)}"
|
|
||||||
>
|
|
||||||
<svg class="fill-foreground" width="20px" height="20px">
|
|
||||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<svg class="fill-foreground shrink-0" width="20px" height="20px">
|
|
||||||
<use xlink:href="/images/weather-icons/wi-strong-wind.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
<span>
|
|
||||||
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
|
|
||||||
>-{gustMax?.toFixed(0) ?? '-'}</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
button {
|
|
||||||
min-width: 92px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button :global(svg[width='48px']) {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,517 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data, daily, selectedDay, units, locationName }: Props = $props();
|
|
||||||
|
|
||||||
let hourlyInterval = $state<1 | 3>(3);
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
</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 leading-tight">
|
|
||||||
{#if iconName}
|
|
||||||
{@render weatherIcon(iconName)}
|
|
||||||
{/if}
|
|
||||||
{#if label}
|
|
||||||
<span class="text-[11px] font-semibold text-muted-foreground">{label}</span>
|
|
||||||
{/if}
|
|
||||||
{#if unit}
|
|
||||||
<span class="text-[10px] font-semibold text-muted-foreground">{unit}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 class="text-lg font-bold">
|
|
||||||
{formatZoned(selectedDay, data.timezone, 'EEEE')} – Hourly
|
|
||||||
<span class="ml-1 text-xs font-normal text-muted-foreground">({timezoneLabel})</span>
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
|
||||||
<span class="select-none text-muted-foreground">3h</span>
|
|
||||||
<button
|
|
||||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
|
||||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
|
||||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
|
||||||
title="Toggle between 1-hour and 3-hour intervals"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-[3px] size-[18px] rounded-full bg-white shadow-sm transition-[left] duration-200
|
|
||||||
{hourlyInterval === 1 ? 'left-[22px]' : 'left-[3px]'}"
|
|
||||||
></span>
|
|
||||||
</button>
|
|
||||||
<span class="select-none text-muted-foreground">1h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if cellData.length > 0}
|
|
||||||
{@const hourly = data.hourly}
|
|
||||||
{@const iconPx = is3h ? 40 : 26}
|
|
||||||
<div class="overflow-hidden rounded-lg border border-border">
|
|
||||||
<table class="w-full table-fixed border-collapse whitespace-nowrap">
|
|
||||||
<caption class="sr-only">Hourly weather details for {locationName}</caption>
|
|
||||||
<colgroup>
|
|
||||||
<col class="w-14 md:w-16" />
|
|
||||||
{#each cellData as _ (_.idx)}
|
|
||||||
<col />
|
|
||||||
{/each}
|
|
||||||
</colgroup>
|
|
||||||
<tbody>
|
|
||||||
<!-- Time + Daylight bar (merged) -->
|
|
||||||
<tr class="!border-t-0">
|
|
||||||
<th class="hdr" scope="row">
|
|
||||||
<span class="text-[10px] font-semibold text-muted-foreground">{timezoneLabel}</span>
|
|
||||||
</th>
|
|
||||||
<td colspan={cellData.length} class="relative h-10 overflow-visible p-0">
|
|
||||||
<!-- Daylight background -->
|
|
||||||
{#if sunTimes && sunrisePercent != null && sunsetPercent != null}
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 left-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
|
||||||
style="width:{sunrisePercent}%"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 bg-amber-400/15 dark:bg-amber-400/10"
|
|
||||||
style="left:{sunrisePercent}%;width:{sunsetPercent - sunrisePercent}%"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 right-0 bg-indigo-950/10 dark:bg-indigo-950/30"
|
|
||||||
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 whitespace-nowrap text-[10px] font-semibold leading-none text-amber-700 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="fill-foreground inline-block"
|
|
||||||
width="12px"
|
|
||||||
height="12px"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</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 bottom-0.5 right-1 whitespace-nowrap text-[10px] font-semibold leading-none text-indigo-600 dark:text-indigo-300 inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="fill-foreground inline-block"
|
|
||||||
width="12px"
|
|
||||||
height="12px"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"
|
|
||||||
></use>
|
|
||||||
</svg>
|
|
||||||
<span class="align-middle">{formatTime(sunTimes.sunset)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/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 pt-1 font-bold pl-0.5 text-sm
|
|
||||||
{cell.isNow ? 'text-destructive' : ''}"
|
|
||||||
style="left:{leftPct}%;width:{widthPct}%"
|
|
||||||
>
|
|
||||||
{#if is3h}
|
|
||||||
{formatZoned(cell.date, data.timezone, 'HH')}
|
|
||||||
{:else}
|
|
||||||
<span class="inline-flex items-baseline gap-1">
|
|
||||||
<span class="text-[11px] font-semibold"
|
|
||||||
>{formatZoned(cell.date, data.timezone, 'HH')}</span
|
|
||||||
>
|
|
||||||
<sup
|
|
||||||
class="text-[9px] font-semibold text-muted-foreground leading-none align-baseline"
|
|
||||||
>00</sup
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Weather Icons -->
|
|
||||||
<tr>
|
|
||||||
{@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 ? 'px-1 py-2.5' : 'px-0.5 py-1.5'}"
|
|
||||||
class:now={cell.isNow}
|
|
||||||
class:icon-day={cell.isDaytime}
|
|
||||||
class:icon-night={!cell.isDaytime}
|
|
||||||
class:icon-dawn={!cell.isDaytime && cellData[i + 1]?.isDaytime}
|
|
||||||
class:icon-dusk={cell.isDaytime && cellData[i + 1] && !cellData[i + 1].isDaytime}
|
|
||||||
>
|
|
||||||
{@render weatherIcon(getWeatherIconName(wCode, cell.isDaytime), iconPx)}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Temperature -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader('wi-thermometer', tempUnit)}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const temp = hourly.temperature_2m[cell.idx]}
|
|
||||||
{@const style = getTempStyle(temp, String(units.temperature_unit))}
|
|
||||||
<td
|
|
||||||
class="cell font-bold {is3h ? 'py-2.5 text-lg' : 'py-2 text-[15px]'}"
|
|
||||||
class:now={cell.isNow}
|
|
||||||
style="background-color:{style.bg};color:{style.fg}"
|
|
||||||
>
|
|
||||||
{formatTemp(temp)}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Feels Like -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader(undefined, tempUnit, 'Feels')}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const temp = hourly.apparent_temperature[cell.idx]}
|
|
||||||
<td
|
|
||||||
class="cell text-muted-foreground {is3h ? 'py-1 text-[13px]' : 'py-0.5 text-[11px]'}"
|
|
||||||
class:now={cell.isNow}
|
|
||||||
>
|
|
||||||
{formatTemp(temp)}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Wind -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader('wi-strong-wind', windUnit)}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const wind = hourly.windspeed_10m[cell.idx]}
|
|
||||||
{@const windDir = hourly.winddirection_10m[cell.idx]}
|
|
||||||
<td class="cell text-center align-middle leading-tight" class:now={cell.isNow}>
|
|
||||||
{#if windDir != null && !isNaN(windDir)}
|
|
||||||
<span
|
|
||||||
class="inline-block leading-[0]"
|
|
||||||
style="transform:{getWindArrowRotation(windDir)}"
|
|
||||||
>
|
|
||||||
{@render weatherIcon('wi-direction-down', 24)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="block font-semibold {is3h ? 'mt-0.5 text-sm' : 'text-xs'}">
|
|
||||||
{formatValue(wind)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Humidity -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader('wi-humidity', '%')}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const hum = hourly.relative_humidity_2m[cell.idx]}
|
|
||||||
<td class="cell" class:now={cell.isNow} style="background:{getHumidityBg(hum ?? 0)}">
|
|
||||||
{formatValue(hum)}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Cloud Cover -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader('wi-cloud', '%')}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const cloud = hourly.cloud_cover[cell.idx]}
|
|
||||||
<td
|
|
||||||
class="cell"
|
|
||||||
class:now={cell.isNow}
|
|
||||||
style="background:rgba(140,160,180,{getCloudOpacity(cloud ?? 0)})"
|
|
||||||
>
|
|
||||||
{formatValue(cloud)}
|
|
||||||
</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Precipitation -->
|
|
||||||
<tr>
|
|
||||||
{@render rowHeader('wi-raindrop', precipUnit)}
|
|
||||||
{#each cellData as cell (cell.idx)}
|
|
||||||
{@const precip = hourly.precipitation[cell.idx]}
|
|
||||||
{@const prob = hourly.precipitation_probability[cell.idx]}
|
|
||||||
<td
|
|
||||||
class="precip-cell {is3h ? 'h-14' : 'h-11'}"
|
|
||||||
class:now={cell.isNow}
|
|
||||||
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>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
tr {
|
|
||||||
border-top: 1px solid hsl(var(--border));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Base cell ──────────────────────────────────────────── */
|
|
||||||
.cell {
|
|
||||||
padding: 6px 2px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cell.now {
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Row header ─────────────────────────────────────────── */
|
|
||||||
.hdr {
|
|
||||||
padding: 4px;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 11px;
|
|
||||||
background: hsl(var(--background));
|
|
||||||
border-right: 2px solid hsl(var(--border));
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Precipitation ──────────────────────────────────────── */
|
|
||||||
.precip-cell {
|
|
||||||
position: relative;
|
|
||||||
padding: 0;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: 1px solid hsl(var(--border) / 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-cell:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-cell.now {
|
|
||||||
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.precip-bar {
|
|
||||||
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(120, 180, 255, 0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────── */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hdr {
|
|
||||||
padding: 3px 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.cell {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 4px 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
|
|
||||||
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
|
|
||||||
|
|
||||||
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
|
|
||||||
import '$lib/components/charts/echarts.css';
|
|
||||||
|
|
||||||
import { getColor } from '../../utils/colors';
|
|
||||||
import {
|
|
||||||
type FetchedHourly,
|
|
||||||
type WeatherUnits,
|
|
||||||
getPrecipUnit,
|
|
||||||
getTempUnit,
|
|
||||||
getWindDirectionLabel,
|
|
||||||
getWindUnit
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
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 MS_PER_DAY = 24 * 3600 * 1000;
|
|
||||||
|
|
||||||
let showCharts = $state(false);
|
|
||||||
let chartComponents: EChart[] = $state([]);
|
|
||||||
let chartInstances: echarts.ECharts[] = $state([]);
|
|
||||||
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
|
||||||
|
|
||||||
export function scrollToDay(day: Date): void {
|
|
||||||
if (chartInstances.length === 0 || !data) return;
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstHourIdx === -1) return;
|
|
||||||
|
|
||||||
const dayStart = data.timestamps[firstHourIdx];
|
|
||||||
const dayEnd = dayStart + MS_PER_DAY;
|
|
||||||
const timestamps = data.timestamps;
|
|
||||||
const rangeStart = timestamps[0];
|
|
||||||
const rangeEnd = timestamps[timestamps.length - 1];
|
|
||||||
const totalRange = rangeEnd - rangeStart;
|
|
||||||
|
|
||||||
if (totalRange <= 0) return;
|
|
||||||
|
|
||||||
const startPct = Math.max(0, ((dayStart - rangeStart) / totalRange) * 100);
|
|
||||||
const endPct = Math.min(100, ((dayEnd - rangeStart) / totalRange) * 100);
|
|
||||||
|
|
||||||
for (const chart of chartInstances) {
|
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: startPct, end: endPct });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetZoom(): void {
|
|
||||||
for (const chart of chartInstances) {
|
|
||||||
if (chart && !chart.isDisposed()) {
|
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onResetZoom?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChartReady(chart: echarts.ECharts): void {
|
|
||||||
chart.group = CHART_GROUP;
|
|
||||||
chartInstances = [...chartInstances, chart];
|
|
||||||
if (chartInstances.length === 3) {
|
|
||||||
echarts.connect(CHART_GROUP);
|
|
||||||
requestAnimationFrame(() => scrollToDay(selectedDay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// Reset chart instances when data changes
|
|
||||||
if (data) {
|
|
||||||
chartInstances = [];
|
|
||||||
chartComponents = [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
const { hourly, timestamps, markAreas } = data;
|
|
||||||
const colors = getThemeColors();
|
|
||||||
const tempUnit = getTempUnit(units);
|
|
||||||
const precipUnit = getPrecipUnit(units);
|
|
||||||
const windUnit = getWindUnit(units);
|
|
||||||
|
|
||||||
const temps = hourly.temperature_2m;
|
|
||||||
const precip = hourly.precipitation;
|
|
||||||
const precipProb = hourly.precipitation_probability;
|
|
||||||
const cloudCov = hourly.cloud_cover;
|
|
||||||
const windSpeed = hourly.windspeed_10m;
|
|
||||||
const humidity = hourly.relative_humidity_2m;
|
|
||||||
|
|
||||||
const validTemps = temps.filter((t: number) => t !== null && !isNaN(t));
|
|
||||||
const minTemp = Math.min(...validTemps);
|
|
||||||
const maxTemp = Math.max(...validTemps);
|
|
||||||
|
|
||||||
const tempData = temps.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const cloudData = cloudCov.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipData = precip.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const precipProbData = precipProb.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const windData = windSpeed.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
const humidityData = humidity.map((v: number, i: number) => [timestamps[i], v]);
|
|
||||||
|
|
||||||
const annotations = (): Array<Record<string, unknown>> => {
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
|
||||||
series.push(buildCurrentTimeSeries());
|
|
||||||
const dl = buildDaylightSeries({ markAreas });
|
|
||||||
if (dl) series.push(dl);
|
|
||||||
return series;
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeXAxis = (showLabel: boolean): Record<string, unknown> => ({
|
|
||||||
type: 'time',
|
|
||||||
splitLine: { show: false },
|
|
||||||
axisLine: { lineStyle: { color: colors.axisLine } },
|
|
||||||
axisLabel: {
|
|
||||||
color: colors.text,
|
|
||||||
hideOverlap: true,
|
|
||||||
show: showLabel,
|
|
||||||
formatter: (value: number) => formatZoned(new Date(value), data.timezone, 'HH:mm')
|
|
||||||
},
|
|
||||||
axisTick: { lineStyle: { color: colors.axisLine } }
|
|
||||||
});
|
|
||||||
|
|
||||||
const insideZoom = (): Record<string, unknown> => ({
|
|
||||||
type: 'inside',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
filterMode: 'none',
|
|
||||||
zoomOnMouseWheel: true,
|
|
||||||
moveOnMouseMove: true,
|
|
||||||
moveOnMouseWheel: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const sliderZoom = (): Record<string, unknown> => ({
|
|
||||||
type: 'slider',
|
|
||||||
xAxisIndex: 0,
|
|
||||||
filterMode: 'none',
|
|
||||||
height: 20,
|
|
||||||
bottom: 4,
|
|
||||||
borderColor: colors.axisLine,
|
|
||||||
fillerColor: 'rgba(100, 140, 200, 0.2)',
|
|
||||||
handleStyle: { color: colors.text },
|
|
||||||
textStyle: { color: colors.text, fontSize: 10 },
|
|
||||||
dataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: colors.splitLine }
|
|
||||||
},
|
|
||||||
selectedDataBackground: {
|
|
||||||
lineStyle: { color: colors.axisLine },
|
|
||||||
areaStyle: { color: 'rgba(100, 140, 200, 0.15)' }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const tooltipBase = (
|
|
||||||
formatter: (
|
|
||||||
params: Array<{
|
|
||||||
axisValue: number;
|
|
||||||
seriesName: string;
|
|
||||||
marker: string;
|
|
||||||
value: number | number[] | null;
|
|
||||||
}>
|
|
||||||
) => string
|
|
||||||
): Record<string, unknown> => ({
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false,
|
|
||||||
label: {
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
color: colors.text,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
borderWidth: 1,
|
|
||||||
formatter: (params: { axisDimension: string; value: number }) => {
|
|
||||||
if (params.axisDimension === 'x') {
|
|
||||||
return formatZoned(new Date(params.value), data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
}
|
|
||||||
return params.value.toFixed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backgroundColor: colors.tooltipBg,
|
|
||||||
borderColor: colors.tooltipBorder,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
formatter
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatDate = (ts: number): string => {
|
|
||||||
const date = new Date(ts);
|
|
||||||
const dateStr = formatZoned(date, data.timezone, 'EEE d MMM HH:mm');
|
|
||||||
return `<b>${dateStr}</b><br/>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isAnnotation = (name: string): boolean => name === 'Daylight' || name === 'Current Time';
|
|
||||||
|
|
||||||
const tempOption: Record<string, unknown> = {
|
|
||||||
title: {
|
|
||||||
text: 'Temperature & Cloud Cover',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
||||||
},
|
|
||||||
tooltip: tooltipBase((params) => {
|
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Temperature')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${tempUnit}</b><br/>`;
|
|
||||||
else if (name === 'Cloud Cover')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Temperature', 'Cloud Cover']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: tempUnit,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{ type: 'value', min: 0, max: 250, inverse: true, show: false }
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Temperature',
|
|
||||||
type: 'line',
|
|
||||||
data: tempData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 3, color: '#ef6c00' },
|
|
||||||
itemStyle: { color: '#ef6c00' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{
|
|
||||||
offset: 0,
|
|
||||||
color: getColor(maxTemp, String(units.temperature_unit)) + '88'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 0.5,
|
|
||||||
color: getColor((maxTemp + minTemp) / 2, String(units.temperature_unit)) + '44'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
color: getColor(minTemp, String(units.temperature_unit)) + '08'
|
|
||||||
}
|
|
||||||
])
|
|
||||||
},
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cloud Cover',
|
|
||||||
type: 'line',
|
|
||||||
data: cloudData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
lineStyle: { width: 0 },
|
|
||||||
itemStyle: { color: colors.text },
|
|
||||||
areaStyle: { color: 'rgba(150, 150, 150, 0.25)', origin: 'start' },
|
|
||||||
z: 1,
|
|
||||||
silent: true
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
const precipOption: Record<string, unknown> = {
|
|
||||||
title: {
|
|
||||||
text: 'Precipitation & Probability',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
||||||
},
|
|
||||||
tooltip: tooltipBase((params) => {
|
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Precipitation')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(1)} ${precipUnit}</b><br/>`;
|
|
||||||
else if (name === 'Precip. Probability')
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 0,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Precipitation', 'Precip. Probability']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 40 },
|
|
||||||
dataZoom: [insideZoom()],
|
|
||||||
xAxis: timeXAxis(false),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: precipUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Precipitation',
|
|
||||||
type: 'bar',
|
|
||||||
data: precipData,
|
|
||||||
barMaxWidth: 8,
|
|
||||||
itemStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(30, 136, 229, 0.9)' },
|
|
||||||
{ offset: 1, color: 'rgba(30, 136, 229, 0.4)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Precip. Probability',
|
|
||||||
type: 'line',
|
|
||||||
data: precipProbData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, type: 'dashed', color: '#5c6bc0' },
|
|
||||||
itemStyle: { color: '#5c6bc0' },
|
|
||||||
yAxisIndex: 1,
|
|
||||||
z: 4
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
const windOption: Record<string, unknown> = {
|
|
||||||
title: {
|
|
||||||
text: 'Wind Speed & Humidity',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: { fontWeight: 'normal', fontSize: 16, color: colors.text }
|
|
||||||
},
|
|
||||||
tooltip: tooltipBase((params) => {
|
|
||||||
if (!params?.length) return '';
|
|
||||||
let html = formatDate(params[0].axisValue as number);
|
|
||||||
for (const item of params) {
|
|
||||||
const name = item.seriesName as string;
|
|
||||||
if (isAnnotation(name)) continue;
|
|
||||||
const val = (item.value as [number, number])?.[1];
|
|
||||||
if (val == null) continue;
|
|
||||||
if (name === 'Wind Speed') {
|
|
||||||
const idx = timestamps.indexOf(
|
|
||||||
(params[0] as Record<string, unknown>).axisValue as number
|
|
||||||
);
|
|
||||||
const windDir = idx >= 0 ? hourly.winddirection_10m[idx] : null;
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)} ${windUnit}</b>`;
|
|
||||||
if (windDir != null && !isNaN(windDir)) html += ` (${getWindDirectionLabel(windDir)})`;
|
|
||||||
html += '<br/>';
|
|
||||||
} else if (name === 'Humidity') {
|
|
||||||
html += `${item.marker} ${name}: <b>${val.toFixed(0)}%</b><br/>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}),
|
|
||||||
legend: {
|
|
||||||
show: true,
|
|
||||||
bottom: 28,
|
|
||||||
textStyle: { color: colors.text },
|
|
||||||
data: ['Wind Speed', 'Humidity']
|
|
||||||
},
|
|
||||||
grid: { left: 60, right: 60, top: 50, bottom: 60 },
|
|
||||||
dataZoom: [insideZoom(), sliderZoom()],
|
|
||||||
xAxis: timeXAxis(true),
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: windUnit,
|
|
||||||
min: 0,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { lineStyle: { color: colors.splitLine } }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '%',
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
nameTextStyle: { color: colors.text },
|
|
||||||
axisLine: { show: false },
|
|
||||||
axisLabel: { color: colors.text },
|
|
||||||
splitLine: { show: false }
|
|
||||||
}
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: 'Wind Speed',
|
|
||||||
type: 'line',
|
|
||||||
data: windData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, color: '#26a69a' },
|
|
||||||
itemStyle: { color: '#26a69a' },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(38, 166, 154, 0.25)' },
|
|
||||||
{ offset: 1, color: 'rgba(38, 166, 154, 0.02)' }
|
|
||||||
])
|
|
||||||
},
|
|
||||||
yAxisIndex: 0,
|
|
||||||
z: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Humidity',
|
|
||||||
type: 'line',
|
|
||||||
data: humidityData,
|
|
||||||
smooth: true,
|
|
||||||
showSymbol: false,
|
|
||||||
lineStyle: { width: 2, type: 'dotted', color: '#8d6e63' },
|
|
||||||
itemStyle: { color: '#8d6e63' },
|
|
||||||
yAxisIndex: 1,
|
|
||||||
z: 4
|
|
||||||
},
|
|
||||||
...annotations()
|
|
||||||
],
|
|
||||||
graphic: [
|
|
||||||
{
|
|
||||||
type: 'text',
|
|
||||||
right: 10,
|
|
||||||
bottom: 30,
|
|
||||||
style: {
|
|
||||||
text: 'Open-Meteo.com',
|
|
||||||
fontSize: 10,
|
|
||||||
fill: colors.text,
|
|
||||||
opacity: 0.4
|
|
||||||
},
|
|
||||||
cursor: 'pointer'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
textStyle: { color: colors.text }
|
|
||||||
};
|
|
||||||
|
|
||||||
chartOptions = [tempOption, precipOption, windOption];
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="charts-toggle-section">
|
|
||||||
<button class="charts-toggle-btn" onclick={() => (showCharts = !showCharts)}>
|
|
||||||
<span>Detailed Meteogram Charts</span>
|
|
||||||
<svg
|
|
||||||
class="toggle-chevron {showCharts ? 'open' : ''}"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
>
|
|
||||||
<polyline points="6 9 12 15 18 9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showCharts}
|
|
||||||
<div class="detailed-charts" in:fade={{ duration: 200 }}>
|
|
||||||
<div class="charts-header">
|
|
||||||
<h3 class="charts-title">
|
|
||||||
{formatZoned(selectedDay, data.timezone, 'EEEE')}
|
|
||||||
<small>
|
|
||||||
{getRelativeDayLabel(selectedDay, data.timezone) !==
|
|
||||||
formatZoned(selectedDay, data.timezone, 'EEEE')
|
|
||||||
? ` (${getRelativeDayLabel(selectedDay, data.timezone)})`
|
|
||||||
: ''}
|
|
||||||
</small>
|
|
||||||
</h3>
|
|
||||||
<button type="button" class="zoom-reset-btn" title="Show all days (Esc)" onclick={resetZoom}>
|
|
||||||
Show All
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChartContainer {loading} chartCount={3} chartHeight={300}>
|
|
||||||
{#each chartOptions as option, i (i)}
|
|
||||||
<EChart
|
|
||||||
{option}
|
|
||||||
height={i === 2 ? '320px' : '300px'}
|
|
||||||
onChartReady={handleChartReady}
|
|
||||||
bind:this={chartComponents[i]}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</ChartContainer>
|
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
|
||||||
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.charts-toggle-section {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-toggle-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--muted) / 0.5);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: var(--radius, 0.375rem);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 150ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-toggle-btn:hover {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--muted));
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-chevron {
|
|
||||||
transition: transform 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-chevron.open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailed-charts {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.charts-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-reset-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: hsl(var(--muted-foreground));
|
|
||||||
background: hsl(var(--muted) / 0.5);
|
|
||||||
border: 1px solid hsl(var(--border));
|
|
||||||
border-radius: var(--radius, 0.375rem);
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color 150ms ease,
|
|
||||||
background-color 150ms ease;
|
|
||||||
white-space: nowrap;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-reset-btn:hover {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
background: hsl(var(--muted));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import * as Select from '$lib/components/ui/select';
|
|
||||||
|
|
||||||
import { models } from '../../options';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
selectedModel: string;
|
|
||||||
onModelChange: (model: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { selectedModel, onModelChange }: Props = $props();
|
|
||||||
|
|
||||||
let modelLabel = $derived(
|
|
||||||
models.find((mo) => String(mo.value) === selectedModel)?.label ?? selectedModel
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mt-6 flex gap-6 md:mt-12">
|
|
||||||
<div class="relative w-1/2">
|
|
||||||
<Select.Root
|
|
||||||
name="model_selection"
|
|
||||||
type="single"
|
|
||||||
value={selectedModel}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
if (val) onModelChange(val);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger
|
|
||||||
aria-label="Forecast model selection"
|
|
||||||
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3"
|
|
||||||
>
|
|
||||||
{modelLabel}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { formatZoned } from '$lib/utils/date';
|
|
||||||
|
|
||||||
import type { FetchedDaily } from './types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
daily: FetchedDaily | null;
|
|
||||||
dayIndex: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { daily, dayIndex }: Props = $props();
|
|
||||||
|
|
||||||
let sunrise = $derived.by(() => {
|
|
||||||
if (!daily) return null;
|
|
||||||
const ts = daily.daily.sunrise[dayIndex];
|
|
||||||
return ts ? new Date(ts * 1000) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
let sunset = $derived.by(() => {
|
|
||||||
if (!daily) return null;
|
|
||||||
const ts = daily.daily.sunset[dayIndex];
|
|
||||||
return ts ? new Date(ts * 1000) : null;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if daily && sunrise && sunset}
|
|
||||||
<div class="sun-info">
|
|
||||||
<div class="sun-item">
|
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<span>{formatZoned(sunrise, daily.timezone, 'HH:mm')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="sun-item">
|
|
||||||
<svg class="fill-foreground" width="24px" height="24px">
|
|
||||||
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
|
|
||||||
</svg>
|
|
||||||
<span>{formatZoned(sunset, daily.timezone, 'HH:mm')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sun-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sun-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { MarkArea, 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[];
|
|
||||||
markAreas: MarkArea[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
+4
-21
@@ -1,33 +1,16 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
adapter: adapter(),
|
adapter: adapter({
|
||||||
// Pregenerate city pages to improve SEO during static build
|
fallback: '404.html'
|
||||||
prerender: {
|
})
|
||||||
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 ['*'];
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-27
@@ -3,34 +3,8 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
import { playwright } from '@vitest/browser-playwright';
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
import { defineConfig } from 'vitest/config';
|
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({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(), sveltekit(), viteServerConfig()],
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
expect: { requireAssertions: true },
|
expect: { requireAssertions: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user