Files
AtreidesAssetsFree/client/ui/sparkline.js
T

214 lines
8.8 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:eb749d1dca35ea9b39abff8cba70a365f5bed2b3d725946f3e7eb2d00e35dd60
import { esc } from './escape.js';
import { fmtPct } from './format.js';
let uid = 0;
const VIEW_W = 100, VIEW_H = 100, PAD_X = 0, PAD_Y = 3;
const DAY_MS = 86400000;
const GRAD_FADE_AT = 0.85;
const SNAP_PX = 10;
export function labelLeft(x, labelW, chartW, gap = 4) {
const half = labelW / 2 + gap;
if (half * 2 >= chartW) return chartW / 2;
return Math.min(chartW - half, Math.max(half, x));
}
export function magnetischeIndex(punten, markeringen, fx, tolerantie) {
let bestI = 0, bestD = Infinity;
punten.forEach((p, i) => { const d = Math.abs(p.fx - fx); if (d < bestD) { bestD = d; bestI = i; } });
let markI = -1, markD = Infinity;
for (const i of markeringen) {
if (!punten[i]) continue;
const d = Math.abs(punten[i].fx - fx);
if (d < markD) { markD = d; markI = i; }
}
return (markI >= 0 && markD <= tolerantie) ? markI : bestI;
}
export function trapPunten(punten) {
const uit = [];
for (let i = 0; i < punten.length; i++) {
if (i > 0 && Math.abs(punten[i - 1].py - punten[i].py) >= 0.01) {
uit.push({ px: punten[i].px, py: punten[i - 1].py });
}
uit.push(punten[i]);
}
return uit;
}
export function smoothPath(punten) {
const p = punten;
const n = p.length;
if (!n) return '';
const f = v => v.toFixed(2);
if (n < 3) return `M${f(p[0].px)},${f(p[0].py)}` + (n === 2 ? ` L${f(p[1].px)},${f(p[1].py)}` : '');
const h = [], s = [];
for (let i = 0; i < n - 1; i++) {
h.push(p[i + 1].px - p[i].px);
s.push(h[i] ? (p[i + 1].py - p[i].py) / h[i] : 0);
}
const m = [s[0]];
for (let i = 1; i < n - 1; i++) {
if (s[i - 1] * s[i] <= 0) { m.push(0); continue; }
const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1];
m.push((w1 + w2) / (w1 / s[i - 1] + w2 / s[i]));
}
m.push(s[n - 2]);
let d = `M${f(p[0].px)},${f(p[0].py)}`;
for (let i = 0; i < n - 1; i++) {
const t = h[i] / 3;
d += ` C${f(p[i].px + t)},${f(p[i].py + m[i] * t)} ${f(p[i + 1].px - t)},${f(p[i + 1].py - m[i + 1] * t)} ${f(p[i + 1].px)},${f(p[i + 1].py)}`;
}
return d;
}
export function mountSparkline(host, points, { tone = 'accent', formatValue = String, markers = [], vergelijk = null,
snap = [], onPick = null, yRange = null, smooth = true } = {}) {
host.innerHTML = '';
if (!points || points.length < 2) return;
const tweede = Array.isArray(vergelijk) && vergelijk.length === points.length ? vergelijk : null;
const ys = points.map(p => p[1]).concat(tweede ? tweede.map(p => p[1]) : []);
const minX = points[0][0], maxX = points[points.length - 1][0];
const minY = yRange ? yRange[0] : Math.min(...ys), maxY = yRange ? yRange[1] : Math.max(...ys);
const spanX = (maxX - minX) || 1, spanY = (maxY - minY) || 1;
const pts = points.map(([x, y]) => {
const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X);
const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y);
return { fx: px / VIEW_W, fy: py / VIEW_H, px, py, price: y, x };
});
const cmpPts2 = tweede ? tweede.map(([x, y]) => {
const px = PAD_X + ((x - minX) / spanX) * (VIEW_W - 2 * PAD_X);
const py = VIEW_H - PAD_Y - ((y - minY) / spanY) * (VIEW_H - 2 * PAD_Y);
return { fx: px / VIEW_W, fy: py / VIEW_H, px, py };
}) : null;
const markPts = cmpPts2 || pts;
const markIdx = [];
const gezien = new Set();
let markHtml = '';
for (const m of markers) {
const n = dichtstbij(pts, m?.x);
const variant = String(m?.variant || '').replace(/[^a-z0-9-]/gi, '');
const label = m?.label == null ? '' : String(m.label);
const key = m?.key == null ? '' : String(m.key).replace(/[^a-z0-9_:-]/gi, '');
const sleutel = `${variant}|${n}`;
if (n < 0 || gezien.has(sleutel)) continue;
gezien.add(sleutel);
markIdx.push(n);
markHtml += `<div class="spark-mini-mark${variant ? ` is-${variant}` : ''}"${key ? ` data-mark="${key}"` : ''}`
+ (label ? ` title="${esc(label)}"` : '')
+ ` style="--spark-x:${(markPts[n].fx * 100).toFixed(2)}%;top:${(markPts[n].fy * 100).toFixed(2)}%"></div>`;
}
for (const x of snap) { const n = dichtstbij(pts, x); if (n >= 0) markIdx.push(n); }
const linePts = pts.map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' ');
const cmpPts = cmpPts2
? trapPunten(cmpPts2).map(p => `${p.px.toFixed(2)},${p.py.toFixed(2)}`).join(' ')
: '';
const areaPts = `${pts[0].px.toFixed(2)},${VIEW_H} ${linePts} ${pts[pts.length - 1].px.toFixed(2)},${VIEW_H}`;
const gid = `spk-grad-${++uid}`;
const curve = smooth ? smoothPath(pts) : '';
const lineAttrs = `fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"`;
const areaSvg = smooth
? `<path class="spark-mini-area" d="M${pts[0].px.toFixed(2)},${VIEW_H} L${curve.slice(1)} L${pts[pts.length - 1].px.toFixed(2)},${VIEW_H} Z" fill="url(#${gid})" stroke="none"/>`
: `<polygon class="spark-mini-area" points="${areaPts}" fill="url(#${gid})" stroke="none"/>`;
const lineSvg = smooth
? `<path class="spark-mini-line" d="${curve}" ${lineAttrs}/>`
: `<polyline class="spark-mini-line" points="${linePts}" ${lineAttrs}/>`;
const root = document.createElement('div');
root.className = `spark-mini is-${tone}${onPick ? ' is-pickable' : ''}`;
root.innerHTML = `
<svg class="spark-mini-svg" viewBox="0 0 ${VIEW_W} ${VIEW_H}" preserveAspectRatio="none" aria-hidden="true">
<defs>
<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
<stop class="spark-grad-top" offset="0"/>
<stop class="spark-grad-bot" offset="${GRAD_FADE_AT}"/>
</linearGradient>
</defs>
${areaSvg}
${cmpPts ? `<polyline class="spark-mini-cmp" points="${cmpPts}" fill="none" stroke="currentColor" stroke-width="1"
stroke-dasharray="3 3" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>` : ''}
${lineSvg}
</svg>
<div class="spark-mini-clip">
${markHtml}
<div class="spark-mini-vline" hidden></div>
<div class="spark-mini-dot" hidden></div>
</div>
<div class="spark-mini-label" hidden></div>`;
host.appendChild(root);
const vline = root.querySelector('.spark-mini-vline');
const dot = root.querySelector('.spark-mini-dot');
const label = root.querySelector('.spark-mini-label');
function indexAt(e) {
const rect = root.getBoundingClientRect();
if (!rect.width) return -1;
const fx = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
return magnetischeIndex(pts, markIdx, fx, SNAP_PX / rect.width);
}
function show(e) {
const bestI = indexAt(e);
if (bestI < 0) return;
const best = pts[bestI];
const leftPct = best.fx * 100, topPct = best.fy * 100;
vline.style.left = `${leftPct}%`;
dot.style.setProperty('--spark-x', `${leftPct}%`);
dot.style.top = `${topPct}%`;
label.textContent = formatValue(best.price, best.x, tweede ? tweede[bestI]?.[1] : undefined);
label.classList.toggle('is-below', best.fy < 0.5);
vline.hidden = dot.hidden = label.hidden = false;
const rw = root.clientWidth;
label.style.left = rw
? `${labelLeft(best.fx * rw, label.offsetWidth, rw)}px`
: `${Math.min(88, Math.max(12, leftPct))}%`;
label.style.top = `${topPct}%`;
}
function hide() { vline.hidden = dot.hidden = label.hidden = true; }
root.addEventListener('pointermove', show);
root.addEventListener('pointerdown', show);
root.addEventListener('pointerleave', hide);
root.addEventListener('pointercancel', hide);
if (onPick) root.addEventListener('click', e => { const i = indexAt(e); if (i >= 0) onPick(pts[i].x, i); });
}
function dichtstbij(pts, x) {
let best = -1, bestD = Infinity;
for (let i = 0; i < pts.length; i++) {
const d = Math.abs(pts[i].x - x);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
export function withSpot(points, price, nowMs = Date.now()) {
if (!Array.isArray(points) || !points.length) return points;
if (!Number.isFinite(price) || price <= 0) return points;
const vandaag = Math.floor(nowMs / DAY_MS) * DAY_MS;
const laatste = points[points.length - 1][0];
if (laatste > vandaag) return points;
if (laatste === vandaag) return [...points.slice(0, -1), [vandaag, price]];
return [...points, [vandaag, price]];
}
export const sliceTone = slice => (slice[slice.length - 1][1] >= slice[0][1] ? 'up' : 'down');
export function changePctHtml(slice, suffixHtml = '', { fine = false, signs = 'negative' } = {}) {
if (!slice || slice.length < 2) return '';
const first = slice[0][1], last = slice[slice.length - 1][1];
if (!(first > 0)) return '';
const change = (last - first) / first;
return `<span class="spark-change delta ${change >= 0 ? 't-pos' : 't-neg'}">${fmtPct(change, { fine, signs })}${suffixHtml}</span>`;
}