619 lines
16 KiB
Svelte
619 lines
16 KiB
Svelte
<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>
|