Chart-Renderer: SVG-Visualisierung mit 10 Chart-Typen

Bar, Line, Pie, Doughnut, HorizontalBar, Scatter, Area,
StackedBar, Radar und Gauge — alles reines SVG ohne externe
Bibliotheken. Nutzt Obsidian-CSS-Variablen fuer Theming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-12 11:17:09 +01:00
parent df39e0174a
commit 794cb42b72

667
src/viz/chart-renderer.ts Normal file
View file

@ -0,0 +1,667 @@
export type ChartType =
| 'bar'
| 'line'
| 'pie'
| 'doughnut'
| 'horizontalBar'
| 'scatter'
| 'area'
| 'stackedBar'
| 'radar'
| 'gauge';
export interface ChartConfig {
type: ChartType;
title?: string;
labelColumn?: string;
valueColumn?: string;
valueColumns?: string[];
xColumn?: string;
yColumn?: string;
width?: number;
height?: number;
minValue?: number;
maxValue?: number;
}
const SVG_NS = 'http://www.w3.org/2000/svg';
// ---------------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------------
export function renderChart(
el: HTMLElement,
rows: Record<string, unknown>[],
config: ChartConfig,
): void {
if (rows.length === 0) {
el.createEl('p', { text: 'Keine Daten.', cls: 'logfire-empty' });
return;
}
const keys = Object.keys(rows[0]);
if (keys.length < 1) return;
const wrapper = el.createDiv({ cls: 'logfire-chart-wrapper' });
if (config.title) {
wrapper.createDiv({ cls: 'logfire-chart-title', text: config.title });
}
const w = config.width ?? 560;
const h = config.height ?? 280;
const labelCol = config.labelColumn ?? keys[0];
const valueCol = config.valueColumn ?? keys[1] ?? keys[0];
switch (config.type) {
case 'bar':
barChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'horizontalBar':
horizontalBarChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'line':
lineChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'area':
areaChart(wrapper, rows, labelCol, valueCol, w, h);
break;
case 'pie':
case 'doughnut':
pieChart(wrapper, rows, labelCol, valueCol, w, h, config.type === 'doughnut');
break;
case 'scatter':
scatterChart(wrapper, rows, config.xColumn ?? keys[0], config.yColumn ?? keys[1], w, h);
break;
case 'stackedBar':
stackedBarChart(wrapper, rows, labelCol, config.valueColumns ?? keys.slice(1), w, h);
break;
case 'radar':
radarChart(wrapper, rows, labelCol, config.valueColumns ?? [valueCol], w, h);
break;
case 'gauge':
gaugeChart(wrapper, rows, valueCol, w, h, config.minValue ?? 0, config.maxValue ?? 100);
break;
}
}
// ---------------------------------------------------------------------------
// Bar
// ---------------------------------------------------------------------------
function barChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const bw = (cw / labels.length) * 0.78;
const gap = (cw / labels.length) * 0.22;
const colors = palette(labels.length);
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
values.forEach((v, i) => {
const bh = (v / maxVal) * ch;
const x = pad.l + i * (bw + gap) + gap / 2;
const y = height - pad.b - bh;
const rect = svgEl('rect', {
x, y, width: bw, height: bh, fill: colors[i], class: 'logfire-chart-bar',
});
addTooltip(rect, `${labels[i]}: ${v}`);
svg.appendChild(rect);
svgText(svg, x + bw / 2, height - pad.b + 14, truncate(labels[i], 10), 'middle', 'logfire-chart-label');
});
legend(container, labels, values, colors);
}
// ---------------------------------------------------------------------------
// Horizontal Bar
// ---------------------------------------------------------------------------
function horizontalBarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const pad = { t: 20, r: 20, b: 24, l: 100 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const bh = (ch / labels.length) * 0.78;
const gap = (ch / labels.length) * 0.22;
const colors = palette(labels.length);
values.forEach((v, i) => {
const w = (v / maxVal) * cw;
const y = pad.t + i * (bh + gap) + gap / 2;
const rect = svgEl('rect', {
x: pad.l, y, width: w, height: bh, fill: colors[i], class: 'logfire-chart-bar',
});
addTooltip(rect, `${labels[i]}: ${v}`);
svg.appendChild(rect);
svgText(svg, pad.l - 6, y + bh / 2 + 4, truncate(labels[i], 14), 'end', 'logfire-chart-label');
svgText(svg, pad.l + w + 5, y + bh / 2 + 4, String(v), 'start', 'logfire-chart-value');
});
}
// ---------------------------------------------------------------------------
// Line
// ---------------------------------------------------------------------------
function lineChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const minVal = Math.min(...values, 0);
const range = maxVal - minVal || 1;
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
const pts = values.map((v, i) => ({
x: pad.l + (i / (labels.length - 1 || 1)) * cw,
y: height - pad.b - ((v - minVal) / range) * ch,
}));
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
svg.appendChild(svgEl('path', {
d, fill: 'none', stroke: 'var(--interactive-accent)', 'stroke-width': 2, class: 'logfire-chart-line',
}));
pts.forEach((p, i) => {
const c = svgEl('circle', {
cx: p.x, cy: p.y, r: 3.5, fill: 'var(--interactive-accent)', class: 'logfire-chart-point',
});
addTooltip(c, `${labels[i]}: ${values[i]}`);
svg.appendChild(c);
if (i % Math.ceil(labels.length / 10) === 0) {
svgText(svg, p.x, height - pad.b + 14, truncate(labels[i], 8), 'middle', 'logfire-chart-label');
}
});
}
// ---------------------------------------------------------------------------
// Area
// ---------------------------------------------------------------------------
function areaChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const maxVal = Math.max(...values, 1);
const minVal = Math.min(...values, 0);
const range = maxVal - minVal || 1;
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
const pts = values.map((v, i) => ({
x: pad.l + (i / (labels.length - 1 || 1)) * cw,
y: height - pad.b - ((v - minVal) / range) * ch,
}));
// Gradient
const gid = `lg-${Math.random().toString(36).slice(2, 9)}`;
const defs = document.createElementNS(SVG_NS, 'defs');
const grad = document.createElementNS(SVG_NS, 'linearGradient');
grad.id = gid;
grad.setAttribute('x1', '0%'); grad.setAttribute('y1', '0%');
grad.setAttribute('x2', '0%'); grad.setAttribute('y2', '100%');
const s1 = document.createElementNS(SVG_NS, 'stop');
s1.setAttribute('offset', '0%');
s1.setAttribute('style', 'stop-color:var(--interactive-accent);stop-opacity:0.35');
const s2 = document.createElementNS(SVG_NS, 'stop');
s2.setAttribute('offset', '100%');
s2.setAttribute('style', 'stop-color:var(--interactive-accent);stop-opacity:0.04');
grad.appendChild(s1); grad.appendChild(s2);
defs.appendChild(grad); svg.appendChild(defs);
const base = height - pad.b;
const areaPts = pts.map(p => `${p.x},${p.y}`);
svg.appendChild(svgEl('path', {
d: `M ${pts[0].x} ${base} L ${areaPts.join(' L ')} L ${pts[pts.length - 1].x} ${base} Z`,
fill: `url(#${gid})`, class: 'logfire-chart-area',
}));
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
svg.appendChild(svgEl('path', {
d, fill: 'none', stroke: 'var(--interactive-accent)', 'stroke-width': 2, class: 'logfire-chart-line',
}));
pts.forEach((p, i) => {
const c = svgEl('circle', {
cx: p.x, cy: p.y, r: 3.5, fill: 'var(--interactive-accent)', class: 'logfire-chart-point',
});
addTooltip(c, `${labels[i]}: ${values[i]}`);
svg.appendChild(c);
});
}
// ---------------------------------------------------------------------------
// Pie / Doughnut
// ---------------------------------------------------------------------------
function pieChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCol: string, width: number, height: number,
isDoughnut: boolean,
): void {
const { labels, values } = extract(rows, labelCol, valueCol);
const svg = createSVG(container, width, height);
const total = values.reduce((a, b) => a + b, 0) || 1;
const cx = width / 2;
const cy = height / 2;
const r = Math.min(width, height) / 2 - 36;
const ir = isDoughnut ? r * 0.5 : 0;
const colors = palette(labels.length);
let angle = -Math.PI / 2;
values.forEach((v, i) => {
const sa = (v / total) * 2 * Math.PI;
const ea = angle + sa;
const lg = sa > Math.PI ? 1 : 0;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
const x2 = cx + r * Math.cos(ea);
const y2 = cy + r * Math.sin(ea);
let d: string;
if (isDoughnut) {
const ix1 = cx + ir * Math.cos(angle);
const iy1 = cy + ir * Math.sin(angle);
const ix2 = cx + ir * Math.cos(ea);
const iy2 = cy + ir * Math.sin(ea);
d = `M ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} L ${ix2} ${iy2} A ${ir} ${ir} 0 ${lg} 0 ${ix1} ${iy1} Z`;
} else {
d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${lg} 1 ${x2} ${y2} Z`;
}
const path = svgEl('path', {
d, fill: colors[i], stroke: 'var(--background-primary)', 'stroke-width': 2,
class: 'logfire-chart-slice',
});
addTooltip(path, `${labels[i]}: ${v} (${((v / total) * 100).toFixed(1)}%)`);
svg.appendChild(path);
angle = ea;
});
if (isDoughnut) {
svgText(svg, cx, cy, String(total), 'middle', 'logfire-chart-center');
}
legend(container, labels, values, colors);
}
// ---------------------------------------------------------------------------
// Scatter
// ---------------------------------------------------------------------------
function scatterChart(
container: HTMLElement, rows: Record<string, unknown>[],
xCol: string, yCol: string, width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const pad = { t: 20, r: 20, b: 56, l: 56 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const xs = rows.map(r => num(r[xCol]));
const ys = rows.map(r => num(r[yCol]));
const xMin = Math.min(...xs); const xMax = Math.max(...xs);
const yMin = Math.min(...ys); const yMax = Math.max(...ys);
const xRange = xMax - xMin || 1;
const yRange = yMax - yMin || 1;
drawYAxis(svg, yMax, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
xs.forEach((xv, i) => {
const x = pad.l + ((xv - xMin) / xRange) * cw;
const y = height - pad.b - ((ys[i] - yMin) / yRange) * ch;
const c = svgEl('circle', {
cx: x, cy: y, r: 5, fill: 'var(--interactive-accent)', opacity: 0.7,
class: 'logfire-chart-point',
});
addTooltip(c, `(${xv}, ${ys[i]})`);
svg.appendChild(c);
});
}
// ---------------------------------------------------------------------------
// Stacked Bar
// ---------------------------------------------------------------------------
function stackedBarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCols: string[], width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const pad = { t: 20, r: 20, b: 56, l: 48 };
const cw = width - pad.l - pad.r;
const ch = height - pad.t - pad.b;
const labels = rows.map(r => String(r[labelCol] ?? ''));
const series = valueCols.map(col => rows.map(r => num(r[col])));
const totals = labels.map((_, i) => series.reduce((s, d) => s + d[i], 0));
const maxVal = Math.max(...totals, 1);
const bw = (cw / labels.length) * 0.78;
const gap = (cw / labels.length) * 0.22;
const colors = palette(valueCols.length);
drawYAxis(svg, maxVal, pad, width, height, ch);
drawAxisLine(svg, pad.l, height - pad.b, width - pad.r, height - pad.b);
labels.forEach((label, i) => {
let offset = 0;
const x = pad.l + i * (bw + gap) + gap / 2;
series.forEach((ds, si) => {
const bh = (ds[i] / maxVal) * ch;
const y = height - pad.b - offset - bh;
const rect = svgEl('rect', {
x, y, width: bw, height: bh, fill: colors[si], class: 'logfire-chart-bar',
});
addTooltip(rect, `${label}${valueCols[si]}: ${ds[i]}`);
svg.appendChild(rect);
offset += bh;
});
svgText(svg, x + bw / 2, height - pad.b + 14, truncate(label, 10), 'middle', 'logfire-chart-label');
});
seriesLegend(container, valueCols, colors);
}
// ---------------------------------------------------------------------------
// Radar
// ---------------------------------------------------------------------------
function radarChart(
container: HTMLElement, rows: Record<string, unknown>[],
labelCol: string, valueCols: string[], width: number, height: number,
): void {
const svg = createSVG(container, width, height);
const cx = width / 2;
const cy = height / 2;
const r = Math.min(width, height) / 2 - 50;
const labels = rows.map(row => String(row[labelCol] ?? ''));
const series = valueCols.map(col => rows.map(row => num(row[col])));
const maxVal = Math.max(...series.flat(), 1);
const step = (2 * Math.PI) / labels.length;
const colors = palette(valueCols.length);
// Grid
for (let lvl = 1; lvl <= 5; lvl++) {
const lr = (r / 5) * lvl;
const pts = labels.map((_, i) => {
const a = i * step - Math.PI / 2;
return `${cx + lr * Math.cos(a)},${cy + lr * Math.sin(a)}`;
}).join(' ');
svg.appendChild(svgEl('polygon', {
points: pts, fill: 'none', stroke: 'var(--background-modifier-border)', 'stroke-width': 1,
}));
}
// Axes + labels
labels.forEach((label, i) => {
const a = i * step - Math.PI / 2;
svg.appendChild(svgEl('line', {
x1: cx, y1: cy, x2: cx + r * Math.cos(a), y2: cy + r * Math.sin(a),
stroke: 'var(--background-modifier-border)', 'stroke-width': 1,
}));
svgText(svg, cx + (r + 16) * Math.cos(a), cy + (r + 16) * Math.sin(a),
truncate(label, 10), 'middle', 'logfire-chart-label');
});
// Data polygons
series.forEach((ds, si) => {
const pts = ds.map((v, i) => {
const a = i * step - Math.PI / 2;
const vr = (v / maxVal) * r;
return `${cx + vr * Math.cos(a)},${cy + vr * Math.sin(a)}`;
}).join(' ');
const poly = svgEl('polygon', {
points: pts, fill: colors[si], 'fill-opacity': 0.25,
stroke: colors[si], 'stroke-width': 2, class: 'logfire-chart-radar',
});
addTooltip(poly, valueCols[si]);
svg.appendChild(poly);
});
if (valueCols.length > 1) seriesLegend(container, valueCols, colors);
}
// ---------------------------------------------------------------------------
// Gauge
// ---------------------------------------------------------------------------
function gaugeChart(
container: HTMLElement, rows: Record<string, unknown>[],
valueCol: string, width: number, height: number,
minVal: number, maxVal: number,
): void {
const svg = createSVG(container, width, height);
const cx = width / 2;
const cy = height * 0.65;
const r = Math.min(width, height) * 0.38;
const ir = r * 0.68;
const value = num(rows[0]?.[valueCol]);
const range = maxVal - minVal || 1;
const pct = Math.max(0, Math.min(1, (value - minVal) / range));
// Background arc
svg.appendChild(svgEl('path', {
d: arc(cx, cy, r, ir, Math.PI, 2 * Math.PI),
fill: 'var(--background-modifier-border)',
}));
// Value arc
const color = pct < 0.33 ? '#E45756' : pct < 0.66 ? '#EECA3B' : '#54A24B';
svg.appendChild(svgEl('path', {
d: arc(cx, cy, r, ir, Math.PI, Math.PI + pct * Math.PI),
fill: color,
}));
svgText(svg, cx, cy - 8, fmtNum(value), 'middle', 'logfire-chart-gauge-value');
svgText(svg, cx, cy + 20, `${(pct * 100).toFixed(1)}%`, 'middle', 'logfire-chart-label');
svgText(svg, cx - r + 8, cy + 16, String(minVal), 'start', 'logfire-chart-label');
svgText(svg, cx + r - 8, cy + 16, String(maxVal), 'end', 'logfire-chart-label');
}
// ---------------------------------------------------------------------------
// Chart config parsing from comment directives
// ---------------------------------------------------------------------------
export function parseChartConfig(source: string): ChartConfig | null {
const match = source.match(/^--\s*chart:\s*(\w+)(?:\s+(.*))?$/im);
if (!match) return null;
const type = match[1].toLowerCase() as ChartType;
const valid: ChartType[] = [
'bar', 'line', 'pie', 'doughnut', 'horizontalBar',
'scatter', 'area', 'stackedBar', 'radar', 'gauge',
];
if (!valid.includes(type)) return null;
const opts = match[2] ?? '';
const config: ChartConfig = { type };
const str = (re: RegExp) => { const m = opts.match(re); return m ? m[1] : undefined; };
const n = (re: RegExp) => { const m = opts.match(re); return m ? parseFloat(m[1]) : undefined; };
config.title = str(/title:"([^"]+)"/);
config.labelColumn = str(/label:(\S+)/);
const vc = str(/value:(\S+)/);
if (vc?.includes(',')) {
config.valueColumns = vc.split(',');
} else if (vc) {
config.valueColumn = vc;
}
config.xColumn = str(/x:(\S+)/);
config.yColumn = str(/y:(\S+)/);
config.width = n(/width:(\d+)/);
config.height = n(/height:(\d+)/);
config.minValue = n(/min:(-?\d+(?:\.\d+)?)/);
config.maxValue = n(/max:(-?\d+(?:\.\d+)?)/);
return config;
}
// ---------------------------------------------------------------------------
// SVG helpers
// ---------------------------------------------------------------------------
function createSVG(container: HTMLElement, w: number, h: number): SVGSVGElement {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('width', String(w));
svg.setAttribute('height', String(h));
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
svg.setAttribute('class', 'logfire-chart-svg');
container.appendChild(svg);
return svg;
}
function svgEl(tag: string, attrs: Record<string, unknown>): SVGElement {
const el = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (v !== undefined) el.setAttribute(k, String(v));
}
return el;
}
function svgText(
svg: SVGSVGElement, x: number, y: number, text: string,
anchor: string, cls: string,
): void {
const el = document.createElementNS(SVG_NS, 'text');
el.setAttribute('x', String(x));
el.setAttribute('y', String(y));
el.setAttribute('text-anchor', anchor);
if (cls.includes('center')) el.setAttribute('dominant-baseline', 'middle');
el.setAttribute('class', cls);
el.textContent = text;
svg.appendChild(el);
}
function addTooltip(el: SVGElement, text: string): void {
const title = document.createElementNS(SVG_NS, 'title');
title.textContent = text;
el.appendChild(title);
}
function drawAxisLine(svg: SVGSVGElement, x1: number, y1: number, x2: number, y2: number): void {
svg.appendChild(svgEl('line', {
x1, y1, x2, y2, stroke: 'var(--text-muted)', 'stroke-width': 1,
}));
}
function drawYAxis(
svg: SVGSVGElement, maxVal: number,
pad: { t: number; r: number; b: number; l: number },
width: number, height: number, ch: number,
): void {
drawAxisLine(svg, pad.l, pad.t, pad.l, height - pad.b);
for (let i = 0; i <= 5; i++) {
const v = (maxVal / 5) * i;
const y = height - pad.b - (i / 5) * ch;
svgText(svg, pad.l - 6, y + 4, fmtNum(v), 'end', 'logfire-chart-axis-label');
svg.appendChild(svgEl('line', {
x1: pad.l, y1: y, x2: width - pad.r, y2: y,
stroke: 'var(--background-modifier-border)', 'stroke-dasharray': '2,2',
}));
}
}
function arc(cx: number, cy: number, or: number, ir: number, sa: number, ea: number): string {
const lg = ea - sa > Math.PI ? 1 : 0;
const so = { x: cx + or * Math.cos(sa), y: cy + or * Math.sin(sa) };
const eo = { x: cx + or * Math.cos(ea), y: cy + or * Math.sin(ea) };
const si = { x: cx + ir * Math.cos(ea), y: cy + ir * Math.sin(ea) };
const ei = { x: cx + ir * Math.cos(sa), y: cy + ir * Math.sin(sa) };
return `M ${so.x} ${so.y} A ${or} ${or} 0 ${lg} 1 ${eo.x} ${eo.y} L ${si.x} ${si.y} A ${ir} ${ir} 0 ${lg} 0 ${ei.x} ${ei.y} Z`;
}
// ---------------------------------------------------------------------------
// Data helpers
// ---------------------------------------------------------------------------
function extract(rows: Record<string, unknown>[], labelCol: string, valueCol: string) {
return {
labels: rows.map(r => String(r[labelCol] ?? '')),
values: rows.map(r => num(r[valueCol])),
};
}
function num(v: unknown): number {
if (typeof v === 'number') return v;
return parseFloat(String(v)) || 0;
}
function fmtNum(v: number): string {
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K';
return v % 1 === 0 ? String(v) : v.toFixed(1);
}
function truncate(s: string, max: number): string {
return s.length <= max ? s : s.slice(0, max - 1) + '\u2026';
}
function palette(n: number): string[] {
const base = ['#4C78A8', '#F58518', '#E45756', '#72B7B2', '#54A24B', '#EECA3B', '#B279A2', '#FF9DA6', '#9D755D', '#BAB0AC'];
return Array.from({ length: n }, (_, i) => base[i % base.length]);
}
// ---------------------------------------------------------------------------
// Legend
// ---------------------------------------------------------------------------
function legend(container: HTMLElement, labels: string[], values: number[], colors: string[]): void {
const total = values.reduce((a, b) => a + b, 0) || 1;
const el = container.createDiv({ cls: 'logfire-chart-legend' });
labels.forEach((label, i) => {
const item = el.createDiv({ cls: 'logfire-legend-item' });
const swatch = item.createDiv({ cls: 'logfire-legend-swatch' });
swatch.style.backgroundColor = colors[i];
item.createSpan({ text: `${label}: ${values[i]} (${((values[i] / total) * 100).toFixed(1)}%)` });
});
}
function seriesLegend(container: HTMLElement, names: string[], colors: string[]): void {
const el = container.createDiv({ cls: 'logfire-chart-legend' });
names.forEach((name, i) => {
const item = el.createDiv({ cls: 'logfire-legend-item' });
const swatch = item.createDiv({ cls: 'logfire-legend-swatch' });
swatch.style.backgroundColor = colors[i];
item.createSpan({ text: name });
});
}