remove echarts, use canvas
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
Usage:
|
||||
<ChartContainer loading={!chartsReady} chartCount={3}>
|
||||
{#each charts as chart}
|
||||
<EChart option={chart.option} />
|
||||
<CanvasChart {...chart} />
|
||||
{/each}
|
||||
</ChartContainer>
|
||||
-->
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
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
|
||||
@@ -11,7 +10,7 @@
|
||||
|
||||
Usage:
|
||||
<ChartToolbar
|
||||
charts={chartInstances}
|
||||
charts={chartComponents}
|
||||
fileName="model-comparison"
|
||||
>
|
||||
{#snippet controls()}
|
||||
@@ -19,22 +18,23 @@
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { downloadMeteogram } from '$lib/utils/echarts/download';
|
||||
<script module lang="ts">
|
||||
/** Anything that can export itself as a PNG data URL (e.g. a CanvasChart). */
|
||||
export interface DownloadableChart {
|
||||
getPngDataUrl(): string | null;
|
||||
}
|
||||
</script>
|
||||
|
||||
import type { ExportFormat } from '$lib/utils/echarts/download';
|
||||
import type * as echarts from 'echarts';
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
/** Array of ECharts instances available for download */
|
||||
charts?: echarts.ECharts[];
|
||||
/** Chart components available for download (undefined entries are skipped) */
|
||||
charts?: Array<DownloadableChart | undefined | null>;
|
||||
/** 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.) */
|
||||
@@ -43,33 +43,88 @@
|
||||
|
||||
let {
|
||||
charts = [],
|
||||
fileName = 'open-meteo-chart',
|
||||
pixelRatio = 2,
|
||||
fileName = 'ombrella-chart',
|
||||
class: className = '',
|
||||
controls
|
||||
}: Props = $props();
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let downloadingFormat: ExportFormat | null = $state(null);
|
||||
let downloading = $state(false);
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
let hasCharts = $derived(charts.length > 0);
|
||||
let hasCharts = $derived(charts.some((chart) => chart != null));
|
||||
|
||||
// ─── Handlers ───────────────────────────────────────────────────────────────
|
||||
// ─── Download ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDownload(format: ExportFormat): Promise<void> {
|
||||
if (!hasCharts || downloadingFormat) return;
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(img);
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
downloadingFormat = format;
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
return bg || '#ffffff';
|
||||
}
|
||||
|
||||
function triggerDownload(url: string, name: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = name;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDownload(): Promise<void> {
|
||||
if (!hasCharts || downloading) return;
|
||||
|
||||
downloading = true;
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
downloadMeteogram(charts, { fileName, format, pixelRatio });
|
||||
const dataUrls = charts
|
||||
.filter((chart): chart is DownloadableChart => chart != null)
|
||||
.map((chart) => chart.getPngDataUrl())
|
||||
.filter((url): url is string => url !== null);
|
||||
if (dataUrls.length === 0) return;
|
||||
|
||||
const images = (await Promise.all(dataUrls.map(loadImage))).filter(
|
||||
(img) => img.naturalWidth > 0
|
||||
);
|
||||
if (images.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...images.map((img) => img.naturalWidth));
|
||||
const totalHeight = images.reduce((sum, img) => sum + img.naturalHeight, 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
canvas.height = totalHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
let y = 0;
|
||||
for (const img of images) {
|
||||
ctx.drawImage(img, 0, y);
|
||||
y += img.naturalHeight;
|
||||
}
|
||||
|
||||
triggerDownload(canvas.toDataURL('image/png'), `${fileName}.png`);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
downloadingFormat = null;
|
||||
downloading = false;
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
@@ -85,17 +140,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right side: Download buttons -->
|
||||
<!-- Right side: Download button -->
|
||||
<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')}
|
||||
disabled={!hasCharts || downloading}
|
||||
onclick={handleDownload}
|
||||
title="Download meteogram as PNG image"
|
||||
>
|
||||
{#if downloadingFormat === 'png'}
|
||||
{#if downloading}
|
||||
<svg
|
||||
class="h-4 w-4 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -126,46 +180,6 @@
|
||||
{/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>
|
||||
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<!--
|
||||
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 { echarts } from './echarts';
|
||||
|
||||
import type { 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) => 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 | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the underlying ECharts instance, or null if not yet initialized.
|
||||
*/
|
||||
export function getChart(): 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>
|
||||
@@ -1,164 +0,0 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════════
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Tree-shaken ECharts build: only the pieces the app actually renders (line
|
||||
// and bar series with grid/tooltip/legend/dataZoom/mark/graphic features) are
|
||||
// registered, instead of the ~1 MB full bundle.
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomInsideComponent,
|
||||
DataZoomSliderComponent,
|
||||
GraphicComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
MarkAreaComponent,
|
||||
MarkLineComponent,
|
||||
TitleComponent,
|
||||
TooltipComponent
|
||||
} from 'echarts/components';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
DataZoomInsideComponent,
|
||||
DataZoomSliderComponent,
|
||||
MarkLineComponent,
|
||||
MarkAreaComponent,
|
||||
GraphicComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
export { echarts };
|
||||
@@ -4,9 +4,8 @@
|
||||
* Re-exports all chart-related Svelte components from a single entry point.
|
||||
*
|
||||
* Usage:
|
||||
* import { EChart, ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
* import { 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';
|
||||
|
||||
Reference in New Issue
Block a user