From 794cb42b72926060c9235c6df12dbbc3dc6db255 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:17:09 +0100 Subject: [PATCH] Chart-Renderer: SVG-Visualisierung mit 10 Chart-Typen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/viz/chart-renderer.ts | 667 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 667 insertions(+) create mode 100644 src/viz/chart-renderer.ts diff --git a/src/viz/chart-renderer.ts b/src/viz/chart-renderer.ts new file mode 100644 index 0000000..7fa6797 --- /dev/null +++ b/src/viz/chart-renderer.ts @@ -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[], + 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[], + 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[], + 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[], + 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[], + 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[], + 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[], + 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[], + 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[], + 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[], + 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): 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[], 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 }); + }); +}