362 lines
13 KiB
JavaScript
362 lines
13 KiB
JavaScript
// Generated, do not edit. sha256:2f0f6627ca3113d46ca463586c34fddd9f180542b18f6825cae3c17d5a06bf19
|
|
import { loadCharting, readPalette } from '../ui/chart-setup.js';
|
|
import { tintSeries } from '../ui/charting.js';
|
|
import { t, localeTag } from '../i18n/index.js';
|
|
import { fmtBase } from '../ui/format.js';
|
|
import { ENUMS } from '../../shared/config.js';
|
|
|
|
export function ringData(kaarten, { restVanaf = 0.02 } = {}) {
|
|
const bruikbaar = kaarten.filter(k => typeof k.m.value === 'number' && k.m.value > 0);
|
|
const alle = bruikbaar
|
|
.map(k => ({ ids: [k.asset.id], kinds: [k.asset.kind], label: k.asset.symbol, value: k.m.value, kind: k.asset.kind }))
|
|
.sort((a, b) => b.value - a.value);
|
|
const perSoort = new Map();
|
|
for (const a of alle) {
|
|
const vorig = perSoort.get(a.kind) || { value: 0, ids: [] };
|
|
perSoort.set(a.kind, { value: vorig.value + a.value, ids: [...vorig.ids, ...a.ids] });
|
|
}
|
|
|
|
const totaal = alle.reduce((s, a) => s + a.value, 0);
|
|
const grens = totaal * restVanaf;
|
|
const klein = alle.filter(a => a.value < grens);
|
|
const assets = klein.length >= 2
|
|
? [...alle.filter(a => a.value >= grens), {
|
|
ids: klein.flatMap(a => a.ids),
|
|
kinds: [...new Set(klein.flatMap(a => a.kinds))],
|
|
label: `${t('wealth.ringOther')} (${klein.length})`,
|
|
value: klein.reduce((s, a) => s + a.value, 0),
|
|
kind: null,
|
|
}]
|
|
: alle;
|
|
const soorten = ENUMS.ASSET_KIND
|
|
.filter(kind => perSoort.has(kind))
|
|
.map(kind => ({
|
|
kind, label: t('enums.asset_kind.' + kind),
|
|
value: perSoort.get(kind).value, ids: perSoort.get(kind).ids, kinds: [kind],
|
|
}));
|
|
|
|
return { soorten, assets };
|
|
}
|
|
|
|
export function segmentIndices(segmenten = [], sel) {
|
|
if (!sel) return [];
|
|
const uit = [];
|
|
if (sel.assetIds?.length) {
|
|
const wil = new Set(sel.assetIds);
|
|
segmenten.forEach((s, i) => { if ((s.ids || []).some(id => wil.has(id))) uit.push(i); });
|
|
return uit;
|
|
}
|
|
if (sel.kind) {
|
|
segmenten.forEach((s, i) => { if ((s.kinds || []).includes(sel.kind)) uit.push(i); });
|
|
}
|
|
return uit;
|
|
}
|
|
|
|
export function uitsteek(aandeel, max = UITSTEEK) {
|
|
const a = Math.min(Math.max(Number(aandeel) || 0, 0), 1);
|
|
return Math.round(max * (1 - a));
|
|
}
|
|
|
|
export function nadrukKleuren(basis = [], aan = new Set()) {
|
|
return basis.map((k, n) => (aan.size === 0 || aan.has(n) ? k : dim(k)));
|
|
}
|
|
|
|
export function glansStops(rgba) {
|
|
const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba));
|
|
if (!m) return [rgba, rgba];
|
|
const [r, g, b, a] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
const licht = c => Math.round(c + (255 - c) * 0.18);
|
|
const binnen = Math.round(a * 0.72 * 1000) / 1000;
|
|
const buiten = Math.round(Math.min(1, a * 1.1) * 1000) / 1000;
|
|
return [`rgba(${r},${g},${b},${binnen})`, `rgba(${licht(r)},${licht(g)},${licht(b)},${buiten})`];
|
|
}
|
|
|
|
export function outerEdge(arc, { offset = 0, spacing = 0 } = {}) {
|
|
const { x, y, startAngle, endAngle, innerRadius = 0, outerRadius } = arc;
|
|
const g = endAngle - startAngle;
|
|
const mid = (startAngle + endAngle) / 2;
|
|
const shift = offset / 4;
|
|
const grow = shift * (1 - Math.sin(Math.min(Math.PI, g || 0)));
|
|
const half = spacing / 2;
|
|
const r = Math.max(outerRadius + half + grow, 0);
|
|
let trimSpacing = 0;
|
|
if (half) {
|
|
const avg = ((innerRadius > 0 ? innerRadius - half : 0) + (r > 0 ? r - half : 0)) / 2;
|
|
trimSpacing = (g - (avg !== 0 ? (g * avg) / (avg + half) : g)) / 2;
|
|
}
|
|
const trimOffset = r > 0 ? (g - Math.max(0.001, g * r - grow / Math.PI) / r) / 2 : 0;
|
|
return {
|
|
cx: x + Math.cos(mid) * shift, cy: y + Math.sin(mid) * shift, r,
|
|
start: startAngle + trimOffset + trimSpacing, end: endAngle - trimOffset - trimSpacing,
|
|
};
|
|
}
|
|
|
|
export function dim(rgba, factor = 0.35) {
|
|
const m = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/.exec(String(rgba));
|
|
if (!m) return rgba;
|
|
const a = Math.round(Number(m[4]) * factor * 1000) / 1000;
|
|
return `rgba(${m[1]},${m[2]},${m[3]},${a})`;
|
|
}
|
|
|
|
const UITSTEEK = 14;
|
|
|
|
const REST_UITSTEEK = 6;
|
|
const basisUitsteek = segmenten => segmenten.map(s => (s.kind === null ? REST_UITSTEEK : 0));
|
|
|
|
const PCT_IN_BOOG = 5;
|
|
|
|
const pctTekst = pct => `${pct.toLocaleString(localeTag(), { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
|
|
|
|
function pasNadrukToe(chart) {
|
|
const ds = chart?.data?.datasets?.[0];
|
|
if (!ds) return;
|
|
const st = chart._vermogen || {};
|
|
const aan = new Set([...(st.hover || []), ...(st.selectie || [])]);
|
|
ds._kleuren = nadrukKleuren(ds._basisKleuren, aan);
|
|
if (!ds._glans) ds.backgroundColor = ds._kleuren;
|
|
const basis = ds._basisOffset || [];
|
|
const totaal = (ds.data || []).reduce((s, v) => s + v, 0);
|
|
ds.offset = basis.map((b, n) => (aan.has(n) ? b + uitsteek(totaal > 0 ? ds.data[n] / totaal : 0) : b));
|
|
chart.update('none');
|
|
}
|
|
|
|
export async function tekenVerdeling(canvasSoorten, canvasAssets, kaarten, { onHover = () => {}, onSelect = () => {} } = {}) {
|
|
const { soorten, assets } = ringData(kaarten);
|
|
if (!assets.length) return null;
|
|
|
|
const Chart = await loadCharting();
|
|
const pal = readPalette();
|
|
const grafieken = [
|
|
canvasSoorten && bouwRing(Chart, pal, canvasSoorten, soorten, { onHover, onSelect, soortRing: true }),
|
|
canvasAssets && bouwRing(Chart, pal, canvasAssets, assets, { onHover, onSelect, soortRing: false }),
|
|
].filter(Boolean);
|
|
if (!grafieken.length) return null;
|
|
|
|
const zet = (veld, sel) => {
|
|
for (const g of grafieken) {
|
|
g._vermogen = { ...g._vermogen, [veld]: segmentIndices(g.data.datasets[0]._segmenten, sel) };
|
|
pasNadrukToe(g);
|
|
}
|
|
};
|
|
return {
|
|
markeer: sel => zet('hover', sel),
|
|
selecteer: sel => zet('selectie', sel),
|
|
destroy: () => { grafieken.forEach(g => g.destroy()); hideRingTip(); },
|
|
};
|
|
}
|
|
|
|
let ringTipEl = null;
|
|
const hideRingTip = () => { if (ringTipEl) ringTipEl.classList.remove('is-visible'); };
|
|
|
|
function ringTip({ chart, tooltip }, deel) {
|
|
if (!tooltip || tooltip.opacity === 0 || !tooltip.dataPoints?.length) { hideRingTip(); return; }
|
|
if (!ringTipEl || !ringTipEl.isConnected) {
|
|
ringTipEl = document.createElement('div');
|
|
ringTipEl.className = 'ring-tip';
|
|
ringTipEl.setAttribute('role', 'tooltip');
|
|
document.body.appendChild(ringTipEl);
|
|
window.addEventListener('scroll', hideRingTip, { capture: true, passive: true });
|
|
}
|
|
const punt = tooltip.dataPoints[0];
|
|
const bedrag = document.createElement('span');
|
|
bedrag.className = 'val';
|
|
bedrag.textContent = fmtBase(punt.parsed);
|
|
const naam = punt.dataset._segmenten?.[punt.dataIndex]?.label ?? '';
|
|
const naamEl = document.createElement('span');
|
|
naamEl.className = 'ring-tip-label';
|
|
naamEl.textContent = naam;
|
|
const pct = document.createElement('span');
|
|
pct.className = 'ring-tip-pct';
|
|
pct.textContent = pctTekst(deel(punt.parsed));
|
|
const regel = document.createElement('span');
|
|
regel.className = 'ring-tip-row';
|
|
regel.append(bedrag, pct);
|
|
ringTipEl.replaceChildren(naamEl, regel);
|
|
const r = chart.canvas.getBoundingClientRect();
|
|
ringTipEl.style.left = `${Math.round(r.left + tooltip.caretX)}px`;
|
|
ringTipEl.style.top = `${Math.round(r.top + tooltip.caretY)}px`;
|
|
ringTipEl.classList.add('is-visible');
|
|
}
|
|
|
|
function bouwRing(Chart, pal, canvas, segmenten, { onHover, onSelect, soortRing }) {
|
|
if (!segmenten.length) return null;
|
|
const kleuren = tintSeries(pal.accent, segmenten.length);
|
|
const totaal = segmenten.reduce((s, a) => s + a.value, 0);
|
|
const deel = v => (totaal > 0 ? (v / totaal) * 100 : 0);
|
|
const meldingVan = n => (soortRing
|
|
? { kind: segmenten[n].kind }
|
|
: { assetIds: segmenten[n].ids ?? [] });
|
|
let laatst = null;
|
|
|
|
const grafiek = new Chart(canvas, {
|
|
type: 'doughnut',
|
|
plugins: [glasPlugin(pal), percentagePlugin(pal), middenLabelPlugin(pal, deel)],
|
|
data: {
|
|
datasets: [{
|
|
data: segmenten.map(s => s.value),
|
|
backgroundColor: pal.ringSheen ? glansVulling : kleuren,
|
|
_kleuren: kleuren,
|
|
_glans: pal.ringSheen,
|
|
spacing: pal.ringSeam,
|
|
borderWidth: pal.ringSeam > 0 ? 0 : 1,
|
|
_segmenten: segmenten,
|
|
_basisKleuren: kleuren,
|
|
offset: basisUitsteek(segmenten), _basisOffset: basisUitsteek(segmenten),
|
|
hoverOffset: 0,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
layout: { padding: REST_UITSTEEK + UITSTEEK + 4 },
|
|
onHover: (evt, actief) => {
|
|
const el = actief[0];
|
|
if (!el || el.index === laatst) return;
|
|
laatst = el.index;
|
|
onHover(meldingVan(el.index));
|
|
},
|
|
onClick: (evt, actief) => {
|
|
const el = actief[0];
|
|
return onSelect(el ? meldingVan(el.index) : null);
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
enabled: false,
|
|
external: ctx => ringTip(ctx, deel),
|
|
},
|
|
},
|
|
borderColor: pal.tooltipBg,
|
|
borderWidth: 1,
|
|
cutout: '58%',
|
|
},
|
|
});
|
|
|
|
canvas.addEventListener('mouseleave', () => { laatst = null; onHover(null); });
|
|
return grafiek;
|
|
}
|
|
|
|
function glansVulling(context) {
|
|
const kleur = context.dataset._kleuren?.[context.dataIndex];
|
|
const { chart } = context;
|
|
const area = chart?.chartArea;
|
|
if (!kleur || !area) return kleur;
|
|
const r = Math.min(area.right - area.left, area.bottom - area.top) / 2;
|
|
if (!(r > 0)) return kleur;
|
|
const cx = (area.left + area.right) / 2;
|
|
const cy = (area.top + area.bottom) / 2;
|
|
const [binnen, buiten] = glansStops(kleur);
|
|
const g = chart.ctx.createRadialGradient(cx, cy, r * 0.58, cx, cy, r);
|
|
g.addColorStop(0, binnen);
|
|
g.addColorStop(1, buiten);
|
|
return g;
|
|
}
|
|
|
|
function glasPlugin(pal) {
|
|
return {
|
|
id: 'wealth-glass',
|
|
afterDatasetsDraw(c) {
|
|
if (!pal.ringSheen) return;
|
|
const meta = c.getDatasetMeta(0);
|
|
if (!meta?.data?.length || meta.hidden) return;
|
|
const { ctx } = c;
|
|
const st = c._vermogen || {};
|
|
const aan = new Set([...(st.hover || []), ...(st.selectie || [])]);
|
|
if (aan.size && pal.accentGlow) {
|
|
ctx.save();
|
|
ctx.shadowColor = pal.accentGlow;
|
|
ctx.shadowBlur = 18;
|
|
for (const n of aan) meta.data[n]?.draw(ctx);
|
|
ctx.restore();
|
|
}
|
|
if (!pal.hairline) return;
|
|
const eerste = meta.data[0];
|
|
const lijn = ctx.createLinearGradient(0, eerste.y - eerste.outerRadius, 0, eerste.y + eerste.outerRadius);
|
|
lijn.addColorStop(0, pal.hairline);
|
|
lijn.addColorStop(1, pal.rimBottom || 'transparent');
|
|
ctx.save();
|
|
ctx.strokeStyle = lijn;
|
|
ctx.lineWidth = 1;
|
|
for (const boog of meta.data) {
|
|
const rand = outerEdge(boog, { offset: boog.options?.offset || 0, spacing: boog.options?.spacing || 0 });
|
|
if (rand.end - rand.start <= 0 || rand.r <= 1) continue;
|
|
ctx.beginPath();
|
|
ctx.arc(rand.cx, rand.cy, rand.r - 0.5, rand.start, rand.end);
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
},
|
|
};
|
|
}
|
|
|
|
function percentagePlugin(pal) {
|
|
return {
|
|
id: 'wealth-pct',
|
|
afterDatasetsDraw(c) {
|
|
const { ctx } = c;
|
|
const meta = c.getDatasetMeta(0);
|
|
if (!meta || meta.hidden) return;
|
|
const rijen = c.data.datasets[0].data;
|
|
const som = rijen.reduce((s, v) => s + v, 0);
|
|
ctx.save();
|
|
ctx.font = '600 11px system-ui, sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
meta.data.forEach((boog, n) => {
|
|
const pct = som > 0 ? (rijen[n] / som) * 100 : 0;
|
|
if (pct < PCT_IN_BOOG) return;
|
|
const { x, y } = boog.getCenterPoint();
|
|
const tekst = pctTekst(pct);
|
|
ctx.lineWidth = 3;
|
|
ctx.strokeStyle = pal.tooltipBg;
|
|
ctx.strokeText(tekst, x, y);
|
|
ctx.fillStyle = pal.text;
|
|
ctx.fillText(tekst, x, y);
|
|
});
|
|
ctx.restore();
|
|
},
|
|
};
|
|
}
|
|
|
|
function middenLabelPlugin(pal, deel) {
|
|
return {
|
|
id: 'wealth-hole',
|
|
afterDatasetsDraw(c) {
|
|
const st = c._vermogen || {};
|
|
const n = (st.hover || [])[0] ?? (st.selectie || [])[0];
|
|
if (n == null) return;
|
|
const ds = c.data.datasets[0];
|
|
const seg = ds._segmenten?.[n];
|
|
if (!seg) return;
|
|
const meta = c.getDatasetMeta(0);
|
|
const boog = meta?.data?.[n];
|
|
if (!boog) return;
|
|
|
|
const { ctx } = c;
|
|
const x = boog.x;
|
|
const y = boog.y;
|
|
const ruimte = (boog.innerRadius ?? 40) * 1.7;
|
|
ctx.save();
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = pal.text;
|
|
ctx.font = '700 1.15rem system-ui, sans-serif';
|
|
ctx.fillText(pctTekst(deel(seg.value)), x, y - 12);
|
|
ctx.font = '600 0.78rem system-ui, sans-serif';
|
|
ctx.fillText(kort(ctx, seg.label, ruimte), x, y + 6);
|
|
ctx.fillStyle = pal.textSec;
|
|
ctx.font = '0.72rem system-ui, sans-serif';
|
|
ctx.fillText(kort(ctx, fmtBase(seg.value), ruimte), x, y + 22);
|
|
ctx.restore();
|
|
},
|
|
};
|
|
}
|
|
|
|
function kort(ctx, tekst, breedte) {
|
|
const s = String(tekst ?? '');
|
|
if (ctx.measureText(s).width <= breedte) return s;
|
|
let uit = s;
|
|
while (uit.length > 1 && ctx.measureText(uit + '…').width > breedte) uit = uit.slice(0, -1);
|
|
return uit + '…';
|
|
}
|