multimodel improvements
This commit is contained in:
@@ -18,14 +18,15 @@
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script module lang="ts">
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
export type { ExportableChart } from './downloadChartsPng';
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { type ExportableChart, downloadChartsPng } from './downloadChartsPng';
|
||||
import {
|
||||
type ChartDownloadOptions,
|
||||
type ChartExportItem,
|
||||
type ExportableChart,
|
||||
downloadChartsPng
|
||||
} from './downloadChartsPng';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -33,9 +34,11 @@
|
||||
|
||||
interface Props {
|
||||
/** Chart components available for download (undefined entries are skipped) */
|
||||
charts?: Array<ExportableChart | undefined | null>;
|
||||
charts?: Array<ChartExportItem | ExportableChart | undefined | null>;
|
||||
/** Base file name for downloaded images (without extension) */
|
||||
fileName?: string;
|
||||
/** Optional title and shared legend drawn into the combined PNG. */
|
||||
exportOptions?: ChartDownloadOptions;
|
||||
/** Optional CSS class for the outer container */
|
||||
class?: string;
|
||||
/** Slot for additional controls (switches, checkboxes, etc.) */
|
||||
@@ -45,6 +48,7 @@
|
||||
let {
|
||||
charts = [],
|
||||
fileName = 'drizzli-chart',
|
||||
exportOptions,
|
||||
class: className = '',
|
||||
controls
|
||||
}: Props = $props();
|
||||
@@ -55,7 +59,9 @@
|
||||
|
||||
// ─── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
let hasCharts = $derived(charts.some((chart) => chart != null));
|
||||
let hasCharts = $derived(
|
||||
charts.some((item) => item != null && ('chart' in item ? item.chart != null : true))
|
||||
);
|
||||
|
||||
// ─── Download ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,7 +69,7 @@
|
||||
if (!hasCharts || downloading) return;
|
||||
downloading = true;
|
||||
try {
|
||||
await downloadChartsPng(charts, fileName);
|
||||
await downloadChartsPng(charts, fileName, exportOptions);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
downloading = false;
|
||||
|
||||
@@ -17,6 +17,17 @@ export interface ChartExportItem {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ExportLegendItem {
|
||||
name: string;
|
||||
color: string;
|
||||
style?: 'point' | 'line' | 'dashed' | 'bar';
|
||||
}
|
||||
|
||||
export interface ChartDownloadOptions {
|
||||
title?: string;
|
||||
legend?: ExportLegendItem[];
|
||||
}
|
||||
|
||||
/** Resolves the page background so exports match the current theme. */
|
||||
function exportBackground(): string {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
|
||||
@@ -47,18 +58,60 @@ function toItem(x: ChartExportItem | ExportableChart | null | undefined): ChartE
|
||||
*/
|
||||
export async function downloadChartsPng(
|
||||
charts: Array<ChartExportItem | ExportableChart | null | undefined>,
|
||||
fileName: string
|
||||
fileName: string,
|
||||
options: ChartDownloadOptions = {}
|
||||
): Promise<void> {
|
||||
const items = charts.map(toItem).filter((it) => it.chart != null);
|
||||
const items = charts
|
||||
.map(toItem)
|
||||
.filter(
|
||||
(it): it is ChartExportItem & { chart: ExportableChart } =>
|
||||
typeof it.chart?.getExportImage === 'function'
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
|
||||
const canvases = (
|
||||
await Promise.all(items.map((it) => it.chart!.getExportImage({ title: it.title })))
|
||||
).filter((c): c is HTMLCanvasElement => c != null && c.width > 0 && c.height > 0);
|
||||
// A supplementary panel must never prevent the remaining charts from being
|
||||
// downloaded. Invalid adapters are filtered above; rejected renders are
|
||||
// isolated here instead of aborting the complete Promise.all chain.
|
||||
const rendered = await Promise.allSettled(
|
||||
items.map((it) => it.chart.getExportImage({ title: it.title }))
|
||||
);
|
||||
const canvases = rendered.flatMap((result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
result.value != null &&
|
||||
result.value.width > 0 &&
|
||||
result.value.height > 0
|
||||
? [result.value]
|
||||
: []
|
||||
);
|
||||
if (canvases.length === 0) return;
|
||||
|
||||
const maxWidth = Math.max(...canvases.map((c) => c.width));
|
||||
const totalHeight = canvases.reduce((sum, c) => sum + c.height, 0);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const padding = 12 * dpr;
|
||||
const gap = 14 * dpr;
|
||||
const rowHeight = 22 * dpr;
|
||||
const measure = document.createElement('canvas').getContext('2d');
|
||||
const legendRows: ExportLegendItem[][] = [];
|
||||
if (measure && options.legend?.length) {
|
||||
measure.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
let row: ExportLegendItem[] = [];
|
||||
let rowWidth = 0;
|
||||
for (const item of options.legend) {
|
||||
const width = 18 * dpr + measure.measureText(item.name).width;
|
||||
if (row.length > 0 && rowWidth + gap + width > maxWidth - padding * 2) {
|
||||
legendRows.push(row);
|
||||
row = [];
|
||||
rowWidth = 0;
|
||||
}
|
||||
row.push(item);
|
||||
rowWidth += (rowWidth > 0 ? gap : 0) + width;
|
||||
}
|
||||
if (row.length > 0) legendRows.push(row);
|
||||
}
|
||||
const headerHeight =
|
||||
(options.title ? 34 * dpr : 0) +
|
||||
(legendRows.length > 0 ? legendRows.length * rowHeight + 8 * dpr : 0);
|
||||
const totalHeight = headerHeight + canvases.reduce((sum, c) => sum + c.height, 0);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxWidth;
|
||||
@@ -70,7 +123,50 @@ export async function downloadChartsPng(
|
||||
ctx.fillStyle = exportBackground();
|
||||
ctx.fillRect(0, 0, maxWidth, totalHeight);
|
||||
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const foreground = styles.getPropertyValue('--foreground').trim() || '#1f2937';
|
||||
const muted = styles.getPropertyValue('--muted-foreground').trim() || '#6b7280';
|
||||
let y = 0;
|
||||
if (options.title) {
|
||||
ctx.fillStyle = foreground;
|
||||
ctx.font = `600 ${16 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(options.title, padding, 17 * dpr);
|
||||
y += 34 * dpr;
|
||||
}
|
||||
if (legendRows.length > 0) {
|
||||
ctx.font = `${12 * dpr}px system-ui, -apple-system, sans-serif`;
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const row of legendRows) {
|
||||
let x = padding;
|
||||
for (const item of row) {
|
||||
const markerWidth = 12 * dpr;
|
||||
ctx.strokeStyle = item.color;
|
||||
ctx.fillStyle = item.color;
|
||||
ctx.lineWidth = 2 * dpr;
|
||||
ctx.setLineDash(item.style === 'dashed' ? [4 * dpr, 3 * dpr] : []);
|
||||
if (item.style === 'point') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + markerWidth / 2, y + rowHeight / 2, 3 * dpr, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (item.style === 'bar') {
|
||||
ctx.fillRect(x + 3 * dpr, y + 5 * dpr, 6 * dpr, 12 * dpr);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + rowHeight / 2);
|
||||
ctx.lineTo(x + markerWidth, y + rowHeight / 2);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
x += markerWidth + 6 * dpr;
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(item.name, x, y + rowHeight / 2);
|
||||
x += ctx.measureText(item.name).width + gap;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
y += 8 * dpr;
|
||||
}
|
||||
for (const c of canvases) {
|
||||
ctx.drawImage(c, 0, y);
|
||||
y += c.height;
|
||||
|
||||
@@ -9,4 +9,10 @@
|
||||
|
||||
export { default as ChartContainer } from './ChartContainer.svelte';
|
||||
export { default as ChartToolbar } from './ChartToolbar.svelte';
|
||||
export { downloadChartsPng, type ExportableChart, type ChartExportItem } from './downloadChartsPng';
|
||||
export {
|
||||
downloadChartsPng,
|
||||
type ChartDownloadOptions,
|
||||
type ChartExportItem,
|
||||
type ExportableChart,
|
||||
type ExportLegendItem
|
||||
} from './downloadChartsPng';
|
||||
|
||||
Reference in New Issue
Block a user