// 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 + '…'; }