librelad 67a841299c ux(system): disk ring shows LibrePortal as a portion + LP line on disk page
Replace the disk gauge's concentric inner ring with a single ring whose
leading portion is coloured to mark the LibrePortal share of the disk
(one ring: total disk used overall, LibrePortal highlighted within it).
On the full-screen Disk metric page, add a flat reference line marking
LibrePortal's current share alongside the disk-usage trend. The gauge
gains a `segment` option; the chart line is a "now" value (no historical
LP series yet), so it's flat across the range.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-28 22:59:49 +01:00

159 lines
8.1 KiB
JavaScript

// 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 <svg> 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 `<div class="lp-chart-empty">No data yet</div>`;
}
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 `
<svg class="lp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">
<defs>
<linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(${getRGB(color.rgb)}, 0.35)"/>
<stop offset="100%" stop-color="rgba(${getRGB(color.rgb)}, 0.02)"/>
</linearGradient>
</defs>
<path d="${area}" fill="url(#${id})" stroke="none"/>
<path d="${line}" fill="none" stroke="${color.line}" stroke-width="2"
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
<div class="lp-chart-last" style="color:${color.line}">${fmt(last)}</div>`;
}
// 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 `<div class="lp-chart-empty">No data yet</div>`;
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 `<path d="${line}" fill="none" stroke="${c.line}" stroke-width="2"
vector-effect="non-scaling-stroke" stroke-linejoin="round" stroke-linecap="round"/>`;
}).join('');
return `<svg class="lp-chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img">${lines}</svg>`;
}
// 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 `<svg class="lp-spark" viewBox="0 0 ${W} ${H}"></svg>`;
const max = Math.max(...values) || 1;
const { line } = paths(values, W, H, pad, 0, max * 1.1);
return `<svg class="lp-spark" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<path d="${line}" fill="none" stroke="${color.line}" stroke-width="1.5"
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
}
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max,
// segment }. segment = { value, color } colours the LEADING part of the used
// arc differently — a sub-share within the same ring (e.g. the slice of the
// disk that's LibrePortal). When present, caps are butt so the split is crisp.
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);
const cap = opts.segment ? 'butt' : 'round';
let segArc = '';
if (opts.segment) {
const seg = Math.max(0, Math.min(pct, (opts.segment.value || 0) / max));
if (seg > 0) {
const sc = palette(opts.segment.color || 'accent');
segArc = `<circle cx="60" cy="60" r="${r}" fill="none" stroke="${sc.line}" stroke-width="10"
stroke-linecap="butt" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${(C * (1 - seg)).toFixed(1)}"
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>`;
}
}
return `
<div class="lp-gauge">
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
<circle cx="60" cy="60" r="${r}" fill="none" stroke="rgba(var(--text-rgb),0.10)" stroke-width="10"/>
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
stroke-linecap="${cap}" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
${segArc}
</svg>
<div class="lp-gauge-center">
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>
${opts.sublabel ? `<div class="lp-gauge-sub">${opts.sublabel}</div>` : ''}
</div>
<div class="lp-gauge-label">${opts.label || ''}</div>
</div>`;
}
// 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 `<div class="lp-bar"><span class="lp-bar-fill" style="width:${p}%;background:${color.line}"></span></div>`;
}
// 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;