remove echarts, use canvas

This commit is contained in:
Vincent van der Wal
2026-07-19 15:25:04 +02:00
parent 6d81af8df5
commit e031716ce6
37 changed files with 1483 additions and 2510 deletions
+8 -1
View File
@@ -63,7 +63,14 @@
<main
class={fullBleed ? 'flex-1 overflow-hidden' : 'flex-1 overflow-y-auto p-5 md:px-8 md:py-6'}
>
{@render children()}
{#if fullBleed}
{@render children()}
{:else}
<!-- cap the content width on very large screens -->
<div class="mx-auto w-full max-w-[1536px]">
{@render children()}
</div>
{/if}
</main>
</div>
</div>
+1 -1
View File
@@ -1,3 +1,3 @@
<svelte:head>
<title>Open-Meteo Weather</title>
<title>OMbrella</title>
</svelte:head>
+1 -1
View File
@@ -8,6 +8,6 @@ describe('/+page.svelte', () => {
render(Page);
const title = document.querySelector('title');
expect(title?.textContent).toBe('Open-Meteo Weather');
expect(title?.textContent).toBe('OMbrella');
});
});
+83 -105
View File
@@ -1,32 +1,24 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import { 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 { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { CHART_COLORS, CanvasChart, type ChartSeries, isColumnUnit } from '$lib/charts';
import {
type DaylightBand,
type EnsembleForecastResult,
type MarkArea,
fetchEnsembleForecast
} from '$lib/services/weather';
import { defaultParameters } from '../../options';
import type { PageData } from './$types';
import type * as echarts from 'echarts';
const CHART_GROUP = '14-day-ensemble';
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
@@ -34,9 +26,7 @@
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let chartComponents: CanvasChart[] = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
@@ -64,7 +54,7 @@
ensembleResult: EnsembleForecastResult;
timestamps: number[];
timezone: string;
markAreas: MarkArea[];
daylightBands: DaylightBand[];
}
let fetchedData: FetchedData | null = $state(null);
@@ -75,22 +65,8 @@
mounted = true;
});
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// chart components persist across refetches (options update in place), so
// the registry is never reset; only instances disposed because the chart
// count shrank are filtered out
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Data Fetching (only when params.hourly or params.models change) ───────
@@ -125,7 +101,7 @@
ensembleResult: result,
timestamps: result.timestamps,
timezone: result.timezone,
markAreas: result.markAreas
daylightBands: result.daylightBands
};
loading = false;
@@ -137,77 +113,74 @@
});
});
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
$effect(() => {
if (!fetchedData) return;
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
);
const { ensembleResult, timestamps, timezone, markAreas } = fetchedData;
const _showLegend = showLegend;
interface ChartDef {
title?: string;
subtitle?: string;
unit: string;
showCredit: boolean;
series: ChartSeries[];
}
const colors = getThemeColors();
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const { ensembleResult } = fetchedData;
const variables = params.hourly || [];
const defs: ChartDef[] = [];
for (let vi = 0; vi < variables.length; vi++) {
const variable = variables[vi];
const varData = ensembleResult.variables[variable];
if (!varData) continue;
const unit = varData.unit;
const { average, min: minValues, max: maxValues } = varData;
const isColumn = isColumnUnit(unit);
const series: ChartSeries[] = [];
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);
// Individual ensemble members: thin, low-alpha lines
for (let mi = 0; mi < varData.members.length; mi++) {
series.push({
name: `${variable}_member${String(mi).padStart(2, '0')}`,
type: 'line',
color: CHART_COLORS.memberLine,
data: varData.members[mi],
width: 1,
showInLegend: false
});
}
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
// Ensemble mean: bold dashed line on top
series.push({
name: variable + '_average',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: varData.average,
width: 4,
dashed: !isColumn
});
newOptions.push(option);
const isFirst = vi === 0;
const isLast = vi === variables.length - 1;
defs.push({
title: isFirst ? 'Model Spread' : undefined,
subtitle: isFirst
? `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
: undefined,
unit,
showCredit: isLast,
series
});
}
chartOptions = newOptions;
return defs;
});
</script>
@@ -221,20 +194,25 @@
</div>
{/if}
<ChartContainer
{loading}
chartCount={params.hourly?.length || 0}
chartHeight={showLegend ? 400 : 300}
>
{#each chartOptions as option, i (i)}
<EChart
{option}
notMerge
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
<ChartContainer {loading} chartCount={params.hourly?.length || 1} chartHeight={300}>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
{/each}
{/if}
</ChartContainer>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
@@ -1,28 +1,25 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { 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 { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
type MarkArea,
CHART_COLORS,
CanvasChart,
type ChartSeries,
SERIES_COLORS,
calculateAverage,
findUnit,
isColumnUnit
} from '$lib/charts';
import {
type DaylightBand,
type ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
@@ -32,19 +29,18 @@
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
import type { PageData } from './$types';
import type * as echarts from 'echarts';
const models = [modelsFlat];
const CHART_GROUP = 'model-compare';
// ─── 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 chartComponents: CanvasChart[] = $state([]);
let mounted = $state(false);
let loading = $state(true);
let loadError = $state<string | null>(null);
@@ -86,7 +82,7 @@
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
timezone: string;
markAreas: MarkArea[];
daylightBands: DaylightBand[];
timestamps: number[];
sunrise: number[];
sunset: number[];
@@ -100,22 +96,8 @@
mounted = true;
});
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// chart components persist across refetches (options update in place), so
// the registry is never reset; only instances disposed because the chart
// count shrank are filtered out
let liveCharts = $derived(chartInstances.filter((chart) => !chart.isDisposed()));
// components persist across refetches; entries are null while unmounted
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
// ─── Data Fetching (only when params.hourly or params.models change) ───────
@@ -149,7 +131,7 @@
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
timezone: result.timezone,
markAreas: result.markAreas,
daylightBands: result.daylightBands,
timestamps: result.timestamps,
sunrise: result.sunrise,
sunset: result.sunset
@@ -164,83 +146,77 @@
});
});
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
// ─── Chart Building (runs when fetchedData or the variable list changes) ────
$effect(() => {
if (!fetchedData) return;
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived.by(() =>
fetchedData ? fetchedData.timestamps.map((t) => t / 1000) : []
);
const { hourly: hourlyData, hourly_units, timezone, markAreas, timestamps } = fetchedData;
const _showLegend = showLegend;
interface ChartDef {
title?: string;
subtitle?: string;
unit: string;
showCredit: boolean;
series: ChartSeries[];
}
const colors = getThemeColors();
let chartDefs = $derived.by((): ChartDef[] => {
if (!fetchedData) return [];
const { hourly: hourlyData, hourly_units, timestamps } = fetchedData;
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
const variableCount = chartVariables.length;
const timeLength = timestamps.length;
const newOptions: Array<Record<string, unknown>> = [];
const defs: ChartDef[] = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = chartVariables[vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const isColumn = isColumnUnit(unit);
const series: Array<Record<string, unknown>> = [];
const series: ChartSeries[] = [];
let modelIndex = 0;
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
})
);
series.push({
name: model,
type: isColumn ? 'bar' : 'line',
color: SERIES_COLORS[modelIndex % SERIES_COLORS.length],
data: values as (number | null)[],
width: 2
});
modelIndex++;
}
const { average } = calculateAverage(hourlyData, variable, timeLength);
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);
}
series.push({
name: variable + '_average',
type: isColumn ? 'bar' : 'line',
color: CHART_COLORS.average,
data: average,
width: 4,
dashed: !isColumn
});
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,
defs.push({
title: isFirst ? 'Model Compare' : undefined,
subtitle: isFirst
? `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
: undefined,
unit,
showCredit: isLast,
colors,
timezone
series
});
newOptions.push(option);
}
chartOptions = newOptions;
return defs;
});
</script>
@@ -254,16 +230,25 @@
</div>
{/if}
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
{#each chartOptions as option, i (i)}
<EChart
{option}
notMerge
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
<ChartContainer {loading} chartCount={chartDefs.length || 1} chartHeight={300}>
{#if fetchedData}
{#each chartDefs as def, i (i)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={fetchedData.timezone}
series={def.series}
bands={fetchedData.daylightBands}
unit={def.unit}
title={def.title}
subtitle={def.subtitle}
showCredit={def.showCredit}
{showLegend}
height={300}
group={CHART_GROUP}
/>
{/each}
{/if}
</ChartContainer>
{#if fetchedData && !loading}
@@ -82,7 +82,7 @@
timezone: result.timezone,
timestamps: result.hourlyTimestamps,
hourlyDates: result.hourlyDates,
markAreas: result.markAreas
daylightBands: result.daylightBands
};
fetchedDaily = {
@@ -102,8 +102,8 @@
</script>
<svelte:head>
<title>Weather | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather" />
<title>OMbrella | Weather</title>
<link rel="canonical" href="https://ombrella.servert.ch/weather/week" />
<meta name="description" content="7-day weather forecast with detailed hourly data" />
</svelte:head>
@@ -40,7 +40,7 @@
</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">
<div class="flex gap-2 overflow-x-auto p-1 pb-2" style="scrollbar-width: thin">
{#if daily}
{#each daily.dailyDates as time, index (index)}
{@const selected = isSameDayInZone(time, selectedDay, daily.timezone)}
@@ -57,100 +57,90 @@
{@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
class="group relative flex min-w-[112px] max-w-[170px] flex-1 cursor-pointer flex-col items-center gap-1 rounded-2xl border px-2 pt-3 pb-2.5 transition-all duration-200
{selected
? 'scale-[1.03] border-primary bg-accent shadow-md'
: 'border-transparent bg-card hover:bg-accent'}"
? 'border-primary/50 bg-primary/5 shadow-md ring-2 ring-primary/40'
: 'border-border/60 bg-card shadow-xs hover:-translate-y-0.5 hover:border-border hover:shadow-md'}"
aria-pressed={selected}
onclick={() => onSelectDay(time, index)}
>
<!-- Day label -->
<span class="text-sm font-bold tracking-wide">
<span class="text-[13px] font-semibold tracking-wider">
{formatZoned(time, daily.timezone, 'EEE').toUpperCase()}
</span>
<span class="text-[11px] text-muted-foreground">
<span class="-mt-1 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>
<svg class="day-icon my-1 fill-foreground" width="46px" height="46px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wCode as keyof typeof weatherCodes
] ?? 'clear'}.svg#Layer_1"
></use>
</svg>
<!-- 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"
<div class="flex items-baseline gap-1.5">
<span
class="rounded-lg px-2 py-0.5 text-[15px] font-bold tabular-nums"
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}"
>
</span>
<span class="text-sm font-medium tabular-nums text-muted-foreground">
{tempMin.toFixed(0)}°
</div>
</span>
</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}">
<!-- Details -->
<div class="mt-1.5 flex w-full flex-col gap-1 border-t border-border/50 px-1 pt-1.5">
<!-- Sunshine -->
<div class="flex w-full items-center gap-1.5">
<svg class="shrink-0" width="13px" height="13px" 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-1 flex-1 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all"
style="width: {sunPct}%; background-color: {sunColor}"
></div>
</div>
<span class="text-[10px] font-medium text-muted-foreground">
<span class="text-[10px] font-medium tabular-nums 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>
<!-- Precipitation + wind -->
<div
class="flex w-full items-center justify-center gap-2.5 text-[11px] tabular-nums text-foreground/80"
>
<span class="inline-flex items-center gap-0.5">
<svg class="shrink-0 fill-foreground/70" width="13px" height="13px">
<use xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"></use>
</svg>
{Number(precipSum ?? 0).toFixed(
precipSum >= 10 ? 0 : 1
)}{units.precipitation_unit === 'mm' ? ' mm' : "'"}
</span>
</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>
<span class="inline-flex items-center gap-0.5">
{#if windDir != null && !isNaN(windDir)}
<span
class="inline-flex shrink-0"
style="transform: {getWindArrowRotation(windDir)}"
>
<svg class="fill-foreground/70" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg>
</span>
{:else}
<svg class="shrink-0 fill-foreground/70" width="16px" height="16px">
<use xlink:href="/images/weather-icons/wi-strong-wind.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>
{/if}
{windMax?.toFixed(0) ?? '-'}<span class="text-muted-foreground"
>-{gustMax?.toFixed(0) ?? '-'}</span
>
@@ -167,12 +157,12 @@
<style>
@media (max-width: 768px) {
button {
min-width: 92px !important;
min-width: 96px !important;
}
button :global(svg[width='48px']) {
width: 40px;
height: 40px;
button :global(.day-icon) {
width: 38px;
height: 38px;
}
}
</style>
@@ -189,27 +189,29 @@
{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
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
role="group"
aria-label="Hourly interval"
>
{#each [3, 1] as interval (interval)}
<button
class="cursor-pointer rounded-md px-3 py-1 transition-colors {hourlyInterval === interval
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
aria-pressed={hourlyInterval === interval}
onclick={() => (hourlyInterval = interval as 1 | 3)}
>
{interval}h
</button>
{/each}
</div>
</div>
{#if cellData.length > 0}
{@const hourly = data.hourly}
{@const iconPx = is3h ? 40 : 26}
<div class="overflow-hidden rounded-lg border border-border">
<div class="overflow-hidden rounded-xl border border-border/70 bg-card shadow-xs">
<table class="w-full table-fixed border-collapse whitespace-nowrap">
<caption class="sr-only">Hourly weather details for {locationName}</caption>
<colgroup>
@@ -425,7 +427,7 @@
<style>
tr {
border-top: 1px solid hsl(var(--border));
border-top: 1px solid hsl(var(--border) / 0.6);
}
/* ── Base cell ──────────────────────────────────────────── */
@@ -434,7 +436,8 @@
text-align: center;
font-size: 13px;
font-weight: 500;
border-right: 1px solid hsl(var(--border) / 0.2);
font-variant-numeric: tabular-nums;
border-right: 1px solid hsl(var(--border) / 0.15);
overflow: hidden;
}
@@ -444,7 +447,7 @@
.cell.now {
font-weight: 700;
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
}
/* ── Row header ─────────────────────────────────────────── */
@@ -453,8 +456,8 @@
text-align: center;
font-weight: 600;
font-size: 11px;
background: hsl(var(--background));
border-right: 2px solid hsl(var(--border));
background: hsl(var(--muted) / 0.35);
border-right: 1px solid hsl(var(--border));
white-space: nowrap;
overflow: hidden;
}
@@ -473,7 +476,7 @@
}
.precip-cell.now {
box-shadow: inset 0 0 0 2px hsl(var(--destructive) / 0.45);
box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.55);
}
.precip-bar {
@@ -2,13 +2,11 @@
import { fade } from 'svelte/transition';
import { formatZoned, getRelativeDayLabel } from '$lib/utils/date';
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import { echarts } from '$lib/components/charts/echarts';
import '$lib/components/charts/echarts.css';
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
import { CanvasChart, type ChartSeries } from '$lib/charts';
import { getColor } from '../../utils/colors';
import {
type FetchedHourly,
type WeatherUnits,
@@ -18,8 +16,6 @@
getWindUnit
} from './types';
import type { ECharts } from 'echarts';
interface Props {
data: FetchedHourly;
selectedDay: Date;
@@ -31,15 +27,18 @@
let { data, selectedDay, units, loading, onResetZoom }: Props = $props();
const CHART_GROUP = 'week-meteogram';
const MS_PER_DAY = 24 * 3600 * 1000;
const SECONDS_PER_DAY = 24 * 3600;
let showCharts = $state(false);
let chartComponents: EChart[] = $state([]);
let chartInstances: ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let chartComponents: CanvasChart[] = $state([]);
// Timestamps from the service are in milliseconds; CanvasChart uses seconds.
let timestampsSec = $derived(data.timestamps.map((t) => t / 1000));
let liveCharts = $derived(chartComponents.filter((chart) => chart != null));
export function scrollToDay(day: Date): void {
if (chartInstances.length === 0 || !data) return;
if (!data || liveCharts.length === 0) return;
const tz = data.timezone;
const targetDayStr = formatZoned(day, tz, 'yyyy-MM-dd');
@@ -49,436 +48,150 @@
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 });
}
}
const dayStart = data.timestamps[firstHourIdx] / 1000;
// Charts share the group, so setting the range on one syncs all of them
liveCharts[0].setRange(dayStart, dayStart + SECONDS_PER_DAY);
}
function resetZoom(): void {
for (const chart of chartInstances) {
if (chart && !chart.isDisposed()) {
chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100 });
}
}
liveCharts[0]?.resetRange();
onResetZoom?.();
}
function handleChartReady(chart: ECharts): void {
chart.group = CHART_GROUP;
chartInstances = [...chartInstances, chart];
if (chartInstances.length === 3) {
echarts.connect(CHART_GROUP);
// Zoom to the selected day once all three charts are mounted
let scrolledOnMount = false;
$effect(() => {
if (!showCharts) {
scrolledOnMount = false;
return;
}
if (!scrolledOnMount && liveCharts.length === 3 && data) {
scrolledOnMount = true;
requestAnimationFrame(() => scrollToDay(selectedDay));
}
});
// ─── Series Building ────────────────────────────────────────────────────────
let tempUnit = $derived(getTempUnit(units));
let precipUnit = $derived(getPrecipUnit(units));
let windUnit = $derived(getWindUnit(units));
interface ChartDef {
title: string;
unit: string;
unitRight?: string;
yMin?: number;
yMinRight?: number;
yMaxRight?: number;
invertRight?: boolean;
showCredit?: boolean;
series: ChartSeries[];
}
$effect(() => {
if (!data) return;
let chartDefs = $derived.by((): ChartDef[] => {
if (!data) return [];
const { hourly, timestamps, markAreas } = data;
const colors = getThemeColors();
const tempUnit = getTempUnit(units);
const precipUnit = getPrecipUnit(units);
const windUnit = getWindUnit(units);
const { hourly } = data;
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 }
],
const tempChart: ChartDef = {
title: 'Temperature & Cloud Cover',
unit: tempUnit,
// Hidden inverted right axis (0-250) so cloud cover hangs from the top,
// occupying at most the upper 40% of the plot
yMinRight: 0,
yMaxRight: 250,
invertRight: true,
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
color: 'rgb(150, 150, 150)',
data: hourly.cloud_cover,
width: 0,
fill: true,
fillOpacity: 0.25,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
},
...annotations()
],
textStyle: { color: colors.text }
{
name: 'Temperature',
type: 'line',
color: '#ef6c00',
data: hourly.temperature_2m,
width: 3,
fill: true,
fillOpacity: 0.2,
format: (v) => `${v.toFixed(1)} ${tempUnit}`
}
]
};
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 }
}
],
const precipChart: ChartDef = {
title: 'Precipitation & Probability',
unit: precipUnit,
unitRight: '%',
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
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
color: 'rgba(30, 136, 229, 0.8)',
data: hourly.precipitation,
format: (v) => `${v.toFixed(1)} ${precipUnit}`
},
{
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 }
color: '#5c6bc0',
data: hourly.precipitation_probability,
width: 2,
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
}
]
};
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 }
}
],
const windChart: ChartDef = {
title: 'Wind Speed & Humidity',
unit: windUnit,
unitRight: '%',
yMin: 0,
yMinRight: 0,
yMaxRight: 100,
showCredit: true,
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
color: '#26a69a',
data: hourly.windspeed_10m,
width: 2,
fill: true,
fillOpacity: 0.15,
format: (v, i) => {
const dir = hourly.winddirection_10m[i];
const dirLabel = dir != null && !isNaN(dir) ? ` (${getWindDirectionLabel(dir)})` : '';
return `${v.toFixed(0)} ${windUnit}${dirLabel}`;
}
},
{
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'
color: '#8d6e63',
data: hourly.relative_humidity_2m,
width: 2,
dashed: true,
axis: 'right',
format: (v) => `${v.toFixed(0)}%`
}
],
textStyle: { color: colors.text }
]
};
chartOptions = [tempOption, precipOption, windOption];
return [tempChart, precipChart, windChart];
});
</script>
@@ -520,19 +233,30 @@
</div>
<ChartContainer {loading} chartCount={3} chartHeight={300}>
{#each chartOptions as option, i (i)}
<EChart
{option}
notMerge
height={i === 2 ? '320px' : '300px'}
onChartReady={handleChartReady}
{#each chartDefs as def, i (def.title)}
<CanvasChart
bind:this={chartComponents[i]}
timestamps={timestampsSec}
timezone={data.timezone}
series={def.series}
bands={data.daylightBands}
unit={def.unit}
unitRight={def.unitRight}
yMin={def.yMin}
yMinRight={def.yMinRight}
yMaxRight={def.yMaxRight}
invertRight={def.invertRight}
title={def.title}
showCredit={def.showCredit}
showLegend
height={300}
group={CHART_GROUP}
/>
{/each}
</ChartContainer>
<div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="week-forecast" />
<ChartToolbar charts={liveCharts} fileName="week-forecast" />
</div>
</div>
{/if}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { MarkArea, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
import type { DaylightBand, WeekDailyData, WeekHourlyData } from '$lib/services/weather';
export interface WeatherUnits {
temperature_unit: string;
@@ -12,7 +12,7 @@ export interface FetchedHourly {
timezone: string;
timestamps: number[];
hourlyDates: Date[];
markAreas: MarkArea[];
daylightBands: DaylightBand[];
}
export interface FetchedDaily {