WIP: refactoring
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
<!--
|
||||||
|
ChartContainer.svelte — Consistent chart layout wrapper
|
||||||
|
|
||||||
|
Provides a container with:
|
||||||
|
- Consistent padding and spacing
|
||||||
|
- Loading overlay with spinner
|
||||||
|
- Fade transitions
|
||||||
|
- Responsive min-height calculation
|
||||||
|
- Slot for chart content
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<ChartContainer loading={!chartsReady} chartCount={3}>
|
||||||
|
{#each charts as chart}
|
||||||
|
<EChart option={chart.option} />
|
||||||
|
{/each}
|
||||||
|
</ChartContainer>
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Whether the charts are still loading */
|
||||||
|
loading?: boolean;
|
||||||
|
/** Number of charts being rendered (used for min-height calculation) */
|
||||||
|
chartCount?: number;
|
||||||
|
/** Height per individual chart in pixels (default: 300) */
|
||||||
|
chartHeight?: number;
|
||||||
|
/** Extra vertical padding in pixels added to the total min-height (default: 2) */
|
||||||
|
extraPadding?: number;
|
||||||
|
/** Optional CSS class for the outer wrapper */
|
||||||
|
class?: string;
|
||||||
|
/** Slot content (charts go here) */
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
loading = true,
|
||||||
|
chartCount = 1,
|
||||||
|
chartHeight = 300,
|
||||||
|
extraPadding = 2,
|
||||||
|
class: className = '',
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="chart-container relative {className}"
|
||||||
|
style:min-height="{minHeight}px"
|
||||||
|
>
|
||||||
|
<!-- Chart content area -->
|
||||||
|
<div
|
||||||
|
class="chart-content"
|
||||||
|
in:fade={{ duration: 300 }}
|
||||||
|
out:fade={{ duration: 300 }}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div
|
||||||
|
class="loading-overlay absolute inset-0 z-30 flex items-center justify-center rounded-lg bg-accent transition-opacity duration-300"
|
||||||
|
class:pointer-events-none={!loading}
|
||||||
|
class:opacity-0={!loading}
|
||||||
|
class:opacity-100={loading}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
<svg
|
||||||
|
class="lucide lucide-loader-circle animate-spin text-muted-foreground"
|
||||||
|
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"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Loading charts...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chart-container {
|
||||||
|
/* Negative horizontal margin on mobile to use full width, reset on md+ */
|
||||||
|
margin-left: -1.5rem;
|
||||||
|
margin-right: -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.chart-container {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transition for the loading overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
will-change: opacity;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<!--
|
||||||
|
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)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<ChartToolbar
|
||||||
|
charts={chartInstances}
|
||||||
|
fileName="model-comparison"
|
||||||
|
>
|
||||||
|
{#snippet controls()}
|
||||||
|
<Switch bind:checked={showLegend} />
|
||||||
|
{/snippet}
|
||||||
|
</ChartToolbar>
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type * as echarts from 'echarts';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { downloadChart, downloadAllCharts } from '$lib/utils/echarts/download';
|
||||||
|
import type { ExportFormat } from '$lib/utils/echarts/download';
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Array of ECharts instances available for download */
|
||||||
|
charts?: echarts.ECharts[];
|
||||||
|
/** Base file name for downloaded images (without extension) */
|
||||||
|
fileName?: string;
|
||||||
|
/** Pixel ratio for PNG exports (default: 2) */
|
||||||
|
pixelRatio?: number;
|
||||||
|
/** Whether to show the "Download All" button when multiple charts exist */
|
||||||
|
showDownloadAll?: boolean;
|
||||||
|
/** Optional CSS class for the outer container */
|
||||||
|
class?: string;
|
||||||
|
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||||
|
controls?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
charts = [],
|
||||||
|
fileName = 'open-meteo-chart',
|
||||||
|
pixelRatio = 2,
|
||||||
|
showDownloadAll = true,
|
||||||
|
class: className = '',
|
||||||
|
controls
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let downloadingFormat: ExportFormat | 'all' | null = $state(null);
|
||||||
|
|
||||||
|
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let hasCharts = $derived(charts.length > 0);
|
||||||
|
let hasMultipleCharts = $derived(charts.length > 1);
|
||||||
|
|
||||||
|
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDownload(format: ExportFormat): Promise<void> {
|
||||||
|
if (!hasCharts || downloadingFormat) return;
|
||||||
|
|
||||||
|
downloadingFormat = format;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Small delay to let the UI update to show the loading state
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
if (charts.length === 1) {
|
||||||
|
downloadChart(charts[0], { fileName, format, pixelRatio });
|
||||||
|
} else {
|
||||||
|
downloadAllCharts(charts, { fileName, format, pixelRatio });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Reset state after a brief moment
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadingFormat = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDownloadAll(format: ExportFormat = 'png'): Promise<void> {
|
||||||
|
if (!hasCharts || downloadingFormat) return;
|
||||||
|
|
||||||
|
downloadingFormat = 'all';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
downloadAllCharts(charts, { fileName, format, pixelRatio });
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadingFormat = null;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chart-toolbar flex flex-col items-center gap-4 md:flex-row md:justify-between {className}">
|
||||||
|
<!-- Left side: Custom controls slot -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 md:gap-6">
|
||||||
|
{#if controls}
|
||||||
|
{@render controls()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side: Download buttons -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<!-- Download as PNG -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar-btn"
|
||||||
|
disabled={!hasCharts || downloadingFormat !== null}
|
||||||
|
onclick={() => handleDownload('png')}
|
||||||
|
title="Download chart as PNG image"
|
||||||
|
>
|
||||||
|
{#if downloadingFormat === 'png'}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>PNG</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Download as SVG -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar-btn"
|
||||||
|
disabled={!hasCharts || downloadingFormat !== null}
|
||||||
|
onclick={() => handleDownload('svg')}
|
||||||
|
title="Download chart as SVG vector image"
|
||||||
|
>
|
||||||
|
{#if downloadingFormat === 'svg'}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>SVG</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Download All (only shown when there are multiple charts) -->
|
||||||
|
{#if showDownloadAll && hasMultipleCharts}
|
||||||
|
<div class="mx-1 hidden h-5 w-px bg-border md:block" aria-hidden="true"></div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toolbar-btn"
|
||||||
|
disabled={!hasCharts || downloadingFormat !== null}
|
||||||
|
onclick={() => handleDownloadAll('png')}
|
||||||
|
title="Download all charts as separate PNG images"
|
||||||
|
>
|
||||||
|
{#if downloadingFormat === 'all'}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span>All ({charts.length})</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toolbar-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
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,
|
||||||
|
border-color 150ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover:not(:disabled) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-color: hsl(var(--foreground) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:active:not(:disabled) {
|
||||||
|
background: hsl(var(--muted) / 0.8);
|
||||||
|
transform: translateY(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--ring));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!--
|
||||||
|
EChart.svelte — Reusable ECharts wrapper component
|
||||||
|
|
||||||
|
Handles chart lifecycle (init, update, dispose), responsive resize via
|
||||||
|
ResizeObserver, and exposes the ECharts instance for programmatic access
|
||||||
|
(e.g. export/download).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<EChart
|
||||||
|
option={chartOption}
|
||||||
|
height="300px"
|
||||||
|
renderer="canvas"
|
||||||
|
onChartReady={(chart) => { ... }}
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The ECharts option object to render */
|
||||||
|
option: Record<string, unknown>;
|
||||||
|
/** CSS height of the chart container (default: '300px') */
|
||||||
|
height?: string;
|
||||||
|
/** CSS width of the chart container (default: '100%') */
|
||||||
|
width?: string;
|
||||||
|
/** Renderer type: 'canvas' or 'svg' (default: 'canvas') */
|
||||||
|
renderer?: 'canvas' | 'svg';
|
||||||
|
/** Whether to merge options on update (true) or replace them (false) */
|
||||||
|
notMerge?: boolean;
|
||||||
|
/** Whether to delay update until next animation frame */
|
||||||
|
lazyUpdate?: boolean;
|
||||||
|
/** Optional CSS class for the outer container */
|
||||||
|
class?: string;
|
||||||
|
/** Callback fired once the chart instance is initialized */
|
||||||
|
onChartReady?: (chart: echarts.ECharts) => void;
|
||||||
|
/** Callback fired when the chart is disposed */
|
||||||
|
onChartDisposed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
option,
|
||||||
|
height = '300px',
|
||||||
|
width = '100%',
|
||||||
|
renderer = 'canvas',
|
||||||
|
notMerge = false,
|
||||||
|
lazyUpdate = false,
|
||||||
|
class: className = '',
|
||||||
|
onChartReady,
|
||||||
|
onChartDisposed
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// ─── Internal State ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let containerEl: HTMLDivElement;
|
||||||
|
let chartInstance: echarts.ECharts | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the underlying ECharts instance, or null if not yet initialized.
|
||||||
|
*/
|
||||||
|
export function getChart(): echarts.ECharts | null {
|
||||||
|
return chartInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the chart has been initialized and is not disposed.
|
||||||
|
*/
|
||||||
|
export function isReady(): boolean {
|
||||||
|
return chartInstance !== null && !chartInstance.isDisposed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a manual resize of the chart.
|
||||||
|
* Useful after layout changes that the ResizeObserver might miss.
|
||||||
|
*/
|
||||||
|
export function resize(): void {
|
||||||
|
if (chartInstance && !chartInstance.isDisposed()) {
|
||||||
|
chartInstance.resize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the chart instance and cleans up observers.
|
||||||
|
* Called automatically on component destroy, but can be invoked manually.
|
||||||
|
*/
|
||||||
|
export function dispose(): void {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
initChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Reactivity: Update option when it changes ──────────────────────────────
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chartInstance && !chartInstance.isDisposed() && option) {
|
||||||
|
chartInstance.setOption(option, notMerge, lazyUpdate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Init & Cleanup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initChart(): void {
|
||||||
|
if (!containerEl) return;
|
||||||
|
|
||||||
|
// Dispose any existing instance (e.g. from HMR)
|
||||||
|
if (chartInstance && !chartInstance.isDisposed()) {
|
||||||
|
chartInstance.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = echarts.init(containerEl, null, { renderer });
|
||||||
|
|
||||||
|
if (option) {
|
||||||
|
chartInstance.setOption(option, notMerge, lazyUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up responsive resize
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (chartInstance && !chartInstance.isDisposed()) {
|
||||||
|
chartInstance.resize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerEl);
|
||||||
|
|
||||||
|
onChartReady?.(chartInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup(): void {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartInstance) {
|
||||||
|
if (!chartInstance.isDisposed()) {
|
||||||
|
chartInstance.dispose();
|
||||||
|
}
|
||||||
|
chartInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChartDisposed?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={containerEl} class="echart-wrapper {className}" style:width style:height></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.echart-wrapper {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
ECharts Global Styles — Open-Meteo Weather
|
||||||
|
|
||||||
|
Shared CSS for all ECharts chart instances across the application.
|
||||||
|
Provides consistent theming, tooltip styling, and responsive behavior
|
||||||
|
that integrates with the application's design system (Tailwind + shadcn).
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─── Chart Wrapper ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.echart-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chart Container (legacy class support) ───────────────────────────────── */
|
||||||
|
|
||||||
|
.echarts-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure canvas background is always transparent so our page bg shows through */
|
||||||
|
.echart-wrapper canvas,
|
||||||
|
.echarts-container canvas {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tooltip Styling ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Override ECharts' default tooltip to match the application's popover design */
|
||||||
|
.echarts-tooltip {
|
||||||
|
background: hsl(var(--popover)) !important;
|
||||||
|
border: 1px solid hsl(var(--border)) !important;
|
||||||
|
border-radius: var(--radius, 0.5rem) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 0.05) !important;
|
||||||
|
padding: 0.625rem 0.75rem !important;
|
||||||
|
font-size: 0.8125rem !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
max-width: min(90vw, 480px) !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echarts-tooltip-content {
|
||||||
|
color: hsl(var(--popover-foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip marker dots — make them slightly larger and rounded */
|
||||||
|
.echarts-tooltip .echarts-tooltip-marker,
|
||||||
|
.echarts-tooltip span[style*="border-radius"] {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Loading Mask ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.echarts-loading-mask {
|
||||||
|
background: hsl(var(--background) / 0.8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Light Mode Adjustments ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-theme='light'] .echart-wrapper,
|
||||||
|
[data-theme='light'] .echarts-container,
|
||||||
|
:root:not(.dark):not([data-theme='dark']) .echart-wrapper,
|
||||||
|
:root:not(.dark):not([data-theme='dark']) .echarts-container {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] .echarts-tooltip,
|
||||||
|
:root:not(.dark):not([data-theme='dark']) .echarts-tooltip {
|
||||||
|
color: hsl(var(--popover-foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Dark Mode Adjustments ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dark .echart-wrapper,
|
||||||
|
[data-theme='dark'] .echart-wrapper,
|
||||||
|
.dark .echarts-container,
|
||||||
|
[data-theme='dark'] .echarts-container {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .echarts-tooltip,
|
||||||
|
[data-theme='dark'] .echarts-tooltip {
|
||||||
|
color: hsl(var(--popover-foreground)) !important;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgb(0 0 0 / 0.3),
|
||||||
|
0 2px 4px -2px rgb(0 0 0 / 0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Toolbox Icon Overrides ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Make sure the toolbox icons are visually subtle until hovered */
|
||||||
|
.echart-wrapper [class*="toolbox"],
|
||||||
|
.echarts-container [class*="toolbox"] {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.echart-wrapper:hover [class*="toolbox"],
|
||||||
|
.echarts-container:hover [class*="toolbox"] {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Chart Spacing ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Add consistent vertical spacing between stacked chart instances */
|
||||||
|
.chart-content .echart-wrapper + .echart-wrapper {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Responsive Sizing ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.echarts-container {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slightly smaller tooltips on mobile */
|
||||||
|
.echarts-tooltip {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
padding: 0.5rem 0.625rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) and (max-width: 1024px) {
|
||||||
|
.echarts-container {
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Print Styles ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.echart-wrapper,
|
||||||
|
.echarts-container {
|
||||||
|
break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide interactive elements when printing */
|
||||||
|
.echart-wrapper [class*="toolbox"],
|
||||||
|
.echarts-container [class*="toolbox"],
|
||||||
|
.chart-toolbar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Accessibility ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Respect reduced motion preferences */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.echart-wrapper *,
|
||||||
|
.echarts-container * {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Chart Components — Barrel Export
|
||||||
|
*
|
||||||
|
* Re-exports all chart-related Svelte components from a single entry point.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { default as EChart } from './EChart.svelte';
|
||||||
|
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||||
|
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type * as echarts from 'echarts';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ExportFormat = 'png' | 'svg';
|
||||||
|
|
||||||
|
export interface DownloadOptions {
|
||||||
|
/** The file name (without extension) */
|
||||||
|
fileName?: string;
|
||||||
|
/** Export format: 'png' or 'svg' */
|
||||||
|
format?: ExportFormat;
|
||||||
|
/** Pixel ratio for PNG exports (default: 2 for retina quality) */
|
||||||
|
pixelRatio?: number;
|
||||||
|
/** Background color (default: '#ffffff' for PNG, 'none' for SVG) */
|
||||||
|
backgroundColor?: string;
|
||||||
|
/** Components to exclude from the export (e.g. ['toolbox']) */
|
||||||
|
excludeComponents?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_FILE_NAME = 'open-meteo-chart';
|
||||||
|
const DEFAULT_PIXEL_RATIO = 2;
|
||||||
|
|
||||||
|
// ─── Download Functions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a single ECharts instance as an image file.
|
||||||
|
*
|
||||||
|
* @param chart - The ECharts instance to export
|
||||||
|
* @param options - Download configuration options
|
||||||
|
*/
|
||||||
|
export function downloadChart(chart: echarts.ECharts, options: DownloadOptions = {}): void {
|
||||||
|
const {
|
||||||
|
fileName = DEFAULT_FILE_NAME,
|
||||||
|
format = 'png',
|
||||||
|
pixelRatio = DEFAULT_PIXEL_RATIO,
|
||||||
|
backgroundColor,
|
||||||
|
excludeComponents = ['toolbox']
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
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,
|
||||||
|
backgroundColor: resolvedBg,
|
||||||
|
excludeComponents
|
||||||
|
});
|
||||||
|
|
||||||
|
triggerDownload(dataUrl, `${fileName}.${format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* @param charts - Array of ECharts instances to export
|
||||||
|
* @param options - Download configuration options (fileName is used as prefix)
|
||||||
|
*/
|
||||||
|
export function downloadAllCharts(
|
||||||
|
charts: echarts.ECharts[],
|
||||||
|
options: DownloadOptions = {}
|
||||||
|
): void {
|
||||||
|
const { fileName = DEFAULT_FILE_NAME, ...rest } = options;
|
||||||
|
|
||||||
|
charts.forEach((chart, index) => {
|
||||||
|
if (chart && !chart.isDisposed()) {
|
||||||
|
downloadChart(chart, {
|
||||||
|
...rest,
|
||||||
|
fileName: charts.length === 1 ? fileName : `${fileName}-${index + 1}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the data URL of a chart without triggering a download.
|
||||||
|
* Useful for previewing or embedding chart images programmatically.
|
||||||
|
*
|
||||||
|
* @param chart - The ECharts instance to export
|
||||||
|
* @param options - Export configuration options
|
||||||
|
* @returns A base64-encoded data URL string
|
||||||
|
*/
|
||||||
|
export function getChartDataUrl(
|
||||||
|
chart: echarts.ECharts,
|
||||||
|
options: DownloadOptions = {}
|
||||||
|
): string {
|
||||||
|
const {
|
||||||
|
format = 'png',
|
||||||
|
pixelRatio = DEFAULT_PIXEL_RATIO,
|
||||||
|
backgroundColor,
|
||||||
|
excludeComponents = ['toolbox']
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const resolvedBg = backgroundColor ?? (format === 'svg' ? 'none' : '#ffffff');
|
||||||
|
|
||||||
|
return chart.getDataURL({
|
||||||
|
type: format === 'svg' ? 'svg' : 'png',
|
||||||
|
pixelRatio: format === 'png' ? pixelRatio : 1,
|
||||||
|
backgroundColor: resolvedBg,
|
||||||
|
excludeComponents
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers a browser file download from a data URL.
|
||||||
|
* Creates a temporary anchor element, clicks it, and removes it.
|
||||||
|
*/
|
||||||
|
function triggerDownload(dataUrl: string, fileName: string): void {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* ECharts Utilities — Barrel Export
|
||||||
|
*
|
||||||
|
* Re-exports all ECharts-related utilities from a single entry point.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { getThemeColors, composeChartOption, buildModelSeries, downloadChart } from '$lib/utils/echarts';
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Theme: dark/light detection, color palettes, theme color accessors
|
||||||
|
export {
|
||||||
|
SERIES_COLORS,
|
||||||
|
CHART_COLORS,
|
||||||
|
isDarkMode,
|
||||||
|
getThemeColors,
|
||||||
|
getTextColor,
|
||||||
|
getAxisLineColor,
|
||||||
|
getSplitLineColor
|
||||||
|
} from './theme';
|
||||||
|
export type { ThemeColors } from './theme';
|
||||||
|
|
||||||
|
// Option builders: grid, title, tooltip, legend, axes, toolbox, full composer
|
||||||
|
export {
|
||||||
|
buildGrid,
|
||||||
|
buildTitle,
|
||||||
|
buildTooltip,
|
||||||
|
buildLegend,
|
||||||
|
buildTimeXAxis,
|
||||||
|
buildValueYAxis,
|
||||||
|
buildCreditGraphic,
|
||||||
|
buildToolbox,
|
||||||
|
composeChartOption,
|
||||||
|
isColumnUnit
|
||||||
|
} from './options';
|
||||||
|
export type {
|
||||||
|
GridOptions,
|
||||||
|
TitleOptions,
|
||||||
|
LegendOptions,
|
||||||
|
TooltipOptions,
|
||||||
|
AxisOptions,
|
||||||
|
CreditOptions,
|
||||||
|
BuildGridParams,
|
||||||
|
ToolboxOptions,
|
||||||
|
ChartOptionParams
|
||||||
|
} from './options';
|
||||||
|
|
||||||
|
// Series builders: model lines, averages, time markers, daylight bands, ensemble spread
|
||||||
|
export {
|
||||||
|
buildModelSeries,
|
||||||
|
buildAverageSeries,
|
||||||
|
buildCurrentTimeSeries,
|
||||||
|
buildDaylightMarkAreas,
|
||||||
|
buildDaylightSeries,
|
||||||
|
buildSpreadSeries,
|
||||||
|
calculateAverage,
|
||||||
|
calculateSpread,
|
||||||
|
convertTimestamps,
|
||||||
|
findUnit
|
||||||
|
} from './series';
|
||||||
|
export type {
|
||||||
|
ModelSeriesParams,
|
||||||
|
AverageSeriesParams,
|
||||||
|
CurrentTimeSeriesParams,
|
||||||
|
DaylightSeriesParams,
|
||||||
|
SpreadSeriesParams,
|
||||||
|
AverageResult,
|
||||||
|
SpreadResult
|
||||||
|
} from './series';
|
||||||
|
|
||||||
|
// Download: export charts as PNG or SVG
|
||||||
|
export { downloadChart, downloadAllCharts, getChartDataUrl } from './download';
|
||||||
|
export type { ExportFormat, DownloadOptions } from './download';
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* ECharts Option Builders
|
||||||
|
*
|
||||||
|
* Shared factory functions for constructing common ECharts option fragments.
|
||||||
|
* These builders ensure visual consistency across all chart pages and reduce
|
||||||
|
* boilerplate in page-level components.
|
||||||
|
*/
|
||||||
|
import { getThemeColors } from './theme';
|
||||||
|
|
||||||
|
import type { ThemeColors } from './theme';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface GridOptions {
|
||||||
|
left?: number;
|
||||||
|
right?: number;
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TitleOptions {
|
||||||
|
text: string;
|
||||||
|
subtext?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegendOptions {
|
||||||
|
show: boolean;
|
||||||
|
data?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TooltipOptions {
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AxisOptions {
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreditOptions {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Default Constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_GRID: GridOptions = {
|
||||||
|
left: 60,
|
||||||
|
right: 16,
|
||||||
|
top: 40,
|
||||||
|
bottom: 40
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRID_WITH_TITLE: Partial<GridOptions> = {
|
||||||
|
top: 80
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRID_WITH_LEGEND: Partial<GridOptions> = {
|
||||||
|
bottom: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Grid ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface BuildGridParams {
|
||||||
|
hasTitle?: boolean;
|
||||||
|
hasSubtitle?: boolean;
|
||||||
|
showLegend?: boolean;
|
||||||
|
overrides?: Partial<GridOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a grid configuration with sensible defaults.
|
||||||
|
* Automatically adjusts top/bottom spacing for title and legend presence.
|
||||||
|
*/
|
||||||
|
export function buildGrid(params: BuildGridParams = {}): GridOptions {
|
||||||
|
const { hasTitle = false, hasSubtitle = false, showLegend = false, overrides } = params;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...DEFAULT_GRID,
|
||||||
|
...(hasTitle ? GRID_WITH_TITLE : {}),
|
||||||
|
...(hasSubtitle ? { top: 90 } : {}),
|
||||||
|
...(showLegend ? GRID_WITH_LEGEND : {}),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Title ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a title configuration. Pass `null` to hide the title.
|
||||||
|
*/
|
||||||
|
export function buildTitle(
|
||||||
|
options: TitleOptions | null,
|
||||||
|
colors?: ThemeColors
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
|
if (!options) {
|
||||||
|
return { title: { show: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
|
text: options.text,
|
||||||
|
left: 'left',
|
||||||
|
textStyle: {
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: 16,
|
||||||
|
color: c.text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.subtext) {
|
||||||
|
result.subtext = options.subtext;
|
||||||
|
result.subtextStyle = {
|
||||||
|
fontWeight: 'normal',
|
||||||
|
fontSize: 12,
|
||||||
|
color: c.textMuted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tooltip ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a tooltip with cross-axis pointer and unit-aware value formatting.
|
||||||
|
*/
|
||||||
|
export function buildTooltip(
|
||||||
|
options: TooltipOptions,
|
||||||
|
colors?: ThemeColors
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
const { unit } = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
trigger: 'axis',
|
||||||
|
axisPointer: {
|
||||||
|
type: 'cross',
|
||||||
|
animation: false,
|
||||||
|
label: {
|
||||||
|
backgroundColor: c.tooltipBg,
|
||||||
|
color: c.text,
|
||||||
|
borderColor: c.tooltipBorder,
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backgroundColor: c.tooltipBg,
|
||||||
|
borderColor: c.tooltipBorder,
|
||||||
|
textStyle: {
|
||||||
|
color: c.text
|
||||||
|
},
|
||||||
|
valueFormatter: (value: number) => {
|
||||||
|
if (value === null || value === undefined) return '-';
|
||||||
|
return value.toFixed(1) + ' ' + unit;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Legend ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a scrollable legend configuration pinned to the bottom.
|
||||||
|
*/
|
||||||
|
export function buildLegend(options: LegendOptions, colors?: ThemeColors): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: options.show,
|
||||||
|
bottom: 0,
|
||||||
|
type: 'scroll',
|
||||||
|
...(options.data ? { data: options.data } : {}),
|
||||||
|
textStyle: {
|
||||||
|
color: c.text
|
||||||
|
},
|
||||||
|
pageTextStyle: {
|
||||||
|
color: c.text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── X Axis (Time) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a time-based X axis with theme-aware styling.
|
||||||
|
*/
|
||||||
|
export function buildTimeXAxis(colors?: ThemeColors): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'time',
|
||||||
|
splitLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: c.axisLine
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: c.text,
|
||||||
|
hideOverlap: true
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
lineStyle: {
|
||||||
|
color: c.axisLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Y Axis (Value) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a value-based Y axis with optional unit label.
|
||||||
|
*/
|
||||||
|
export function buildValueYAxis(
|
||||||
|
options: AxisOptions = {},
|
||||||
|
colors?: ThemeColors
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
...(options.unit ? { name: options.unit } : {}),
|
||||||
|
nameTextStyle: {
|
||||||
|
color: c.text,
|
||||||
|
padding: [0, 0, 0, 4]
|
||||||
|
},
|
||||||
|
axisLine: {
|
||||||
|
show: false
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
color: c.text
|
||||||
|
},
|
||||||
|
splitLine: {
|
||||||
|
lineStyle: {
|
||||||
|
color: c.splitLine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Credit Watermark ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Open-Meteo.com credit watermark graphic element.
|
||||||
|
*/
|
||||||
|
export function buildCreditGraphic(colors?: ThemeColors): Record<string, unknown>[] {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
right: 10,
|
||||||
|
bottom: 5,
|
||||||
|
style: {
|
||||||
|
text: 'Open-Meteo.com',
|
||||||
|
fontSize: 10,
|
||||||
|
fill: c.text,
|
||||||
|
opacity: 0.4
|
||||||
|
},
|
||||||
|
onclick: function () {
|
||||||
|
window.open('https://open-meteo.com', '_blank');
|
||||||
|
},
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Toolbox (Download) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ToolboxOptions {
|
||||||
|
/** Show the save-as-image button */
|
||||||
|
saveAsImage?: boolean;
|
||||||
|
/** File name prefix for downloaded images */
|
||||||
|
fileName?: string;
|
||||||
|
/** Export format: 'png' or 'svg' */
|
||||||
|
format?: 'png' | 'svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the ECharts toolbox with download functionality.
|
||||||
|
*/
|
||||||
|
export function buildToolbox(
|
||||||
|
options: ToolboxOptions = {},
|
||||||
|
colors?: ThemeColors
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const c = colors ?? getThemeColors();
|
||||||
|
const { saveAsImage = true, fileName = 'open-meteo-chart', format = 'png' } = options;
|
||||||
|
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
right: 16,
|
||||||
|
top: 4,
|
||||||
|
iconStyle: {
|
||||||
|
borderColor: c.textMuted
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
iconStyle: {
|
||||||
|
borderColor: c.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
feature: {
|
||||||
|
...(saveAsImage
|
||||||
|
? {
|
||||||
|
saveAsImage: {
|
||||||
|
type: format,
|
||||||
|
name: fileName,
|
||||||
|
title: format === 'svg' ? 'Save as SVG' : 'Save as PNG',
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
excludeComponents: ['toolbox'],
|
||||||
|
iconStyle: {
|
||||||
|
borderColor: c.textMuted
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
iconStyle: {
|
||||||
|
borderColor: c.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Full Option Composer ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ChartOptionParams {
|
||||||
|
title?: TitleOptions | null;
|
||||||
|
tooltip: TooltipOptions;
|
||||||
|
legend?: LegendOptions;
|
||||||
|
grid?: BuildGridParams;
|
||||||
|
yAxis?: AxisOptions;
|
||||||
|
series: Array<Record<string, unknown>>;
|
||||||
|
toolbox?: ToolboxOptions | false;
|
||||||
|
showCredit?: boolean;
|
||||||
|
colors?: ThemeColors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes a complete ECharts option object from individual builder params.
|
||||||
|
* This is the primary entry point for building chart options — it calls all
|
||||||
|
* the individual builders and merges the results into a single config object.
|
||||||
|
*/
|
||||||
|
export function composeChartOption(params: ChartOptionParams): Record<string, unknown> {
|
||||||
|
const colors = params.colors ?? getThemeColors();
|
||||||
|
const hasTitle = params.title != null && params.title.text !== '';
|
||||||
|
const showLegend = params.legend?.show ?? false;
|
||||||
|
|
||||||
|
const option: Record<string, unknown> = {
|
||||||
|
title: buildTitle(params.title ?? null, colors),
|
||||||
|
tooltip: buildTooltip(params.tooltip, colors),
|
||||||
|
legend: buildLegend(params.legend ?? { show: false }, colors),
|
||||||
|
grid: buildGrid({
|
||||||
|
...params.grid,
|
||||||
|
hasTitle,
|
||||||
|
hasSubtitle: hasTitle && !!params.title?.subtext,
|
||||||
|
showLegend
|
||||||
|
}),
|
||||||
|
xAxis: buildTimeXAxis(colors),
|
||||||
|
yAxis: buildValueYAxis(params.yAxis, colors),
|
||||||
|
series: params.series,
|
||||||
|
textStyle: {
|
||||||
|
color: colors.text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add toolbox unless explicitly disabled
|
||||||
|
if (params.toolbox !== false) {
|
||||||
|
option.toolbox = buildToolbox(params.toolbox ?? {}, colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add credit watermark
|
||||||
|
if (params.showCredit) {
|
||||||
|
option.graphic = buildCreditGraphic(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Utility: Detect column-type variables ───────────────────────────────────
|
||||||
|
|
||||||
|
/** Units that should be rendered as bar/column charts instead of lines. */
|
||||||
|
const COLUMN_UNITS = new Set(['mm', 'cm', 'inch', 'MJ/m²']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given unit should be rendered as a bar chart.
|
||||||
|
*/
|
||||||
|
export function isColumnUnit(unit: string): boolean {
|
||||||
|
return COLUMN_UNITS.has(unit);
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* ECharts Series Builders
|
||||||
|
*
|
||||||
|
* Factory functions for constructing common series patterns used across
|
||||||
|
* weather chart visualizations. These builders encapsulate the styling
|
||||||
|
* and configuration details so page-level code only needs to provide data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CHART_COLORS } from './theme';
|
||||||
|
import { isColumnUnit } from './options';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ModelSeriesParams {
|
||||||
|
/** The series name (typically the model key from the API response) */
|
||||||
|
name: string;
|
||||||
|
/** Array of [timestamp, value] data points */
|
||||||
|
data: Array<[number, number | null]>;
|
||||||
|
/** The unit string, used to determine bar vs line rendering */
|
||||||
|
unit: string;
|
||||||
|
/** Optional line width override (default: 2) */
|
||||||
|
lineWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AverageSeriesParams {
|
||||||
|
/** The variable name, used to construct the series name */
|
||||||
|
variable: string;
|
||||||
|
/** Array of [timestamp, value] data points */
|
||||||
|
data: Array<[number, number]>;
|
||||||
|
/** The unit string, used to determine bar vs line rendering */
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentTimeSeriesParams {
|
||||||
|
/** UTC offset in seconds from the API response */
|
||||||
|
utcOffsetSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaylightSeriesParams {
|
||||||
|
/** Array of mark area pairs: [[start, end], [start, end], ...] */
|
||||||
|
markAreas: Array<
|
||||||
|
[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadSeriesParams {
|
||||||
|
/** The variable name, used to construct series names */
|
||||||
|
variable: string;
|
||||||
|
/** Array of [timestamp, min, max] data points */
|
||||||
|
spreadData: Array<[number, number, number]>;
|
||||||
|
/** Optional color for the spread area (default: theme spread color) */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkAreaEntry {
|
||||||
|
xAxis: number;
|
||||||
|
itemStyle?: { color: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Model Series ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a single model series (line or bar depending on the unit).
|
||||||
|
* Used on the Model Comparison page where each weather model gets its own series.
|
||||||
|
*/
|
||||||
|
export function buildModelSeries(params: ModelSeriesParams): Record<string, unknown> {
|
||||||
|
const { name, data, unit, lineWidth = 2 } = params;
|
||||||
|
const isColumn = isColumnUnit(unit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: isColumn ? 'bar' : 'line',
|
||||||
|
data,
|
||||||
|
smooth: !isColumn,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: {
|
||||||
|
width: lineWidth
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
lineStyle: {
|
||||||
|
width: lineWidth + 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
barMaxWidth: 5
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Average Series ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the ensemble/model average series.
|
||||||
|
* Rendered as a dashed line (or bar) that stands out from individual model lines.
|
||||||
|
*/
|
||||||
|
export function buildAverageSeries(params: AverageSeriesParams): Record<string, unknown> {
|
||||||
|
const { variable, data, unit } = params;
|
||||||
|
const isColumn = isColumnUnit(unit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: variable + '_average',
|
||||||
|
type: isColumn ? 'bar' : 'line',
|
||||||
|
data,
|
||||||
|
smooth: !isColumn,
|
||||||
|
showSymbol: false,
|
||||||
|
lineStyle: {
|
||||||
|
type: 'dashed',
|
||||||
|
width: 4,
|
||||||
|
color: CHART_COLORS.average
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: CHART_COLORS.average
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
lineStyle: {
|
||||||
|
width: 6
|
||||||
|
}
|
||||||
|
},
|
||||||
|
barMaxWidth: 5,
|
||||||
|
z: 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Current Time Marker ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a helper series that renders a vertical red line at the current time.
|
||||||
|
* Uses an empty data series with a markLine to overlay onto the chart.
|
||||||
|
*/
|
||||||
|
export function buildCurrentTimeSeries(params: CurrentTimeSeriesParams): Record<string, unknown> {
|
||||||
|
const { utcOffsetSeconds } = params;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Current Time',
|
||||||
|
type: 'line',
|
||||||
|
data: [],
|
||||||
|
markLine: {
|
||||||
|
silent: true,
|
||||||
|
symbol: 'none',
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
xAxis: Date.now() + utcOffsetSeconds * 1000,
|
||||||
|
lineStyle: {
|
||||||
|
color: CHART_COLORS.currentTimeLine,
|
||||||
|
width: 2,
|
||||||
|
type: 'solid'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Daylight Bands ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds mark area entries from sunrise/sunset arrays.
|
||||||
|
* Each entry is a pair of axis markers that ECharts renders as a shaded band.
|
||||||
|
*
|
||||||
|
* @param sunrise - Array of sunrise timestamps (unix seconds, without UTC offset)
|
||||||
|
* @param sunset - Array of sunset timestamps (unix seconds, without UTC offset)
|
||||||
|
* @param utcOffsetSeconds - UTC offset to apply (from the API response)
|
||||||
|
*/
|
||||||
|
export function buildDaylightMarkAreas(
|
||||||
|
sunrise: number[],
|
||||||
|
sunset: number[],
|
||||||
|
utcOffsetSeconds: number
|
||||||
|
): Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> {
|
||||||
|
return sunrise.map((r: number, i: number) => [
|
||||||
|
{
|
||||||
|
xAxis: (r + utcOffsetSeconds) * 1000,
|
||||||
|
itemStyle: {
|
||||||
|
color: CHART_COLORS.daylight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
xAxis: (sunset[i] + utcOffsetSeconds) * 1000
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a helper series that renders day/night shading bands via markArea.
|
||||||
|
* Returns null if no mark areas are provided (so callers can filter it out).
|
||||||
|
*/
|
||||||
|
export function buildDaylightSeries(
|
||||||
|
params: DaylightSeriesParams
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (params.markAreas.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Daylight',
|
||||||
|
type: 'line',
|
||||||
|
data: [],
|
||||||
|
markArea: {
|
||||||
|
silent: true,
|
||||||
|
data: params.markAreas
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Ensemble Spread (Min/Max Area) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a pair of stacked area series that visualize the ensemble spread
|
||||||
|
* (min-to-max range). The lower bound is rendered invisibly and the upper
|
||||||
|
* bound delta is stacked on top with a translucent fill.
|
||||||
|
*
|
||||||
|
* Returns an array of two series that should be spread into the series list.
|
||||||
|
*/
|
||||||
|
export function buildSpreadSeries(params: SpreadSeriesParams): Array<Record<string, unknown>> {
|
||||||
|
const { variable, spreadData, color = CHART_COLORS.spreadArea } = params;
|
||||||
|
|
||||||
|
const lowerBound: Record<string, unknown> = {
|
||||||
|
name: variable + '_spread_lower',
|
||||||
|
type: 'line',
|
||||||
|
data: spreadData.map((d) => [d[0], d[1]]),
|
||||||
|
areaStyle: {
|
||||||
|
color,
|
||||||
|
origin: 'auto'
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
width: 0
|
||||||
|
},
|
||||||
|
showSymbol: false,
|
||||||
|
stack: 'spread_' + variable,
|
||||||
|
smooth: true,
|
||||||
|
z: 1,
|
||||||
|
silent: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const upperDelta: Record<string, unknown> = {
|
||||||
|
name: variable + '_spread_upper',
|
||||||
|
type: 'line',
|
||||||
|
data: spreadData.map((d) => [d[0], d[2] - d[1]]),
|
||||||
|
areaStyle: {
|
||||||
|
color,
|
||||||
|
origin: 'auto'
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
width: 0
|
||||||
|
},
|
||||||
|
showSymbol: false,
|
||||||
|
stack: 'spread_' + variable,
|
||||||
|
smooth: true,
|
||||||
|
z: 1,
|
||||||
|
silent: true
|
||||||
|
};
|
||||||
|
|
||||||
|
return [lowerBound, upperDelta];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Processing Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AverageResult {
|
||||||
|
average: number[];
|
||||||
|
averageCount: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpreadResult {
|
||||||
|
minValues: (number | undefined)[];
|
||||||
|
maxValues: (number | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates per-timestep average and count from hourly model data.
|
||||||
|
* Shared between the Model Compare and 14-Day Forecast pages.
|
||||||
|
*
|
||||||
|
* @param hourlyData - The `data.hourly` object from the API response
|
||||||
|
* @param variable - The variable prefix to filter on (e.g. 'temperature_2m')
|
||||||
|
* @param timeLength - Number of timesteps
|
||||||
|
* @returns Object containing running average and count arrays
|
||||||
|
*/
|
||||||
|
export function calculateAverage(
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string,
|
||||||
|
timeLength: number
|
||||||
|
): AverageResult {
|
||||||
|
const average = new Array<number>(timeLength).fill(0);
|
||||||
|
const averageCount = new Array<number>(timeLength).fill(0);
|
||||||
|
|
||||||
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
|
for (const [index, val] of (values as number[]).entries()) {
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
average[index] += val;
|
||||||
|
averageCount[index]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize average values
|
||||||
|
for (let i = 0; i < timeLength; i++) {
|
||||||
|
if (averageCount[i] > 0) {
|
||||||
|
average[i] = Math.round((average[i] / averageCount[i]) * 10) / 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { average, averageCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates per-timestep min and max values from hourly ensemble data.
|
||||||
|
* Used by the 14-Day Forecast page to render the ensemble spread.
|
||||||
|
*
|
||||||
|
* @param hourlyData - The `data.hourly` object from the API response
|
||||||
|
* @param variable - The variable prefix to filter on
|
||||||
|
* @param timeLength - Number of timesteps
|
||||||
|
* @returns Object containing min and max value arrays
|
||||||
|
*/
|
||||||
|
export function calculateSpread(
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string,
|
||||||
|
timeLength: number
|
||||||
|
): SpreadResult {
|
||||||
|
const minValues = new Array<number | undefined>(timeLength).fill(undefined);
|
||||||
|
const maxValues = new Array<number | undefined>(timeLength).fill(undefined);
|
||||||
|
|
||||||
|
for (const [model, values] of Object.entries(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (!model.startsWith(variable)) continue;
|
||||||
|
|
||||||
|
for (const [index, val] of (values as number[]).entries()) {
|
||||||
|
if (val !== null && val !== undefined) {
|
||||||
|
if (minValues[index] === undefined || val < minValues[index]!) {
|
||||||
|
minValues[index] = val;
|
||||||
|
}
|
||||||
|
if (maxValues[index] === undefined || val > maxValues[index]!) {
|
||||||
|
maxValues[index] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minValues, maxValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts raw unix timestamps (seconds) to millisecond timestamps with UTC offset applied.
|
||||||
|
*/
|
||||||
|
export function convertTimestamps(times: number[], utcOffsetSeconds: number): number[] {
|
||||||
|
return times.map((t) => (t + utcOffsetSeconds) * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the unit string for a given variable from the hourly_units map.
|
||||||
|
* Returns an empty string if the variable is not found.
|
||||||
|
*/
|
||||||
|
export function findUnit(
|
||||||
|
hourlyUnits: Record<string, string>,
|
||||||
|
hourlyData: Record<string, unknown>,
|
||||||
|
variable: string
|
||||||
|
): string {
|
||||||
|
for (const model of Object.keys(hourlyData)) {
|
||||||
|
if (model === 'time') continue;
|
||||||
|
if (model.startsWith(variable) && hourlyUnits[model]) {
|
||||||
|
return hourlyUnits[model];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* ECharts Theme Utilities
|
||||||
|
*
|
||||||
|
* Centralized dark/light mode detection and color helpers for consistent
|
||||||
|
* chart theming across all ECharts visualizations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Color Palette ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default series color palette matching the application's design system */
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'#5470c6',
|
||||||
|
'#91cc75',
|
||||||
|
'#fac858',
|
||||||
|
'#ee6666',
|
||||||
|
'#73c0de',
|
||||||
|
'#3ba272',
|
||||||
|
'#fc8452',
|
||||||
|
'#9a60b4',
|
||||||
|
'#ea7ccc',
|
||||||
|
'#4dc9f6'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Semantic colors used for specific chart elements */
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
average: '#5e5e5e',
|
||||||
|
currentTimeLine: '#ef4444',
|
||||||
|
daylight: 'rgba(255, 255, 194, 0.3)',
|
||||||
|
spreadArea: 'rgba(173, 216, 230, 0.3)',
|
||||||
|
creditText: { light: '#374151', dark: '#e5e7eb' }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Dark Mode Detection ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects whether the application is currently in dark mode.
|
||||||
|
* Checks multiple sources: HTML class, data-theme attribute, and media query.
|
||||||
|
*/
|
||||||
|
export function isDarkMode(): boolean {
|
||||||
|
if (typeof document === 'undefined') return false;
|
||||||
|
|
||||||
|
const html = document.documentElement;
|
||||||
|
const dataTheme = html.getAttribute('data-theme');
|
||||||
|
|
||||||
|
// Explicit data-theme takes priority
|
||||||
|
if (dataTheme === 'dark') return true;
|
||||||
|
if (dataTheme === 'light') return false;
|
||||||
|
|
||||||
|
// Check for dark class (e.g. Tailwind dark mode)
|
||||||
|
if (html.classList.contains('dark')) return true;
|
||||||
|
|
||||||
|
// Fall back to system preference
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Theme Colors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ThemeColors {
|
||||||
|
text: string;
|
||||||
|
textMuted: string;
|
||||||
|
axisLine: string;
|
||||||
|
splitLine: string;
|
||||||
|
background: string;
|
||||||
|
tooltipBg: string;
|
||||||
|
tooltipBorder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIGHT_COLORS: ThemeColors = {
|
||||||
|
text: '#374151',
|
||||||
|
textMuted: 'rgba(55, 65, 81, 0.6)',
|
||||||
|
axisLine: 'rgba(55, 65, 81, 0.3)',
|
||||||
|
splitLine: 'rgba(55, 65, 81, 0.1)',
|
||||||
|
background: 'transparent',
|
||||||
|
tooltipBg: '#ffffff',
|
||||||
|
tooltipBorder: '#e5e7eb'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DARK_COLORS: ThemeColors = {
|
||||||
|
text: '#e5e7eb',
|
||||||
|
textMuted: 'rgba(229, 231, 235, 0.6)',
|
||||||
|
axisLine: 'rgba(229, 231, 235, 0.3)',
|
||||||
|
splitLine: 'rgba(229, 231, 235, 0.1)',
|
||||||
|
background: 'transparent',
|
||||||
|
tooltipBg: '#1f2937',
|
||||||
|
tooltipBorder: '#374151'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the full set of theme colors based on current dark/light mode.
|
||||||
|
*/
|
||||||
|
export function getThemeColors(): ThemeColors {
|
||||||
|
return isDarkMode() ? DARK_COLORS : LIGHT_COLORS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shorthand helpers kept for backward compatibility and convenience */
|
||||||
|
export function getTextColor(): string {
|
||||||
|
return getThemeColors().text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAxisLineColor(): string {
|
||||||
|
return getThemeColors().axisLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSplitLineColor(): string {
|
||||||
|
return getThemeColors().splitLine;
|
||||||
|
}
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
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 { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
import '../compare/echarts.css';
|
import { defaultParameters } from '../options';
|
||||||
import { defaultParameters } from './options';
|
|
||||||
|
|
||||||
let node: HTMLElement;
|
import type * as echarts from 'echarts';
|
||||||
let charts: echarts.ECharts[] = [];
|
|
||||||
let resizeObservers: ResizeObserver[] = [];
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let chartComponents: EChart[] = $state([]);
|
||||||
|
let chartInstances: echarts.ECharts[] = $state([]);
|
||||||
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(false);
|
||||||
let averageOnly = $state(false);
|
let averageOnly = $state(false);
|
||||||
@@ -32,404 +49,167 @@
|
|||||||
models: ['gfs_seamless']
|
models: ['gfs_seamless']
|
||||||
});
|
});
|
||||||
|
|
||||||
let count = $state(0);
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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(() => {
|
onMount(() => {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
chartInstances = [];
|
||||||
|
chartOptions = [];
|
||||||
|
chartComponents = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleChartReady(chart: echarts.ECharts): void {
|
||||||
|
chartInstances = [...chartInstances, chart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Loading & Chart Building ──────────────────────────────────────────
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
count = 0;
|
if (!mounted) return;
|
||||||
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();
|
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
chartInstances = [];
|
||||||
|
chartComponents = [];
|
||||||
|
|
||||||
|
// Fetch sunrise/sunset from the standard forecast API
|
||||||
const dataDaily = await fetch(
|
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`
|
`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 wd = await dataDaily.json();
|
||||||
|
|
||||||
|
// Fetch ensemble data
|
||||||
const dataReq = await fetch(
|
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`
|
`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 data = await dataReq.json();
|
||||||
|
|
||||||
// Create day/night plot bands as markArea data
|
// ─── Compute daylight bands ─────────────────────────────────────
|
||||||
|
|
||||||
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
||||||
[];
|
[];
|
||||||
|
|
||||||
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
|
||||||
let rise = wd.daily.sunrise;
|
markAreas = buildDaylightMarkAreas(
|
||||||
let set = wd.daily.sunset;
|
wd.daily.sunrise,
|
||||||
markAreas = rise.map(function (r: number, i: number) {
|
wd.daily.sunset,
|
||||||
return [
|
data.utc_offset_seconds
|
||||||
{
|
);
|
||||||
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);
|
// ─── Build chart options for each variable ──────────────────────
|
||||||
let maxValues = new Array(data.hourly.time.length).fill(undefined);
|
|
||||||
|
|
||||||
const textColor = getTextColor();
|
const colors = getThemeColors();
|
||||||
const axisLineColor = getAxisLineColor();
|
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||||
const splitLineColor = getSplitLineColor();
|
const variableCount = params.hourly?.length || 0;
|
||||||
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let variable of params.hourly || []) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const chartDiv = document.createElement('div');
|
const variable = params.hourly![vi];
|
||||||
chartDiv.style.width = '100%';
|
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
||||||
chartDiv.style.height = showLegend ? '400px' : '300px';
|
const timeLength = data.hourly.time.length;
|
||||||
|
|
||||||
// Append to DOM BEFORE echarts.init so it can measure dimensions
|
// ─── Calculate average and spread ───────────────────────────
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
|
||||||
node.appendChild(chartDiv);
|
|
||||||
|
|
||||||
let unit: string = '';
|
const { average } = calculateAverage(data.hourly, variable, timeLength);
|
||||||
|
const { minValues, maxValues } = calculateSpread(data.hourly, variable, timeLength);
|
||||||
|
|
||||||
|
// ─── Build series ───────────────────────────────────────────
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
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(
|
// Ensemble spread (min/max area)
|
||||||
(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(
|
const spreadData: Array<[number, number, number]> = minValues.map(
|
||||||
(min: number, index: number) => [timestamps[index], min, maxValues[index]]
|
(min, index) =>
|
||||||
|
[timestamps[index], min ?? 0, maxValues[index] ?? 0] as [number, number, number]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
|
series.push(...buildSpreadSeries({ variable, spreadData }));
|
||||||
|
|
||||||
// Add spread series (lower bound)
|
// Average line
|
||||||
series.push({
|
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
|
||||||
name: variable + '_spread',
|
series.push(buildAverageSeries({ variable, data: averageData, unit }));
|
||||||
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)
|
// Current time marker
|
||||||
series.push({
|
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: data.utc_offset_seconds }));
|
||||||
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
|
// Daylight bands
|
||||||
const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]);
|
const daylightSeries = buildDaylightSeries({ markAreas });
|
||||||
|
if (daylightSeries) {
|
||||||
series.push({
|
series.push(daylightSeries);
|
||||||
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> = {
|
// ─── Compose final option ───────────────────────────────────
|
||||||
title: {
|
|
||||||
text: count === 0 ? 'Model Spread' : '',
|
const isFirst = vi === 0;
|
||||||
left: 'left',
|
const isLast = vi === variableCount - 1;
|
||||||
textStyle: {
|
|
||||||
fontWeight: 'normal',
|
const option = composeChartOption({
|
||||||
color: textColor
|
title: isFirst
|
||||||
},
|
|
||||||
...(count === 0
|
|
||||||
? {
|
? {
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`,
|
text: 'Model Spread',
|
||||||
subtextStyle: {
|
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
fontWeight: 'normal',
|
|
||||||
color: textColor
|
|
||||||
}
|
}
|
||||||
}
|
: null,
|
||||||
: {})
|
tooltip: { unit },
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
axisPointer: {
|
|
||||||
type: 'cross',
|
|
||||||
animation: false
|
|
||||||
},
|
|
||||||
valueFormatter: (value: number) => {
|
|
||||||
if (value === null || value === undefined) return '-';
|
|
||||||
return value.toFixed(1) + ' ' + unit;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
legend: {
|
legend: {
|
||||||
show: showLegend,
|
show: showLegend,
|
||||||
bottom: 0,
|
data: [variable + '_average']
|
||||||
type: 'scroll',
|
|
||||||
data: [variable + '_average'],
|
|
||||||
textStyle: {
|
|
||||||
color: textColor
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
left: 60,
|
hasTitle: isFirst,
|
||||||
right: 10,
|
hasSubtitle: isFirst,
|
||||||
top: count === 0 ? 80 : 40,
|
showLegend
|
||||||
bottom: showLegend ? 60 : 40
|
|
||||||
},
|
},
|
||||||
xAxis: {
|
yAxis: { unit },
|
||||||
type: 'time',
|
series,
|
||||||
splitLine: {
|
toolbox: {
|
||||||
show: false
|
saveAsImage: true,
|
||||||
|
fileName: `14-day-forecast-${variable}`,
|
||||||
|
format: 'png'
|
||||||
},
|
},
|
||||||
axisLine: {
|
showCredit: isLast,
|
||||||
lineStyle: {
|
colors
|
||||||
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++;
|
newOptions.push(option);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chartOptions = newOptions;
|
||||||
|
loading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
resizeObservers.forEach((ro) => ro.disconnect());
|
|
||||||
resizeObservers = [];
|
|
||||||
charts.forEach((chart) => {
|
|
||||||
chart.dispose();
|
|
||||||
});
|
|
||||||
charts = [];
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
<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="">
|
<ChartContainer
|
||||||
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
|
{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">
|
<div class="flex gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="show_legend"
|
id="show_legend"
|
||||||
@@ -452,5 +232,6 @@
|
|||||||
/>
|
/>
|
||||||
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</ChartToolbar>
|
||||||
</div>
|
</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,25 +3,42 @@
|
|||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
import { storedLocation } from '$lib/stores/settings';
|
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 { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
import { Switch } from '$lib/components/ui/switch';
|
||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../options';
|
import { hourly, models as modelsFlat } from '../options';
|
||||||
import './echarts.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
|
// Wrap models in array to match template expectation of nested arrays like hourly
|
||||||
const models = [modelsFlat];
|
const models = [modelsFlat];
|
||||||
|
|
||||||
let node: HTMLElement;
|
// ─── State ──────────────────────────────────────────────────────────────────
|
||||||
let charts: echarts.ECharts[] = [];
|
|
||||||
let resizeObservers: ResizeObserver[] = [];
|
let chartComponents: EChart[] = $state([]);
|
||||||
|
let chartInstances: echarts.ECharts[] = $state([]);
|
||||||
|
let chartOptions: Array<Record<string, unknown>> = $state([]);
|
||||||
let mounted = $state(false);
|
let mounted = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
let showLegend = $state(false);
|
let showLegend = $state(false);
|
||||||
let averageOnly = $state(false);
|
let averageOnly = $state(false);
|
||||||
@@ -42,378 +59,176 @@
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let count = $state(0);
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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(() => {
|
onMount(() => {
|
||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
chartInstances = [];
|
||||||
|
chartOptions = [];
|
||||||
|
chartComponents = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Chart Instance Tracking ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleChartReady(chart: echarts.ECharts): void {
|
||||||
|
chartInstances = [...chartInstances, chart];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data Loading & Chart Building ──────────────────────────────────────────
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
count = 0;
|
if (!mounted) return;
|
||||||
if (mounted) {
|
|
||||||
// Dispose existing charts and observers
|
loading = true;
|
||||||
resizeObservers.forEach((ro) => ro.disconnect());
|
chartInstances = [];
|
||||||
resizeObservers = [];
|
chartComponents = [];
|
||||||
charts.forEach((chart) => {
|
|
||||||
if (chart) {
|
|
||||||
chart.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
charts = [];
|
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
|
||||||
node.replaceChildren();
|
|
||||||
|
|
||||||
const dataReq = await fetch(
|
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=${params.hourly?.join(',') || ''}&models=${params.models?.join(',') || ''}&timeformat=unixtime&daily=sunset,sunrise`
|
||||||
);
|
);
|
||||||
const data = await dataReq.json();
|
const data = await dataReq.json();
|
||||||
|
|
||||||
|
// ─── Compute daylight bands ─────────────────────────────────────
|
||||||
|
|
||||||
|
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
||||||
|
[];
|
||||||
|
|
||||||
|
if ('daily' in data) {
|
||||||
|
// Find the first model-suffixed key for sunrise/sunset
|
||||||
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
|
let dailyFirstModelKey: string[] | string = Object.keys(data.daily)[1].split('_');
|
||||||
dailyFirstModelKey.shift();
|
dailyFirstModelKey.shift();
|
||||||
dailyFirstModelKey = dailyFirstModelKey.join('_');
|
dailyFirstModelKey = dailyFirstModelKey.join('_');
|
||||||
|
|
||||||
// Create day/night plot bands as markArea data
|
const sunriseKey = 'sunrise_' + dailyFirstModelKey;
|
||||||
let markAreas: Array<[{ xAxis: number; itemStyle: { color: string } }, { xAxis: number }]> =
|
const sunsetKey = 'sunset_' + dailyFirstModelKey;
|
||||||
[];
|
|
||||||
if (
|
if (sunriseKey in data.daily && sunsetKey in data.daily) {
|
||||||
'daily' in data &&
|
markAreas = buildDaylightMarkAreas(
|
||||||
'sunrise_' + dailyFirstModelKey in data.daily &&
|
data.daily[sunriseKey],
|
||||||
'sunset_' + dailyFirstModelKey in data.daily
|
data.daily[sunsetKey],
|
||||||
) {
|
data.utc_offset_seconds
|
||||||
let rise = data.daily['sunrise_' + dailyFirstModelKey];
|
);
|
||||||
let set = data.daily['sunset_' + dailyFirstModelKey];
|
|
||||||
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
|
|
||||||
}
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const textColor = getTextColor();
|
// ─── Build chart options for each variable ──────────────────────
|
||||||
const axisLineColor = getAxisLineColor();
|
|
||||||
const splitLineColor = getSplitLineColor();
|
|
||||||
|
|
||||||
for (let variable of params.hourly || []) {
|
const colors = getThemeColors();
|
||||||
const chartDiv = document.createElement('div');
|
const timestamps = convertTimestamps(data.hourly.time, data.utc_offset_seconds);
|
||||||
chartDiv.style.width = '100%';
|
const variableCount = params.hourly?.length || 0;
|
||||||
chartDiv.style.height = showLegend ? '400px' : '300px';
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
// Append to DOM BEFORE echarts.init so it can measure dimensions
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
// eslint-disable-next-line svelte/no-dom-manipulating
|
const variable = params.hourly![vi];
|
||||||
node.appendChild(chartDiv);
|
const unit = findUnit(data.hourly_units, data.hourly, variable);
|
||||||
|
const timeLength = data.hourly.time.length;
|
||||||
|
|
||||||
let unit: string = '';
|
// ─── Build individual model series ───────────────────────────
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
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]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unit = data.hourly_units[model];
|
|
||||||
|
|
||||||
if (!averageOnly) {
|
if (!averageOnly) {
|
||||||
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
|
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(
|
const seriesData = (values as (number | null)[]).map(
|
||||||
(val: number | null, idx: number) => [timestamps[idx], val]
|
(val, idx) => [timestamps[idx], val] as [number, number | null]
|
||||||
);
|
);
|
||||||
|
|
||||||
series.push({
|
series.push(
|
||||||
|
buildModelSeries({
|
||||||
name: model,
|
name: model,
|
||||||
type: isColumn ? 'bar' : 'line',
|
|
||||||
data: seriesData,
|
data: seriesData,
|
||||||
smooth: !isColumn,
|
unit
|
||||||
showSymbol: false,
|
})
|
||||||
lineStyle: {
|
);
|
||||||
width: 2
|
|
||||||
},
|
|
||||||
emphasis: {
|
|
||||||
lineStyle: {
|
|
||||||
width: 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
barMaxWidth: 5
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate average
|
// ─── Average series ─────────────────────────────────────────
|
||||||
for (let [index, val] of average.entries()) {
|
|
||||||
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isColumn = unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²';
|
// ─── Compose final option ───────────────────────────────────
|
||||||
const averageData = average.map((val: number, idx: number) => [timestamps[idx], val]);
|
|
||||||
|
|
||||||
series.push({
|
const isFirst = vi === 0;
|
||||||
name: variable + '_average',
|
const isLast = vi === variableCount - 1;
|
||||||
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
|
const option = composeChartOption({
|
||||||
series.push({
|
title: isFirst
|
||||||
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 Compare' : '',
|
|
||||||
left: 'left',
|
|
||||||
textStyle: {
|
|
||||||
fontWeight: 'normal',
|
|
||||||
color: textColor
|
|
||||||
},
|
|
||||||
...(count === 0
|
|
||||||
? {
|
? {
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`,
|
text: 'Model Compare',
|
||||||
subtextStyle: {
|
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
fontWeight: 'normal',
|
|
||||||
color: textColor
|
|
||||||
}
|
}
|
||||||
}
|
: null,
|
||||||
: {})
|
tooltip: { unit },
|
||||||
},
|
legend: { show: showLegend },
|
||||||
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',
|
|
||||||
textStyle: {
|
|
||||||
color: textColor
|
|
||||||
}
|
|
||||||
},
|
|
||||||
grid: {
|
grid: {
|
||||||
left: 60,
|
hasTitle: isFirst,
|
||||||
right: 10,
|
hasSubtitle: isFirst,
|
||||||
top: count === 0 ? 80 : 40,
|
showLegend
|
||||||
bottom: showLegend ? 60 : 40
|
|
||||||
},
|
},
|
||||||
xAxis: {
|
yAxis: { unit },
|
||||||
type: 'time',
|
series,
|
||||||
splitLine: {
|
toolbox: {
|
||||||
show: false
|
saveAsImage: true,
|
||||||
|
fileName: `model-compare-${variable}`,
|
||||||
|
format: 'png'
|
||||||
},
|
},
|
||||||
axisLine: {
|
showCredit: isLast,
|
||||||
lineStyle: {
|
colors
|
||||||
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++;
|
newOptions.push(option);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
chartOptions = newOptions;
|
||||||
|
loading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
resizeObservers.forEach((ro) => ro.disconnect());
|
|
||||||
resizeObservers = [];
|
|
||||||
charts.forEach((chart) => {
|
|
||||||
chart.dispose();
|
|
||||||
});
|
|
||||||
charts = [];
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
<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="">
|
<ChartContainer
|
||||||
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
|
{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="model-comparison">
|
||||||
|
{#snippet controls()}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
id="show_legend"
|
id="show_legend"
|
||||||
@@ -436,9 +251,12 @@
|
|||||||
/>
|
/>
|
||||||
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
<Label for="average_only" class="mb-0.5 cursor-pointer text-lg">Average only</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/snippet}
|
||||||
|
</ChartToolbar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
<div class="mt-4 md:mt-8">
|
<div class="mt-4 md:mt-8">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
||||||
@@ -487,7 +305,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HOURLY -->
|
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
|
||||||
|
|
||||||
<div class="mt-6 md:mt-12">
|
<div class="mt-6 md:mt-12">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<a href="#hourly_weather_variables"
|
<a href="#hourly_weather_variables"
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/* ECharts theme integration for Open-Meteo Weather */
|
|
||||||
|
|
||||||
/* Container styling */
|
|
||||||
.echarts-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure ECharts respects current color scheme */
|
|
||||||
[data-theme='light'] .echarts-container,
|
|
||||||
:root:not([data-theme='dark']) .echarts-container {
|
|
||||||
color: hsl(var(--foreground));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltip styling to match application theme */
|
|
||||||
.echarts-tooltip {
|
|
||||||
background: hsl(var(--popover)) !important;
|
|
||||||
border: 1px solid hsl(var(--border)) !important;
|
|
||||||
border-radius: var(--radius) !important;
|
|
||||||
box-shadow:
|
|
||||||
0 4px 6px -1px rgb(0 0 0 / 0.1),
|
|
||||||
0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
|
|
||||||
padding: 0.75rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.echarts-tooltip-content {
|
|
||||||
color: hsl(var(--popover-foreground)) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure text is readable in both themes */
|
|
||||||
.echarts-container text {
|
|
||||||
fill: currentColor !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chart background */
|
|
||||||
.echarts-container canvas {
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading state */
|
|
||||||
.echarts-loading-mask {
|
|
||||||
background: hsl(var(--background) / 0.8) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color palette for series */
|
|
||||||
:root {
|
|
||||||
--echarts-color-0: #5470c6;
|
|
||||||
--echarts-color-1: #91cc75;
|
|
||||||
--echarts-color-2: #fac858;
|
|
||||||
--echarts-color-3: #ee6666;
|
|
||||||
--echarts-color-4: #73c0de;
|
|
||||||
--echarts-color-5: #3ba272;
|
|
||||||
--echarts-color-6: #fc8452;
|
|
||||||
--echarts-color-7: #9a60b4;
|
|
||||||
--echarts-color-8: #ea7ccc;
|
|
||||||
--echarts-color-9: #5470c6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive sizing */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.echarts-container {
|
|
||||||
min-height: 250px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user