Final modularization layout (user-chosen): every page is a self-contained folder under components/<id>/ (controllers + CSS + its html fragment), and all shared/framework code folds into core/: core/kernel (feature-registry, lifecycle, services, spa) core/boot (auth, system-loader/orchestrator, setup, loaders) core/lib (data-loader, router, helpers, the task kernel, shared modules) core/ui (topbar, modal, notifications, … + topbar.html) core/css (all shared stylesheets) core/icons Top level is now just: components/, core/, themes/, index.html (+ runtime data/). Every path reference rewritten (index.html, scripts arrays, fetch()/ loadFragment()/loadScript() literals, system-loader + config-manager controller paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The /api/features/list endpoint NAME is unchanged (it now scans components/). Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js). Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
159 lines
8.1 KiB
JavaScript
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;
|