diff --git a/src/lib/components/charts/ChartToolbar.svelte b/src/lib/components/charts/ChartToolbar.svelte index 46d8a5e..ea7a079 100644 --- a/src/lib/components/charts/ChartToolbar.svelte +++ b/src/lib/components/charts/ChartToolbar.svelte @@ -2,10 +2,12 @@ ChartToolbar.svelte — Chart action bar with download and display controls Provides a toolbar row with: - - Download as PNG button - - Download as SVG button - - Download all charts button (when multiple charts exist) - - Slot for additional custom controls (e.g. legend toggle, average toggle) + - Download full meteogram as PNG button + - Download full meteogram as SVG button + - Slot for additional custom controls (e.g. legend toggle) + + When multiple charts are provided, they are stitched into a single + combined image on download. Usage: --> -
+
{#if controls} @@ -114,7 +93,7 @@ class="toolbar-btn" disabled={!hasCharts || downloadingFormat !== null} onclick={() => handleDownload('png')} - title="Download chart as PNG image" + title="Download meteogram as PNG image" > {#if downloadingFormat === 'png'} handleDownload('svg')} - title="Download chart as SVG vector image" + title="Download meteogram as SVG vector image" > {#if downloadingFormat === 'svg'} SVG - - - {#if showDownloadAll && hasMultipleCharts} - - - {/if}
diff --git a/src/lib/utils/echarts/download.ts b/src/lib/utils/echarts/download.ts index 86bba24..0f13b48 100644 --- a/src/lib/utils/echarts/download.ts +++ b/src/lib/utils/echarts/download.ts @@ -2,10 +2,9 @@ * ECharts Download Utilities * * Provides programmatic chart export functionality for downloading - * charts as PNG or SVG images. These utilities wrap ECharts' built-in - * export capabilities with a convenient API and sensible defaults. + * charts as PNG or SVG images. Supports stitching multiple chart + * instances into a single combined meteogram image. */ - import type * as echarts from 'echarts'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -49,7 +48,6 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); - // Use ECharts' getDataURL for PNG, getConnectedDataURL for SVG const dataUrl = chart.getDataURL({ type: format === 'svg' ? 'svg' : 'png', pixelRatio: format === 'png' ? pixelRatio : 1, @@ -61,25 +59,47 @@ export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = } /** - * Downloads all provided ECharts instances as separate image files. - * Each file is named with an incrementing suffix (e.g. chart-1.png, chart-2.png). + * Downloads multiple ECharts instances stitched into a single combined + * meteogram image. Charts are stacked vertically in the order provided. * - * @param charts - Array of ECharts instances to export - * @param options - Download configuration options (fileName is used as prefix) + * For a single chart, delegates to `downloadChart`. + * + * @param charts - Array of ECharts instances to combine + * @param options - Download configuration options */ -export function downloadAllCharts( - charts: echarts.ECharts[], - options: DownloadOptions = {} -): void { - const { fileName = DEFAULT_FILE_NAME, ...rest } = options; +export function downloadMeteogram(charts: echarts.ECharts[], options: DownloadOptions = {}): void { + const validCharts = charts.filter((c) => c && !c.isDisposed()); + if (validCharts.length === 0) return; - charts.forEach((chart, index) => { - if (chart && !chart.isDisposed()) { - downloadChart(chart, { - ...rest, - fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}` - }); - } + if (validCharts.length === 1) { + downloadChart(validCharts[0], options); + return; + } + + const { + fileName = DEFAULT_FILE_NAME, + format = 'png', + pixelRatio = DEFAULT_PIXEL_RATIO, + backgroundColor, + excludeComponents = ['toolbox'] + } = options; + + const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff'); + + if (format === 'svg') { + downloadMeteogramSvg(validCharts, { + fileName, + backgroundColor: resolvedBg, + excludeComponents + }); + return; + } + + downloadMeteogramPng(validCharts, { + fileName, + pixelRatio, + backgroundColor: resolvedBg, + excludeComponents }); } @@ -91,10 +111,7 @@ export function downloadAllCharts( * @param options - Export configuration options * @returns A base64-encoded data URL string */ -export function getChartDataUrl( - chart: echarts.ECharts, - options: DownloadOptions = {} -): string { +export function getChartDataUrl(chart: echarts.ECharts, options: DownloadOptions = {}): string { const { format = 'png', pixelRatio = DEFAULT_PIXEL_RATIO, @@ -112,22 +129,192 @@ export function getChartDataUrl( }); } +// ─── Internal: PNG Meteogram ───────────────────────────────────────────────── + +interface PngStitchOptions { + fileName: string; + pixelRatio: number; + backgroundColor: string; + excludeComponents: string[]; +} + +/** + * Stitches multiple charts into a single PNG by rendering each chart's + * data URL onto an off-screen canvas, stacked vertically. + */ +function downloadMeteogramPng(charts: echarts.ECharts[], opts: PngStitchOptions): void { + const { fileName, pixelRatio, backgroundColor, excludeComponents } = opts; + + const dataUrls = charts.map((chart) => + chart.getDataURL({ + type: 'png', + pixelRatio, + backgroundColor: 'transparent', + excludeComponents + }) + ); + + const images: HTMLImageElement[] = []; + let loadedCount = 0; + + dataUrls.forEach((url, index) => { + const img = new Image(); + images[index] = img; + + img.onload = () => { + loadedCount++; + if (loadedCount === dataUrls.length) { + composePngAndDownload(images, fileName, backgroundColor); + } + }; + + img.onerror = () => { + loadedCount++; + if (loadedCount === dataUrls.length) { + composePngAndDownload(images, fileName, backgroundColor); + } + }; + + img.src = url; + }); +} + +function composePngAndDownload( + images: HTMLImageElement[], + fileName: string, + backgroundColor: string +): void { + const validImages = images.filter((img) => img.naturalWidth > 0); + if (validImages.length === 0) return; + + const maxWidth = Math.max(...validImages.map((img) => img.naturalWidth)); + const totalHeight = validImages.reduce((sum, img) => sum + img.naturalHeight, 0); + + const canvas = document.createElement('canvas'); + canvas.width = maxWidth; + canvas.height = totalHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + if (backgroundColor && backgroundColor !== 'transparent' && backgroundColor !== 'none') { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, maxWidth, totalHeight); + } + + let y = 0; + for (const img of validImages) { + ctx.drawImage(img, 0, y); + y += img.naturalHeight; + } + + const dataUrl = canvas.toDataURL('image/png'); + triggerDownload(dataUrl, `${fileName}.png`); +} + +// ─── Internal: SVG Meteogram ───────────────────────────────────────────────── + +interface SvgStitchOptions { + fileName: string; + backgroundColor: string; + excludeComponents: string[]; +} + +/** + * Stitches multiple charts into a single SVG by extracting each chart's + * SVG markup and embedding them as nested groups with vertical offsets. + */ +function downloadMeteogramSvg(charts: echarts.ECharts[], opts: SvgStitchOptions): void { + const { fileName, backgroundColor, excludeComponents } = opts; + + const svgStrings = charts.map((chart) => + chart.getDataURL({ + type: 'svg', + pixelRatio: 1, + backgroundColor: 'transparent', + excludeComponents + }) + ); + + const parser = new DOMParser(); + const fragments: { svg: SVGSVGElement; width: number; height: number }[] = []; + + for (const svgDataUrl of svgStrings) { + const svgContent = decodeSvgDataUrl(svgDataUrl); + if (!svgContent) continue; + + const doc = parser.parseFromString(svgContent, 'image/svg+xml'); + const svg = doc.querySelector('svg'); + if (!svg) continue; + + const width = parseFloat(svg.getAttribute('width') || '0'); + const height = parseFloat(svg.getAttribute('height') || '0'); + + if (width > 0 && height > 0) { + fragments.push({ svg, width, height }); + } + } + + if (fragments.length === 0) return; + + const maxWidth = Math.max(...fragments.map((f) => f.width)); + const totalHeight = fragments.reduce((sum, f) => sum + f.height, 0); + + let combinedSvg = ``; + + if (backgroundColor && backgroundColor !== 'none' && backgroundColor !== 'transparent') { + combinedSvg += ``; + } + + let yOffset = 0; + for (const fragment of fragments) { + combinedSvg += ``; + combinedSvg += fragment.svg.innerHTML; + combinedSvg += ``; + yOffset += fragment.height; + } + + combinedSvg += ``; + + const blob = new Blob([combinedSvg], { type: 'image/svg+xml;charset=utf-8' }); + const url = URL.createObjectURL(blob); + triggerDownload(url, `${fileName}.svg`); + + setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +function decodeSvgDataUrl(dataUrl: string): string | null { + try { + if (dataUrl.startsWith('data:image/svg+xml;charset=UTF-8,')) { + return decodeURIComponent(dataUrl.slice('data:image/svg+xml;charset=UTF-8,'.length)); + } + if (dataUrl.startsWith('data:image/svg+xml;base64,')) { + return atob(dataUrl.slice('data:image/svg+xml;base64,'.length)); + } + if (dataUrl.startsWith('data:image/svg+xml,')) { + return decodeURIComponent(dataUrl.slice('data:image/svg+xml,'.length)); + } + return null; + } catch { + return null; + } +} + // ─── Internal Helpers ──────────────────────────────────────────────────────── /** - * Triggers a browser file download from a data URL. + * Triggers a browser file download from a data URL or object URL. * Creates a temporary anchor element, clicks it, and removes it. */ -function triggerDownload(dataUrl: string, fileName: string): void { +function triggerDownload(url: string, fileName: string): void { const link = document.createElement('a'); - link.href = dataUrl; + link.href = url; link.download = fileName; link.style.display = 'none'; document.body.appendChild(link); link.click(); - // Clean up the DOM after a brief delay to ensure the download starts requestAnimationFrame(() => { document.body.removeChild(link); }); diff --git a/src/lib/utils/echarts/index.ts b/src/lib/utils/echarts/index.ts index c036a1b..22732e9 100644 --- a/src/lib/utils/echarts/index.ts +++ b/src/lib/utils/echarts/index.ts @@ -68,5 +68,5 @@ export type { } from './series'; // Download: export charts as PNG or SVG -export { downloadChart, downloadAllCharts, getChartDataUrl } from './download'; +export { downloadChart, downloadMeteogram, getChartDataUrl } from './download'; export type { ExportFormat, DownloadOptions } from './download'; diff --git a/src/routes/weather/14-day/+page.svelte b/src/routes/weather/14-day/+page.svelte index 61d44ca..60b6772 100644 --- a/src/routes/weather/14-day/+page.svelte +++ b/src/routes/weather/14-day/+page.svelte @@ -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; + hourly_units: Record; + 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> = []; - 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> = []; - - // 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> = []; + + 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> = []; + + 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; + }); @@ -211,27 +231,9 @@ {#snippet controls()}
- { - params.hourly = params.hourly; - }} - /> +
-
- { - params.hourly = params.hourly; - }} - /> - -
{/snippet}
diff --git a/src/routes/weather/compare/+page.svelte b/src/routes/weather/compare/+page.svelte index 6875688..6a1172d 100644 --- a/src/routes/weather/compare/+page.svelte +++ b/src/routes/weather/compare/+page.svelte @@ -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; + hourly_units: Record; + 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> = []; - 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> = []; - - 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> = []; + + for (let vi = 0; vi < variableCount; vi++) { + const variable = params.hourly![vi]; + const unit = findUnit(hourly_units, hourlyData, variable); + + const series: Array> = []; + + 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; + }); @@ -230,27 +249,9 @@ {#snippet controls()}
- { - params.hourly = params.hourly; - }} - /> +
-
- { - params.hourly = params.hourly; - }} - /> - -
{/snippet}
@@ -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]; } }} />