better download

This commit is contained in:
terraputix
2026-02-15 18:51:44 +01:00
parent e12add9ac3
commit adbbb462bf
5 changed files with 469 additions and 345 deletions
+119 -117
View File
@@ -27,7 +27,11 @@
import type * as echarts from 'echarts';
// ─── State ──────────────────────────────────────────────────────────────────
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
@@ -35,12 +39,8 @@
let mounted = $state(false);
let loading = $state(true);
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],
@@ -49,6 +49,18 @@
models: ['gfs_seamless']
});
// ─── Cached API Response ────────────────────────────────────────────────────
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(() => {
@@ -67,32 +79,31 @@
chartInstances = [...chartInstances, chart];
}
// ─── Data Loading & Chart Building ──────────────────────────────────────────
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const loadData = async () => {
if (!mounted) return;
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
loading = true;
chartInstances = [];
chartComponents = [];
// Fetch sunrise/sunset from the standard forecast API
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 [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`
)
]);
// Fetch ensemble data
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();
const [wd, data] = await Promise.all([dataDaily.json(), dataReq.json()]);
// ─── Compute daylight bands ─────────────────────────────────────
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
let markAreas: FetchedData['markAreas'] = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
markAreas = buildDaylightMarkAreas(
@@ -102,90 +113,99 @@
);
}
// ─── Build chart options for each variable ──────────────────────
const colors = getThemeColors();
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const unit = findUnit(data.hourly_units, data.hourly, variable);
const timeLength = data.hourly.time.length;
fetchedData = {
hourly: data.hourly,
hourly_units: data.hourly_units,
utc_offset_seconds: data.utc_offset_seconds,
markAreas,
timestamps
};
// ─── Calculate average and spread ───────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
// ─── Build series ───────────────────────────────────────────
const series: Array<Record<string, unknown>> = [];
// Ensemble spread (min/max area)
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 }));
// Average line
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// Current time marker
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
// Daylight bands
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
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
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `14-day-forecast-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
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>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -211,27 +231,9 @@
<ChartToolbar charts={chartInstances} fileName="14-day-forecast">
{#snippet controls()}
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<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>
<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>
{/snippet}
</ChartToolbar>
</div>
+118 -119
View File
@@ -29,10 +29,13 @@
import type * as echarts from 'echarts';
// Wrap models in array to match template expectation of nested arrays like hourly
const models = [modelsFlat];
// ─── State ──────────────────────────────────────────────────────────────────
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
@@ -40,9 +43,6 @@
let mounted = $state(false);
let loading = $state(true);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation);
let params = $state({
@@ -59,6 +59,18 @@
]
});
// ─── Cached API Response ────────────────────────────────────────────────────
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(() => {
@@ -77,28 +89,27 @@
chartInstances = [...chartInstances, chart];
}
// ─── Data Loading & Chart Building ──────────────────────────────────────────
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const loadData = async () => {
if (!mounted) return;
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loadData = async () => {
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`
`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();
// ─── Compute daylight bands ─────────────────────────────────────
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
[];
let markAreas: FetchedData['markAreas'] = [];
if ('daily' in data) {
// Find the first model-suffixed key for sunrise/sunset
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_');
@@ -115,96 +126,104 @@
}
}
// ─── Build chart options for each variable ──────────────────────
const colors = getThemeColors();
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
const variableCount = params.hourly?.length || 0;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const unit = findUnit(data.hourly_units, data.hourly, variable);
const timeLength = data.hourly.time.length;
fetchedData = {
hourly: data.hourly,
hourly_units: data.hourly_units,
utc_offset_seconds: data.utc_offset_seconds,
markAreas,
timestamps
};
// ─── Build individual model series ───────────────────────────
const series: Array<Record<string, unknown>> = [];
if (!averageOnly) {
for (const [model, values] of Object.entries(data.hourly)) {
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
})
);
}
}
// ─── Average series ─────────────────────────────────────────
const { average } = calculateAverage(data.hourly, variable, timeLength);
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
// ─── Annotation series ───────────────────────────────────────
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
// ─── Compose final option ───────────────────────────────────
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
},
yAxis: { unit },
series,
toolbox: {
saveAsImage: true,
fileName: `model-compare-${variable}`,
format: 'png'
},
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
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 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>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
@@ -230,27 +249,9 @@
<ChartToolbar charts={chartInstances} fileName="model-comparison">
{#snippet controls()}
<div class="flex gap-2">
<Switch
id="show_legend"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
params.hourly = params.hourly;
}}
/>
<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>
<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>
{/snippet}
</ChartToolbar>
</div>
@@ -289,8 +290,7 @@
return item !== value;
});
} else if (params.models) {
params.models.push(value);
params.models = params.models;
params.models = [...params.models, value];
}
}}
/>
@@ -344,8 +344,7 @@
return item !== value;
});
} else if (params.hourly) {
params.hourly.push(value);
params.hourly = params.hourly;
params.hourly = [...params.hourly, value];
}
}}
/>