215 lines
8.8 KiB
JavaScript
215 lines
8.8 KiB
JavaScript
// 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>`;
|
|
}
|