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:
parent
df39e0174a
commit
794cb42b72
1 changed files with 667 additions and 0 deletions
667
src/viz/chart-renderer.ts
Normal file
667
src/viz/chart-renderer.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue