From 794cb42b72926060c9235c6df12dbbc3dc6db255 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:17:09 +0100 Subject: [PATCH 1/4] 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 }); + }); +} From eec66b738de23d15b8606c3cfb42e07ec4aaa4fc Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:17:16 +0100 Subject: [PATCH 2/4] Dashboard: View und Code-Block-Parser fuer Multi-Widget-Layouts DashboardView als Obsidian-Sidebar, parseDashboardBlock fuer logfire-dashboard Code-Bloecke. Grid-Layout mit query, chart, stat und text Widgets. Unabhaengige Refresh-Intervalle pro Widget. Co-Authored-By: Claude Opus 4.6 --- src/viz/dashboard.ts | 326 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 src/viz/dashboard.ts diff --git a/src/viz/dashboard.ts b/src/viz/dashboard.ts new file mode 100644 index 0000000..1140d5d --- /dev/null +++ b/src/viz/dashboard.ts @@ -0,0 +1,326 @@ +import { ItemView, WorkspaceLeaf, MarkdownRenderer, Notice } from 'obsidian'; +import { DatabaseManager } from '../core/database'; +import { renderChart, parseChartConfig, ChartConfig } from './chart-renderer'; +import { renderTable, renderMetric } from './table-renderer'; +import type LogfirePlugin from '../main'; + +export const DASHBOARD_VIEW_TYPE = 'logfire-dashboard-view'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DashboardWidget { + id: string; + type: 'query' | 'chart' | 'stat' | 'text'; + title?: string; + sql?: string; + chartConfig?: ChartConfig; + text?: string; + refreshInterval?: number; + position: { row: number; col: number; width: number; height: number }; +} + +export interface Dashboard { + id: string; + name: string; + widgets: DashboardWidget[]; + columns: number; + refreshAll?: number; +} + +// --------------------------------------------------------------------------- +// Dashboard View +// --------------------------------------------------------------------------- + +export class DashboardView extends ItemView { + private plugin: LogfirePlugin; + private db: DatabaseManager; + private currentDashboard: Dashboard | null = null; + private contentContainer!: HTMLElement; + private refreshTimers = new Map>(); + private globalRefreshTimer: ReturnType | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: LogfirePlugin) { + super(leaf); + this.plugin = plugin; + this.db = plugin.db; + } + + getViewType(): string { + return DASHBOARD_VIEW_TYPE; + } + + getDisplayText(): string { + return this.currentDashboard?.name ?? 'Dashboard'; + } + + getIcon(): string { + return 'layout-dashboard'; + } + + async onOpen(): Promise { + const container = this.containerEl.children[1] as HTMLElement; + container.empty(); + container.addClass('logfire-dashboard-view'); + + const header = container.createDiv({ cls: 'logfire-dash-header' }); + header.createSpan({ cls: 'logfire-dash-title', text: 'Dashboard' }); + + const actions = header.createDiv({ cls: 'logfire-dash-actions' }); + + const refreshBtn = actions.createEl('button', { + cls: 'logfire-dash-btn clickable-icon', + attr: { 'aria-label': 'Aktualisieren' }, + text: '\u21bb', + }); + refreshBtn.addEventListener('click', () => this.refreshAll()); + + this.contentContainer = container.createDiv({ cls: 'logfire-dash-content' }); + this.showEmptyState(); + } + + // --------------------------------------------------------------------------- + // Public: load a parsed dashboard + // --------------------------------------------------------------------------- + + loadDashboard(dashboard: Dashboard): void { + this.clearTimers(); + this.currentDashboard = dashboard; + + const titleEl = this.containerEl.querySelector('.logfire-dash-title'); + if (titleEl) titleEl.textContent = dashboard.name; + + this.render(); + + if (dashboard.refreshAll && dashboard.refreshAll > 0) { + this.globalRefreshTimer = setInterval(() => this.refreshAll(), dashboard.refreshAll * 1000); + } + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + private render(): void { + this.contentContainer.empty(); + if (!this.currentDashboard || this.currentDashboard.widgets.length === 0) { + this.showEmptyState(); + return; + } + + const grid = this.contentContainer.createDiv({ cls: 'logfire-dash-grid' }); + grid.style.gridTemplateColumns = `repeat(${this.currentDashboard.columns}, 1fr)`; + + for (const widget of this.currentDashboard.widgets) { + this.renderWidget(grid, widget); + } + } + + private showEmptyState(): void { + this.contentContainer.empty(); + this.contentContainer.createDiv({ + cls: 'logfire-empty', + text: 'Kein Dashboard geladen.', + }); + } + + private renderWidget(container: HTMLElement, widget: DashboardWidget): void { + const el = container.createDiv({ cls: 'logfire-dash-widget' }); + el.style.gridColumn = `${widget.position.col + 1} / span ${widget.position.width}`; + el.style.gridRow = `${widget.position.row + 1} / span ${widget.position.height}`; + + if (widget.title) { + el.createDiv({ cls: 'logfire-dash-widget-title', text: widget.title }); + } + + const content = el.createDiv({ cls: 'logfire-dash-widget-content' }); + this.renderWidgetContent(content, widget); + + if (widget.refreshInterval && widget.refreshInterval > 0) { + const timer = setInterval(() => { + if (!document.contains(el)) { + clearInterval(timer); + this.refreshTimers.delete(widget.id); + return; + } + content.empty(); + this.renderWidgetContent(content, widget); + }, widget.refreshInterval * 1000); + this.refreshTimers.set(widget.id, timer); + } + } + + private renderWidgetContent(container: HTMLElement, widget: DashboardWidget): void { + try { + switch (widget.type) { + case 'query': + this.renderQueryWidget(container, widget); + break; + case 'chart': + this.renderChartWidget(container, widget); + break; + case 'stat': + this.renderStatWidget(container, widget); + break; + case 'text': + this.renderTextWidget(container, widget); + break; + } + } catch (err) { + container.createDiv({ + cls: 'logfire-error', + text: `Fehler: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + + private renderQueryWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Query konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + if (rows.length === 0) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Ergebnisse.' }); + return; + } + renderTable(container, rows); + } + + private renderChartWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql || !widget.chartConfig) { + container.createDiv({ cls: 'logfire-empty', text: 'Kein Chart konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + renderChart(container, rows, widget.chartConfig); + } + + private renderStatWidget(container: HTMLElement, widget: DashboardWidget): void { + if (!widget.sql) { + container.createDiv({ cls: 'logfire-empty', text: 'Keine Query konfiguriert.' }); + return; + } + const rows = this.db.queryReadOnly(widget.sql) as Record[]; + renderMetric(container, rows); + } + + private renderTextWidget(container: HTMLElement, widget: DashboardWidget): void { + if (widget.text) { + MarkdownRenderer.render(this.plugin.app, widget.text, container, '', this.plugin); + } + } + + // --------------------------------------------------------------------------- + // Refresh & cleanup + // --------------------------------------------------------------------------- + + private refreshAll(): void { + this.render(); + } + + private clearTimers(): void { + for (const timer of this.refreshTimers.values()) clearInterval(timer); + this.refreshTimers.clear(); + if (this.globalRefreshTimer) { + clearInterval(this.globalRefreshTimer); + this.globalRefreshTimer = null; + } + } + + async onClose(): Promise { + this.clearTimers(); + } +} + +// --------------------------------------------------------------------------- +// Code-block parser: ```logfire-dashboard +// --------------------------------------------------------------------------- + +export function parseDashboardBlock(source: string): Dashboard | null { + const lines = source.split('\n'); + const dashboard: Dashboard = { + id: `dash-${Date.now()}`, + name: 'Dashboard', + widgets: [], + columns: 12, + }; + + let currentWidget: Partial | null = null; + let widgetLines: string[] = []; + + for (const line of lines) { + // Dashboard metadata + const nameMatch = line.match(/^name:\s*(.+)$/i); + if (nameMatch) { dashboard.name = nameMatch[1].trim(); continue; } + + const colsMatch = line.match(/^columns:\s*(\d+)$/i); + if (colsMatch) { dashboard.columns = parseInt(colsMatch[1]); continue; } + + const refreshMatch = line.match(/^refresh:\s*(\d+)$/i); + if (refreshMatch) { dashboard.refreshAll = parseInt(refreshMatch[1]); continue; } + + // Widget header + const widgetMatch = line.match( + /^\[widget:(\w+)\s+row:(\d+)\s+col:(\d+)\s+width:(\d+)\s+height:(\d+)\]$/i, + ); + if (widgetMatch) { + if (currentWidget) finalizeWidget(currentWidget, widgetLines, dashboard.widgets); + currentWidget = { + id: `w-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + type: widgetMatch[1] as DashboardWidget['type'], + position: { + row: parseInt(widgetMatch[2]), + col: parseInt(widgetMatch[3]), + width: parseInt(widgetMatch[4]), + height: parseInt(widgetMatch[5]), + }, + }; + widgetLines = []; + continue; + } + + if (currentWidget) widgetLines.push(line); + } + + if (currentWidget) finalizeWidget(currentWidget, widgetLines, dashboard.widgets); + return dashboard.widgets.length > 0 ? dashboard : null; +} + +function finalizeWidget( + widget: Partial, + lines: string[], + widgets: DashboardWidget[], +): void { + const content = lines.join('\n').trim(); + + // Title directive + const titleMatch = content.match(/^--\s*title:\s*(.+)$/m); + if (titleMatch) widget.title = titleMatch[1].trim(); + + // Refresh directive + const refreshMatch = content.match(/^--\s*refresh:\s*(\d+)$/m); + if (refreshMatch) widget.refreshInterval = parseInt(refreshMatch[1]); + + // Chart config + const chartConfig = parseChartConfig(content); + if (chartConfig) widget.chartConfig = chartConfig; + + // Extract SQL (lines that are not directives) + const sqlLines = content + .split('\n') + .filter(l => !l.match(/^--\s*(title|refresh|chart):/i)); + const sql = sqlLines.join('\n').trim(); + if (sql) widget.sql = sql; + + // Text widget: use full content + if (widget.type === 'text') { + widget.text = content; + widget.sql = undefined; + } + + if (widget.id && widget.position) { + widgets.push(widget as DashboardWidget); + } +} From aba060f3a5faf823ed9c2eb491fc719c2a53174c Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:17:22 +0100 Subject: [PATCH 3/4] Processor: Chart-Support und Dashboard-Block-Prozessor logfire-sql Bloecke unterstuetzen jetzt -- chart: Direktiven fuer Inline-Visualisierung. Neuer logfire-dashboard Block- Prozessor rendert Multi-Widget-Dashboards direkt in Notizen. Co-Authored-By: Claude Opus 4.6 --- src/query/processor.ts | 80 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/src/query/processor.ts b/src/query/processor.ts index 2395931..df73888 100644 --- a/src/query/processor.ts +++ b/src/query/processor.ts @@ -3,6 +3,9 @@ import { QueryConfig, TimeRange, EventType, EventCategory } from '../types'; import { buildQuery } from '../core/query-builder'; import { DatabaseManager } from '../core/database'; import { renderTable, renderTimeline, renderSummary, renderMetric, renderList, renderHeatmap, formatValue } from '../viz/table-renderer'; +import { renderChart, parseChartConfig } from '../viz/chart-renderer'; +import { parseDashboardBlock, DashboardView, DASHBOARD_VIEW_TYPE } from '../viz/dashboard'; +import type LogfirePlugin from '../main'; // --------------------------------------------------------------------------- // Refresh timer management @@ -84,13 +87,20 @@ export function registerLogfireSqlBlock( return; } + const chartConfig = parseChartConfig(source); + try { const rows = db.queryReadOnly(sql) as Record[]; if (!Array.isArray(rows) || rows.length === 0) { el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' }); return; } - renderTable(el, rows); + + if (chartConfig) { + renderChart(el, rows, chartConfig); + } else { + renderTable(el, rows); + } if (refresh && refresh > 0) { setupRefreshTimer(el, () => { @@ -101,7 +111,11 @@ export function registerLogfireSqlBlock( el.createEl('p', { text: 'Keine Ergebnisse.', cls: 'logfire-empty' }); return; } - renderTable(el, freshRows); + if (chartConfig) { + renderChart(el, freshRows, chartConfig); + } else { + renderTable(el, freshRows); + } } catch (err) { renderError(el, err); } @@ -113,6 +127,68 @@ export function registerLogfireSqlBlock( }); } +// --------------------------------------------------------------------------- +// `logfire-dashboard` block — Dashboard Code-Block +// --------------------------------------------------------------------------- + +export function registerLogfireDashboardBlock( + plugin: LogfirePlugin, + db: DatabaseManager, + registerFn: (language: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void, +): void { + registerFn('logfire-dashboard', (source, el, ctx) => { + const dashboard = parseDashboardBlock(source); + if (!dashboard) { + renderError(el, new Error('Ungültige Dashboard-Definition.')); + return; + } + + // Render dashboard inline + const wrapper = el.createDiv({ cls: 'logfire-dash-inline' }); + + if (dashboard.name) { + wrapper.createDiv({ cls: 'logfire-dash-inline-title', text: dashboard.name }); + } + + const grid = wrapper.createDiv({ cls: 'logfire-dash-grid' }); + grid.style.gridTemplateColumns = `repeat(${dashboard.columns}, 1fr)`; + + for (const widget of dashboard.widgets) { + const widgetEl = grid.createDiv({ cls: 'logfire-dash-widget' }); + widgetEl.style.gridColumn = `${widget.position.col + 1} / span ${widget.position.width}`; + widgetEl.style.gridRow = `${widget.position.row + 1} / span ${widget.position.height}`; + + if (widget.title) { + widgetEl.createDiv({ cls: 'logfire-dash-widget-title', text: widget.title }); + } + + const content = widgetEl.createDiv({ cls: 'logfire-dash-widget-content' }); + + try { + if (widget.type === 'text' && widget.text) { + content.createDiv({ text: widget.text }); + } else if (widget.sql) { + const rows = db.queryReadOnly(widget.sql) as Record[]; + if (rows.length === 0) { + content.createDiv({ cls: 'logfire-empty', text: 'Keine Ergebnisse.' }); + } else if (widget.type === 'chart' && widget.chartConfig) { + renderChart(content, rows, widget.chartConfig); + } else if (widget.type === 'stat') { + renderMetric(content, rows); + } else { + renderTable(content, rows); + } + } + } catch (err) { + content.createDiv({ + cls: 'logfire-error', + text: `Fehler: ${err instanceof Error ? err.message : String(err)}`, + }); + } + } + }); +} + // --------------------------------------------------------------------------- // YAML config parsing // --------------------------------------------------------------------------- From 2f7ee27e9b958a86ecef63323a1a436b22b2a478 Mon Sep 17 00:00:00 2001 From: tolvitty Date: Thu, 12 Feb 2026 11:17:28 +0100 Subject: [PATCH 4/4] Visualisierung in main.ts verdrahtet, Chart/Dashboard CSS Dashboard-View registriert, Ribbon-Icon, Befehl und logfire-dashboard Block-Prozessor eingebunden. CSS fuer SVG-Charts, Legenden, Dashboard-Grid und Widgets ergaenzt. Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 40 ++++++++- styles.css | 245 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 2 deletions(-) diff --git a/src/main.ts b/src/main.ts index a1809c3..c6991db 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,9 +13,10 @@ import { LogfireSettingTab } from './ui/settings-tab'; import { InitialScanModal } from './ui/initial-scan-modal'; import { StatusBar } from './ui/status-bar'; import { EventStreamView, EVENT_STREAM_VIEW_TYPE } from './ui/event-stream-view'; -import { registerLogfireBlock, registerLogfireSqlBlock, cleanupAllRefreshTimers } from './query/processor'; +import { registerLogfireBlock, registerLogfireSqlBlock, registerLogfireDashboardBlock, cleanupAllRefreshTimers } from './query/processor'; import { QueryModal } from './query/query-modal'; import { VirtualTableManager } from './query/virtual-tables'; +import { DashboardView, DASHBOARD_VIEW_TYPE } from './viz/dashboard'; export default class LogfirePlugin extends Plugin { settings!: LogfireSettings; @@ -90,6 +91,12 @@ export default class LogfirePlugin extends Plugin { (leaf) => new EventStreamView(leaf, this.eventBus), ); + // UI: Dashboard view + this.registerView( + DASHBOARD_VIEW_TYPE, + (leaf) => new DashboardView(leaf, this), + ); + // UI: Status bar this.statusBar = new StatusBar(this); this.statusBar.start(); @@ -101,11 +108,17 @@ export default class LogfirePlugin extends Plugin { registerLogfireSqlBlock(this.db, (lang, handler) => { this.registerMarkdownCodeBlockProcessor(lang, handler); }); + registerLogfireDashboardBlock(this, this.db, (lang, handler) => { + this.registerMarkdownCodeBlockProcessor(lang, handler); + }); - // Ribbon icon + // Ribbon icons this.addRibbonIcon('activity', 'Logfire: Event-Stream', () => { this.activateEventStream(); }); + this.addRibbonIcon('layout-dashboard', 'Logfire: Dashboard', () => { + this.activateDashboard(); + }); // Commands this.registerCommands(); @@ -298,6 +311,12 @@ export default class LogfirePlugin extends Plugin { }, }); + this.addCommand({ + id: 'show-dashboard', + name: 'Dashboard anzeigen', + callback: () => this.activateDashboard(), + }); + this.addCommand({ id: 'open-query', name: 'Query-Editor \u00f6ffnen', @@ -340,6 +359,23 @@ export default class LogfirePlugin extends Plugin { } } + // --------------------------------------------------------------------------- + // Dashboard view + // --------------------------------------------------------------------------- + + private async activateDashboard(): Promise { + const existing = this.app.workspace.getLeavesOfType(DASHBOARD_VIEW_TYPE); + if (existing.length > 0) { + this.app.workspace.revealLeaf(existing[0]); + return; + } + const leaf = this.app.workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ type: DASHBOARD_VIEW_TYPE, active: true }); + this.app.workspace.revealLeaf(leaf); + } + } + // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- diff --git a/styles.css b/styles.css index 6c23a8a..d12233c 100644 --- a/styles.css +++ b/styles.css @@ -679,3 +679,248 @@ white-space: pre-wrap; word-break: break-word; } + +/* ═══════════════════════════════════════════════════════════════════════════ + Charts — SVG Visualization + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-chart-wrapper { + padding: 8px 0; +} + +.logfire-chart-title { + font-family: var(--font-monospace); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-muted); + padding: 0 0 6px 0; +} + +.logfire-chart-svg { + display: block; + max-width: 100%; + height: auto; +} + +.logfire-chart-svg text { + font-family: var(--font-monospace); + font-size: 10px; + fill: var(--text-muted); +} + +.logfire-chart-svg .logfire-chart-label { + font-size: 9.5px; + fill: var(--text-faint); +} + +.logfire-chart-svg .logfire-chart-axis-label { + font-size: 9px; + fill: var(--text-faint); + font-variant-numeric: tabular-nums; +} + +.logfire-chart-svg .logfire-chart-value { + font-size: 10px; + fill: var(--text-normal); + font-variant-numeric: tabular-nums; +} + +.logfire-chart-svg .logfire-chart-center { + font-size: 18px; + font-weight: 700; + fill: var(--text-normal); +} + +.logfire-chart-svg .logfire-chart-gauge-value { + font-size: 22px; + font-weight: 700; + fill: var(--text-normal); +} + +.logfire-chart-svg .logfire-chart-bar { + transition: opacity 100ms ease; +} + +.logfire-chart-svg .logfire-chart-bar:hover { + opacity: 0.8; +} + +.logfire-chart-svg .logfire-chart-point { + transition: r 100ms ease; +} + +.logfire-chart-svg .logfire-chart-point:hover { + r: 5.5; +} + +.logfire-chart-svg .logfire-chart-slice { + transition: opacity 100ms ease; +} + +.logfire-chart-svg .logfire-chart-slice:hover { + opacity: 0.85; +} + +/* --------------------------------------------------------------------------- + Chart Legend + --------------------------------------------------------------------------- */ + +.logfire-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding: 6px 0 0 0; + font-family: var(--font-monospace); + font-size: 10px; + color: var(--text-muted); +} + +.logfire-legend-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.logfire-legend-swatch { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} + +/* ═══════════════════════════════════════════════════════════════════════════ + Dashboard — View & Inline + ═══════════════════════════════════════════════════════════════════════════ */ + +.logfire-dashboard-view { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + background: var(--background-primary); +} + +/* --------------------------------------------------------------------------- + Dashboard Header + --------------------------------------------------------------------------- */ + +.logfire-dash-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + flex-shrink: 0; +} + +.logfire-dash-title { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-normal); + letter-spacing: -0.01em; +} + +.logfire-dash-actions { + display: flex; + gap: 4px; +} + +.logfire-dash-btn { + padding: 3px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: 13px; + cursor: pointer; + transition: background 100ms ease, color 100ms ease; +} + +.logfire-dash-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* --------------------------------------------------------------------------- + Dashboard Content & Grid + --------------------------------------------------------------------------- */ + +.logfire-dash-content { + flex: 1; + overflow: auto; + padding: 10px; +} + +.logfire-dash-grid { + display: grid; + gap: 8px; + min-height: 0; +} + +/* --------------------------------------------------------------------------- + Dashboard Widget + --------------------------------------------------------------------------- */ + +.logfire-dash-widget { + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.logfire-dash-widget-title { + padding: 5px 10px; + font-family: var(--font-monospace); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); +} + +.logfire-dash-widget-content { + flex: 1; + overflow: auto; + min-height: 0; +} + +.logfire-dash-widget-content::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +.logfire-dash-widget-content::-webkit-scrollbar-track { + background: transparent; +} + +.logfire-dash-widget-content::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 2px; +} + +/* --------------------------------------------------------------------------- + Dashboard Inline (Code-Block Rendering) + --------------------------------------------------------------------------- */ + +.logfire-dash-inline { + padding: 4px 0; +} + +.logfire-dash-inline-title { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 600; + color: var(--text-normal); + padding: 0 0 8px 0; + letter-spacing: -0.01em; +}