457 lines
11 KiB
Svelte
457 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { get } from 'svelte/store';
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import * as echarts from 'echarts';
|
|
|
|
import { storedLocation } from '$lib/stores/settings';
|
|
|
|
import { Label } from '$lib/components/ui/label';
|
|
import { Switch } from '$lib/components/ui/switch';
|
|
|
|
import '../compare/echarts.css';
|
|
import { defaultParameters } from './options';
|
|
|
|
let node: HTMLElement;
|
|
let charts: echarts.ECharts[] = [];
|
|
let resizeObservers: ResizeObserver[] = [];
|
|
let mounted = $state(false);
|
|
|
|
let showLegend = $state(false);
|
|
let averageOnly = $state(false);
|
|
|
|
const location = get(storedLocation);
|
|
|
|
// Local component state for chart configuration
|
|
let params = $state({
|
|
latitude: [52.52],
|
|
longitude: [13.41],
|
|
...defaultParameters,
|
|
hourly: ['temperature_2m'],
|
|
models: ['gfs_seamless']
|
|
});
|
|
|
|
let count = $state(0);
|
|
|
|
function isDarkMode(): boolean {
|
|
if (typeof document === 'undefined') return false;
|
|
return (
|
|
document.documentElement.classList.contains('dark') ||
|
|
document.documentElement.getAttribute('data-theme') === 'dark' ||
|
|
(window.matchMedia &&
|
|
window.matchMedia('(prefers-color-scheme: dark)').matches &&
|
|
document.documentElement.getAttribute('data-theme') !== 'light')
|
|
);
|
|
}
|
|
|
|
function getTextColor(): string {
|
|
return isDarkMode() ? '#e5e7eb' : '#374151';
|
|
}
|
|
|
|
function getAxisLineColor(): string {
|
|
return isDarkMode() ? 'rgba(229, 231, 235, 0.3)' : 'rgba(55, 65, 81, 0.3)';
|
|
}
|
|
|
|
function getSplitLineColor(): string {
|
|
return isDarkMode() ? 'rgba(229, 231, 235, 0.1)' : 'rgba(55, 65, 81, 0.1)';
|
|
}
|
|
|
|
onMount(() => {
|
|
mounted = true;
|
|
});
|
|
|
|
$effect(() => {
|
|
const loadData = async () => {
|
|
count = 0;
|
|
if (mounted) {
|
|
// Dispose existing charts and observers
|
|
resizeObservers.forEach((ro) => ro.disconnect());
|
|
resizeObservers = [];
|
|
charts.forEach((chart) => {
|
|
if (chart) {
|
|
chart.dispose();
|
|
}
|
|
});
|
|
charts = [];
|
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
|
node.replaceChildren();
|
|
|
|
const dataDaily = await fetch(
|
|
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
|
|
);
|
|
const wd = await dataDaily.json();
|
|
|
|
const dataReq = await fetch(
|
|
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&forecast_days=14`
|
|
);
|
|
const data = await dataReq.json();
|
|
|
|
// Create day/night plot bands as markArea data
|
|
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
|
[];
|
|
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
|
let rise = wd.daily.sunrise;
|
|
let set = wd.daily.sunset;
|
|
markAreas = rise.map(function (r: number, i: number) {
|
|
return [
|
|
{
|
|
xAxis: (r + data.utc_offset_seconds) * 1000,
|
|
itemStyle: {
|
|
color: 'rgba(255, 255, 194, 0.3)'
|
|
}
|
|
},
|
|
{
|
|
xAxis: (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);
|
|
|
|
const textColor = getTextColor();
|
|
const axisLineColor = getAxisLineColor();
|
|
const splitLineColor = getSplitLineColor();
|
|
|
|
for (let variable of params.hourly || []) {
|
|
const chartDiv = document.createElement('div');
|
|
chartDiv.style.width = '100%';
|
|
chartDiv.style.height = showLegend ? '400px' : '300px';
|
|
|
|
// Append to DOM BEFORE echarts.init so it can measure dimensions
|
|
// eslint-disable-next-line svelte/no-dom-manipulating
|
|
node.appendChild(chartDiv);
|
|
|
|
let unit: string = '';
|
|
|
|
const series: Array<Record<string, unknown>> = [];
|
|
let average = new Array(data.hourly.time.length).fill(0);
|
|
let averageCount = new Array(data.hourly.time.length).fill(0);
|
|
|
|
const timestamps = data.hourly.time.map(
|
|
(t: number) => (t + data.utc_offset_seconds) * 1000
|
|
);
|
|
|
|
for (let [model, values] of Object.entries(data.hourly)) {
|
|
if (model === 'time') {
|
|
continue;
|
|
}
|
|
if (model.startsWith(variable)) {
|
|
for (let [index, val] of (values as number[]).entries()) {
|
|
if (val !== null && val !== undefined) {
|
|
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];
|
|
}
|
|
}
|
|
|
|
// Calculate average
|
|
for (let [index, val] of average.entries()) {
|
|
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
|
}
|
|
|
|
// Create min-max area data
|
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
|
(min: number, index: number) => [timestamps[index], min, maxValues[index]]
|
|
);
|
|
|
|
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
|
|
|
|
// Add spread series (lower bound)
|
|
series.push({
|
|
name: variable + '_spread',
|
|
type: 'line',
|
|
data: spreadData.map((d) => [d[0], d[1]]),
|
|
areaStyle: {
|
|
color: 'rgba(173, 216, 230, 0.3)',
|
|
origin: 'auto'
|
|
},
|
|
lineStyle: {
|
|
width: 0
|
|
},
|
|
showSymbol: false,
|
|
stack: 'spread',
|
|
smooth: true,
|
|
z: 1
|
|
});
|
|
|
|
// Add spread series (upper bound delta)
|
|
series.push({
|
|
name: variable + '_spread_max',
|
|
type: 'line',
|
|
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
|
|
areaStyle: {
|
|
color: 'rgba(173, 216, 230, 0.3)',
|
|
origin: 'auto'
|
|
},
|
|
lineStyle: {
|
|
width: 0
|
|
},
|
|
showSymbol: false,
|
|
stack: 'spread',
|
|
smooth: true,
|
|
z: 1
|
|
});
|
|
|
|
// Add average line
|
|
const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]);
|
|
|
|
series.push({
|
|
name: variable + '_average',
|
|
type: isColumn ? 'bar' : 'line',
|
|
data: averageData,
|
|
smooth: !isColumn,
|
|
showSymbol: false,
|
|
lineStyle: {
|
|
type: 'dashed',
|
|
width: 4,
|
|
color: '#5e5e5e'
|
|
},
|
|
itemStyle: {
|
|
color: '#5e5e5e'
|
|
},
|
|
emphasis: {
|
|
lineStyle: {
|
|
width: 6
|
|
}
|
|
},
|
|
barMaxWidth: 5,
|
|
z: 10
|
|
});
|
|
|
|
// Add current time markLine via a helper series
|
|
series.push({
|
|
name: 'Current Time',
|
|
type: 'line',
|
|
data: [],
|
|
markLine: {
|
|
silent: true,
|
|
symbol: 'none',
|
|
data: [
|
|
{
|
|
xAxis: Date.now() + data.utc_offset_seconds * 1000,
|
|
lineStyle: {
|
|
color: 'red',
|
|
width: 2
|
|
},
|
|
label: {
|
|
show: false
|
|
}
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
// Add day/night bands via markArea
|
|
if (markAreas.length > 0) {
|
|
series.push({
|
|
name: 'Daylight',
|
|
type: 'line',
|
|
data: [],
|
|
markArea: {
|
|
silent: true,
|
|
data: markAreas
|
|
}
|
|
});
|
|
}
|
|
|
|
const option: Record<string, unknown> = {
|
|
title: {
|
|
text: count === 0 ? 'Model Spread' : '',
|
|
left: 'left',
|
|
textStyle: {
|
|
fontWeight: 'normal',
|
|
color: textColor
|
|
},
|
|
...(count === 0
|
|
? {
|
|
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`,
|
|
subtextStyle: {
|
|
fontWeight: 'normal',
|
|
color: textColor
|
|
}
|
|
}
|
|
: {})
|
|
},
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
axisPointer: {
|
|
type: 'cross',
|
|
animation: false
|
|
},
|
|
valueFormatter: (value: number) => {
|
|
if (value === null || value === undefined) return '-';
|
|
return value.toFixed(1) + ' ' + unit;
|
|
}
|
|
},
|
|
legend: {
|
|
show: showLegend,
|
|
bottom: 0,
|
|
type: 'scroll',
|
|
data: [variable + '_average'],
|
|
textStyle: {
|
|
color: textColor
|
|
}
|
|
},
|
|
grid: {
|
|
left: 60,
|
|
right: 10,
|
|
top: count === 0 ? 80 : 40,
|
|
bottom: showLegend ? 60 : 40
|
|
},
|
|
xAxis: {
|
|
type: 'time',
|
|
splitLine: {
|
|
show: false
|
|
},
|
|
axisLine: {
|
|
lineStyle: {
|
|
color: axisLineColor
|
|
}
|
|
},
|
|
axisLabel: {
|
|
color: textColor
|
|
}
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
name: unit,
|
|
nameTextStyle: {
|
|
color: textColor
|
|
},
|
|
axisLine: {
|
|
show: false
|
|
},
|
|
axisLabel: {
|
|
color: textColor
|
|
},
|
|
splitLine: {
|
|
lineStyle: {
|
|
color: splitLineColor
|
|
}
|
|
}
|
|
},
|
|
series: series,
|
|
textStyle: {
|
|
color: textColor
|
|
}
|
|
};
|
|
|
|
// Add credits for last chart
|
|
if (count === (params.hourly?.length || 0) - 1) {
|
|
option.graphic = [
|
|
{
|
|
type: 'text',
|
|
right: 10,
|
|
bottom: 5,
|
|
style: {
|
|
text: 'Open-Meteo.com',
|
|
fontSize: 10,
|
|
fill: textColor,
|
|
opacity: 0.5
|
|
},
|
|
onclick: function () {
|
|
window.open('https://open-meteo.com', '_blank');
|
|
},
|
|
cursor: 'pointer'
|
|
}
|
|
];
|
|
}
|
|
|
|
const chart = echarts.init(chartDiv, null, { renderer: 'canvas' });
|
|
charts.push(chart);
|
|
chart.setOption(option);
|
|
|
|
// Handle responsive resize
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
chart.resize();
|
|
});
|
|
resizeObserver.observe(chartDiv);
|
|
resizeObservers.push(resizeObserver);
|
|
|
|
count++;
|
|
}
|
|
}
|
|
};
|
|
loadData();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
resizeObservers.forEach((ro) => ro.disconnect());
|
|
resizeObservers = [];
|
|
charts.forEach((chart) => {
|
|
chart.dispose();
|
|
});
|
|
charts = [];
|
|
});
|
|
</script>
|
|
|
|
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
|
<div
|
|
class="container-wrapper relative -mx-6 md:mx-0"
|
|
style="min-height: {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"
|
|
>
|
|
<svg
|
|
class="lucide lucide-loader-circle animate-spin"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="40"
|
|
height="40"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
</svg>
|
|
<span class="hidden">Loading...</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="">
|
|
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
|
|
<div class="flex gap-2">
|
|
<Switch
|
|
id="show_legend"
|
|
name="Show legend"
|
|
bind:checked={showLegend}
|
|
onCheckedChange={() => {
|
|
params.hourly = params.hourly;
|
|
}}
|
|
/>
|
|
<Label for="show_legend" class="mb-0.5 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-0.5 cursor-pointer text-lg">Average only</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|