feat: canvas to echarts (#5)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/ombrella#5
This commit is contained in:
2026-02-16 00:33:58 +01:00
co-authored by terraputix
parent 4e3ccf1c06
commit 53e5fb6538
19 changed files with 2449 additions and 1149 deletions
@@ -0,0 +1,593 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import * as echarts from 'echarts';
import { buildCurrentTimeSeries, buildDaylightSeries, getThemeColors } from '$lib/utils/echarts';
import { pad } from '$lib/utils/index';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { getColor } from '../../utils/colors';
import {
type FetchedHourly,
type WeatherUnits,
getDayLabel,
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;
const today = new Date();
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) return;
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
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, utc_offset_seconds, 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({ utcOffsetSeconds: utc_offset_seconds }));
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 },
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: Record<string, unknown>[]) => string
): Record<string, unknown> => ({
trigger: 'axis',
axisPointer: {
type: 'cross',
animation: false,
label: {
backgroundColor: colors.tooltipBg,
color: colors.text,
borderColor: colors.tooltipBorder,
borderWidth: 1
}
},
backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder,
textStyle: { color: colors.text },
formatter
});
const formatDate = (ts: number): string => {
const date = new Date(ts);
return `<b>${date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })} ${pad(date.getHours())}:${pad(date.getMinutes())}</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">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
<small>
{getDayLabel(selectedDay, today) !==
selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })
? ` (${getDayLabel(selectedDay, today)})`
: ''}
</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>