multimodel improvements

This commit is contained in:
terraputix
2026-08-02 22:24:51 +02:00
parent bde815c35a
commit ea9eb17d75
19 changed files with 2493 additions and 826 deletions
+15 -9
View File
@@ -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;
+102 -6
View File
@@ -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;
+7 -1
View File
@@ -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';