Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+361
View File
@@ -0,0 +1,361 @@
// 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 + '…';
}