// LPCharts — tiny, dependency-free SVG charts for the Admin → System page. // No external libraries or CDN calls (LibrePortal ships no third-party frontend // assets). Every renderer returns an SVG string and colours itself from the // active theme's CSS variables, so charts re-theme for free. Stroke widths use // vector-effect:non-scaling-stroke so lines stay crisp when the SVG is stretched // to its container width. const LPCharts = (() => { let _uid = 0; const uid = (p) => `${p}-${(++_uid)}`; // name -> { line: "var(--name)", fill: "rgba(var(--name-rgb), a)" } const palette = (name = 'accent') => ({ line: `var(--${name})`, rgb: `var(--${name}-rgb)` }); // Map a series of numbers to SVG path data within a [0,W]x[0,H] box. // Returns { line, area } path strings. `max`/`min` fix the y-range. function paths(values, W, H, pad, min, max) { const n = values.length; if (n === 0) return { line: '', area: '' }; const span = (max - min) || 1; const innerH = H - pad * 2; const stepX = n > 1 ? (W / (n - 1)) : 0; const pts = values.map((v, i) => { const x = n > 1 ? i * stepX : W / 2; const y = pad + innerH * (1 - (Math.max(min, Math.min(max, v)) - min) / span); return [x, y]; }); const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' '); const area = `${line} L${pts[n - 1][0].toFixed(1)},${H} L${pts[0][0].toFixed(1)},${H} Z`; return { line, area }; } // Single filled area chart. values: number[]. opts: { color, height, max, // min, unit, fmt }. Returns an that fills its container width. function areaChart(values, opts = {}) { const W = 300, H = opts.height || 90, pad = 6; const color = palette(opts.color || 'accent'); if (!values || values.length === 0) { return `
No data yet
`; } const dataMax = Math.max(...values); const dataMin = Math.min(...values); const max = (opts.max !== undefined) ? opts.max : (dataMax <= 0 ? 1 : dataMax * 1.15); const min = (opts.min !== undefined) ? opts.min : Math.min(0, dataMin); const { line, area } = paths(values, W, H, pad, min, max); const id = uid('grad'); const last = values[values.length - 1]; const fmt = opts.fmt || ((v) => v); return `
${fmt(last)}
`; } // Multiple overlaid lines on a shared y-range. series: [{values, color, label}]. function multiLine(series, opts = {}) { const W = 300, H = opts.height || 90, pad = 6; const all = series.flatMap(s => s.values || []); if (all.length === 0) return `
No data yet
`; const max = (opts.max !== undefined) ? opts.max : (Math.max(...all) * 1.15 || 1); const min = (opts.min !== undefined) ? opts.min : 0; const lines = series.map(s => { const c = palette(s.color || 'accent'); const { line } = paths(s.values || [], W, H, pad, min, max); return ``; }).join(''); return `${lines}`; } // Inline mini line (no axes/fill). For per-app rows. function sparkline(values, opts = {}) { const W = 100, H = opts.height || 24, pad = 2; const color = palette(opts.color || 'accent'); if (!values || values.length < 2) return ``; const max = Math.max(...values) || 1; const { line } = paths(values, W, H, pad, 0, max * 1.1); return ` `; } // Circular ring gauge. value 0..max. opts: { label, color, sublabel, max }. function gauge(value, opts = {}) { const max = opts.max || 100; const pct = Math.max(0, Math.min(1, (value || 0) / max)); const color = palette(opts.color || pickColor(pct)); const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct); return `
${opts.display !== undefined ? opts.display : Math.round(value)}${opts.suffix || '%'}
${opts.sublabel ? `
${opts.sublabel}
` : ''}
${opts.label || ''}
`; } // Horizontal percentage bar. function bar(pct, opts = {}) { const p = Math.max(0, Math.min(100, pct || 0)); const color = palette(opts.color || pickColor(p / 100)); return `
`; } // Green < 70% < amber < 90% < red — the universal "headroom" cue. function pickColor(frac) { if (frac >= 0.9) return 'status-danger'; if (frac >= 0.7) return 'status-warning'; return 'status-success'; } // Resolve "var(--x-rgb)" to its literal triplet so it can sit inside a // gradient stop's rgba() (SVG gradients don't inherit CSS custom props // reliably across browsers when nested in rgba()). function getRGB(varExpr) { const name = varExpr.match(/--[a-z0-9-]+/i); if (!name) return '0,212,255'; const v = getComputedStyle(document.documentElement).getPropertyValue(name[0]).trim(); return v || '0,212,255'; } return { areaChart, multiLine, sparkline, gauge, bar, pickColor }; })(); window.LPCharts = LPCharts;