feat: migrate to apache echarts (#4)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/ombrella#4
This commit is contained in:
2026-02-16 00:23:08 +01:00
co-authored by terraputix
parent 6b48dd6764
commit 4e3ccf1c06
23 changed files with 2373 additions and 1776 deletions
@@ -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,214 @@
<!--
ChartToolbar.svelte — Chart action bar with download and display controls
Provides a toolbar row with:
- Download full meteogram as PNG button
- Download full meteogram as SVG button
- Slot for additional custom controls (e.g. legend toggle)
When multiple charts are provided, they are stitched into a single
combined image on download.
Usage:
<ChartToolbar
charts={chartInstances}
fileName="model-comparison"
>
{#snippet controls()}
<Switch bind:checked={showLegend} />
{/snippet}
</ChartToolbar>
-->
<script lang="ts">
import { downloadMeteogram } from '$lib/utils/echarts/download';
import type { ExportFormat } from '$lib/utils/echarts/download';
import type * as echarts from 'echarts';
import type { Snippet } from 'svelte';
// ─── 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;
/** 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,
class: className = '',
controls
}: Props = $props();
// ─── State ──────────────────────────────────────────────────────────────────
let downloadingFormat: ExportFormat | null = $state(null);
// ─── Computed ───────────────────────────────────────────────────────────────
let hasCharts = $derived(charts.length > 0);
// ─── Handlers ───────────────────────────────────────────────────────────────
async function handleDownload(format: ExportFormat): Promise<void> {
if (!hasCharts || downloadingFormat) return;
downloadingFormat = format;
try {
await new Promise((resolve) => setTimeout(resolve, 50));
downloadMeteogram(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 meteogram 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 meteogram 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>
</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>
+165
View File
@@ -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>
+164
View File
@@ -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;
}
}
+12
View File
@@ -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';