feat: migrate to apache echarts (#4)
Co-authored-by: terraputix <terraputix@mailbox.org> Reviewed-on: useweb/ombrella#4
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -209,7 +209,7 @@
|
||||
</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)}
|
||||
{#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: 'Apache ECharts 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"
|
||||
|
||||
@@ -2,8 +2,6 @@ import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const load = (async () => {
|
||||
throw redirect(303, '/weather/week/');
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,28 +1,46 @@
|
||||
<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 {
|
||||
buildAverageSeries,
|
||||
buildCurrentTimeSeries,
|
||||
buildDaylightMarkAreas,
|
||||
buildDaylightSeries,
|
||||
buildSpreadSeries,
|
||||
calculateAverage,
|
||||
calculateSpread,
|
||||
composeChartOption,
|
||||
convertTimestamps,
|
||||
findUnit,
|
||||
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 '../compare/highcharts.css';
|
||||
import { defaultParameters } from './options';
|
||||
import { defaultParameters } from '../options';
|
||||
|
||||
let node: HTMLElement;
|
||||
let chart: any;
|
||||
let Highcharts = $state<typeof import('highcharts') | null>(null);
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||
|
||||
let showLegend = $state(false);
|
||||
let averageOnly = $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);
|
||||
|
||||
const location = get(storedLocation);
|
||||
|
||||
// Local component state for chart configuration
|
||||
let params = $state({
|
||||
latitude: [52.52],
|
||||
longitude: [13.41],
|
||||
@@ -31,301 +49,191 @@
|
||||
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);
|
||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
interface FetchedData {
|
||||
hourly: Record<string, unknown>;
|
||||
hourly_units: Record<string, string>;
|
||||
utc_offset_seconds: number;
|
||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const loadData = async () => {
|
||||
count = 0;
|
||||
if (Highcharts) {
|
||||
node.replaceChildren();
|
||||
let fetchedData: FetchedData | null = $state(null);
|
||||
|
||||
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();
|
||||
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
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 loadData = async () => {
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
const [dataDaily, dataReq] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
|
||||
),
|
||||
fetch(
|
||||
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&forecast_days=14`
|
||||
)
|
||||
]);
|
||||
|
||||
const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
|
||||
|
||||
let markAreas: FetchedData['markAreas'] = [];
|
||||
|
||||
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
||||
markAreas = buildDaylightMarkAreas(
|
||||
wd.daily.sunrise,
|
||||
wd.daily.sunset,
|
||||
data.utc_offset_seconds
|
||||
);
|
||||
}
|
||||
|
||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||
|
||||
fetchedData = {
|
||||
hourly: data.hourly,
|
||||
hourly_units: data.hourly_units,
|
||||
utc_offset_seconds: data.utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
};
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
|
||||
$effect(() => {
|
||||
if (!fetchedData) return;
|
||||
|
||||
const {
|
||||
hourly: hourlyData,
|
||||
hourly_units,
|
||||
utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
} = fetchedData;
|
||||
const _showLegend = showLegend;
|
||||
|
||||
const colors = getThemeColors();
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const timeLength = (hourlyData.time as number[]).length;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![vi];
|
||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||
|
||||
const { average } = calculateAverage(hourlyData, variable, timeLength);
|
||||
const { minValues, maxValues } = calculateSpread(hourlyData, variable, timeLength);
|
||||
|
||||
const series: Array<Record<string, unknown>> = [];
|
||||
|
||||
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||
(min, index) =>
|
||||
[timestamps[index], min ?? 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({ utcOffsetSeconds: utc_offset_seconds }));
|
||||
|
||||
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 },
|
||||
legend: {
|
||||
show: _showLegend,
|
||||
data: [variable + '_average']
|
||||
},
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend: _showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: false,
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
});
|
||||
</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>
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
|
||||
<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>
|
||||
<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,7 +0,0 @@
|
||||
// Default configuration for 14-day ensemble forecast charts
|
||||
export const defaultParameters = {
|
||||
timeformat: 'iso8601',
|
||||
wind_speed_unit: 'kmh',
|
||||
temperature_unit: 'celsius',
|
||||
precipitation_unit: 'mm'
|
||||
};
|
||||
@@ -3,27 +3,45 @@
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
import { storedLocation } from '$lib/stores/settings';
|
||||
|
||||
import {
|
||||
buildAverageSeries,
|
||||
buildCurrentTimeSeries,
|
||||
buildDaylightMarkAreas,
|
||||
buildDaylightSeries,
|
||||
buildModelSeries,
|
||||
calculateAverage,
|
||||
composeChartOption,
|
||||
convertTimestamps,
|
||||
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 { hourly, models as modelsFlat } from '../options';
|
||||
import './highcharts.css';
|
||||
import { defaultParameters } from './options';
|
||||
import { defaultParameters } from '../options';
|
||||
|
||||
import type * as echarts from 'echarts';
|
||||
|
||||
// Wrap models in array to match template expectation of nested arrays like hourly
|
||||
const models = [modelsFlat];
|
||||
|
||||
let node: HTMLElement;
|
||||
let chart: any;
|
||||
let Highcharts = $state<typeof import('highcharts') | null>(null);
|
||||
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
|
||||
|
||||
let showLegend = $state(false);
|
||||
let averageOnly = $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);
|
||||
|
||||
const location = get(storedLocation);
|
||||
|
||||
@@ -41,300 +59,210 @@
|
||||
]
|
||||
});
|
||||
|
||||
let count = $state(0);
|
||||
onMount(async () => {
|
||||
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
|
||||
Highcharts = (await import('highcharts')).default;
|
||||
// ─── Cached API Response ────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
interface FetchedData {
|
||||
hourly: Record<string, unknown>;
|
||||
hourly_units: Record<string, string>;
|
||||
utc_offset_seconds: number;
|
||||
markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]>;
|
||||
timestamps: 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 loadData = async () => {
|
||||
count = 0;
|
||||
if (Highcharts) {
|
||||
node.replaceChildren();
|
||||
loading = true;
|
||||
chartInstances = [];
|
||||
chartComponents = [];
|
||||
|
||||
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();
|
||||
const dataReq = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${hourlyVars.join(',')}&models=${modelList.join(',')}&timeformat=unixtime&daily=sunset,sunrise`
|
||||
);
|
||||
const data = await dataReq.json();
|
||||
|
||||
let markAreas: FetchedData['markAreas'] = [];
|
||||
|
||||
if ('daily' in data) {
|
||||
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
|
||||
};
|
||||
});
|
||||
}
|
||||
const sunriseKey = 'sunrise_' + dailyFirstModelKey;
|
||||
const sunsetKey = 'sunset_' + dailyFirstModelKey;
|
||||
|
||||
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);
|
||||
if (sunriseKey in data.daily && sunsetKey in data.daily) {
|
||||
markAreas = buildDaylightMarkAreas(
|
||||
data.daily[sunriseKey],
|
||||
data.daily[sunsetKey],
|
||||
data.utc_offset_seconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||
|
||||
fetchedData = {
|
||||
hourly: data.hourly,
|
||||
hourly_units: data.hourly_units,
|
||||
utc_offset_seconds: data.utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
};
|
||||
|
||||
loading = false;
|
||||
};
|
||||
|
||||
loadData();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
|
||||
|
||||
$effect(() => {
|
||||
if (!fetchedData) return;
|
||||
|
||||
const {
|
||||
hourly: hourlyData,
|
||||
hourly_units,
|
||||
utc_offset_seconds,
|
||||
markAreas,
|
||||
timestamps
|
||||
} = fetchedData;
|
||||
const _showLegend = showLegend;
|
||||
|
||||
const colors = getThemeColors();
|
||||
const variableCount = params.hourly?.length || 0;
|
||||
const timeLength = (hourlyData.time as number[]).length;
|
||||
const newOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (let vi = 0; vi < variableCount; vi++) {
|
||||
const variable = params.hourly![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({ utcOffsetSeconds: utc_offset_seconds }));
|
||||
|
||||
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 ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||
}
|
||||
: null,
|
||||
tooltip: { unit },
|
||||
legend: { show: _showLegend },
|
||||
grid: {
|
||||
hasTitle: isFirst,
|
||||
hasSubtitle: isFirst,
|
||||
showLegend: _showLegend
|
||||
},
|
||||
yAxis: { unit },
|
||||
series,
|
||||
toolbox: false,
|
||||
showCredit: isLast,
|
||||
colors
|
||||
});
|
||||
|
||||
newOptions.push(option);
|
||||
}
|
||||
|
||||
chartOptions = newOptions;
|
||||
});
|
||||
</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]"
|
||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||
|
||||
<ChartContainer
|
||||
{loading}
|
||||
chartCount={params.hourly?.length || 0}
|
||||
chartHeight={showLegend ? 400 : 300}
|
||||
>
|
||||
<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>
|
||||
{#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="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>
|
||||
|
||||
<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>
|
||||
<!-- ─── 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-[5px]">
|
||||
<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"
|
||||
>
|
||||
@@ -362,8 +290,7 @@
|
||||
return item !== value;
|
||||
});
|
||||
} else if (params.models) {
|
||||
params.models.push(value);
|
||||
params.models = params.models;
|
||||
params.models = [...params.models, value];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -378,7 +305,8 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- HOURLY -->
|
||||
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
|
||||
|
||||
<div class="mt-6 md:mt-12">
|
||||
<div class="flex">
|
||||
<a href="#hourly_weather_variables"
|
||||
@@ -387,7 +315,7 @@
|
||||
</h2></a
|
||||
>
|
||||
{#if params.hourly && params.hourly.length > 0}
|
||||
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
|
||||
<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"
|
||||
>
|
||||
@@ -416,8 +344,7 @@
|
||||
return item !== value;
|
||||
});
|
||||
} else if (params.hourly) {
|
||||
params.hourly.push(value);
|
||||
params.hourly = params.hourly;
|
||||
params.hourly = [...params.hourly, value];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
// Default configuration for weather comparison charts
|
||||
export const defaultParameters = {
|
||||
timeformat: 'iso8601',
|
||||
wind_speed_unit: 'kmh',
|
||||
temperature_unit: 'celsius',
|
||||
precipitation_unit: 'mm'
|
||||
};
|
||||
@@ -8,8 +8,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const load = (async () => {
|
||||
const location = get(storedLocation);
|
||||
const locationRoute = geoLocationNameToRoute(location.name);
|
||||
|
||||
@@ -6,8 +6,6 @@ import { geoLocationNameToRoute } from '$lib/utils/meteo';
|
||||
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export const load: PageLoad = async (event) => {
|
||||
const urlLocation = event.params.location;
|
||||
let urlLocationSplit, urlLocationName, urlLocationId;
|
||||
|
||||
Reference in New Issue
Block a user