Files

181 lines
8.1 KiB
JavaScript
Raw Permalink Normal View History

2026-10-29 11:57:29 +01:00
// Generated, do not edit. sha256:c1446a22c58388fe401f93d2f54194b187120f3e6ec07b997b51d0acb9bb51b4
import * as dialog from '../ui/dialog.js';
import { esc } from '../ui/escape.js';
import { fmtBaseSigned, fmtPct, pctTone, colouredSigns, isBaseAsset } from '../ui/format.js';
import { assetLabel, byKindAndLabel } from '../ui/asset-label.js';
import { t } from '../i18n/index.js';
import { localDateNl } from '../../shared/time.js';
import { ENUMS } from '../../shared/config.js';
import { seriesFor } from './series.js';
import { periodChoices, startPeriod, returnLine, idsForSelection, contributorsPerDay } from './return-view.js';
import { getPref, setPref } from '../data/preferences.js';
import { drawReturnChart } from './return-chart.js';
import { carriedDays } from './carried.js';
import { selectLabel } from '../ui/select-label.js';
const DASH = '-';
const PREF_SCOPE = 'wealth.return';
const live = new Set();
export function openReturnDialog({ reeks, assets = [], page = null, selection = null } = {}) {
const closed = dialog.detail({ title: t('wealth.returnTitle'), html: '<div class="return-view return-view--dialog" data-return></div>' });
const hosts = document.querySelectorAll('.dialog-overlay [data-return]');
const host = hosts[hosts.length - 1];
if (!host) return;
const controls = host.closest('.dialog')?.querySelector('[data-dialog-aside]') || null;
const ids = idsForSelection(selection, assets);
const view = mount(host, { reeks, assets, page, data: dataKeyFor(selection, ids), selectionIds: ids, controls });
closed.then(() => view.destroy());
}
export async function mountReturnPanel(host, { reeks, asset }) {
const key = String(asset.id);
const stored = await getPref(PREF_SCOPE, key, null).catch(() => null);
if (!host.isConnected) return null;
return mount(host, { reeks, assets: [asset], page: null, data: `asset:${asset.id}`, stored,
onPeriod: period => setPref(PREF_SCOPE, key, period).catch(() => { }) });
}
export function refreshReturn() {
for (const v of [...live]) v.render();
}
export function lineForData({ reeks, assets = [], data = 'total', venster = null, selectionIds = null }) {
const ids = idsOf(data, assets, selectionIds);
const line = returnLine(seriesFor(reeks, ids && ids.length === 1 ? ids[0] : ids), venster);
const noPct = withoutPct(ids, assets);
const top = contributorsOf(reeks, ids ?? assets.map(a => a.id), venster, assets);
const carried = carriedDays(reeks, ids, assets);
return {
amount: line.amount,
pct: noPct ? null : line.pct,
points: line.points.map(p => ({ ...p, pct: noPct ? null : p.pct, top: top.get(p.i) || [], carried: carried.has(p.i) })),
};
}
export function drawLine(canvas, reeks, points) {
return drawReturnChart(canvas, points, points.map(p => localDateNl(`${reeks.dagen[p.i]}T00:00:00Z`)));
}
function dataKeyFor(selection, ids) {
if (!ids) return 'total';
if (selection?.kind) return `kind:${selection.kind}`;
if (ids.length === 1) return `asset:${ids[0]}`;
return 'selection';
}
function idsOf(key, assets, selectionIds) {
if (key === 'total') return null;
if (key === 'selection') return selectionIds;
if (key.startsWith('kind:')) return assets.filter(a => a.kind === key.slice(5)).map(a => a.id);
return [Number(key.slice(6))];
}
const withoutPct = (ids, assets) => ids?.length === 1 && isBaseAsset(assets.find(a => a.id === ids[0]) || {});
function contributorsOf(reeks, ids, venster, assets) {
const byId = new Map(assets.map(a => [a.id, a]));
const named = new Map();
for (const [i, list] of contributorsPerDay(reeks, ids, venster)) {
named.set(i, list.filter(c => byId.has(c.id)).map(c => ({
label: assetLabel(byId.get(c.id)), day: c.day, pct: isBaseAsset(byId.get(c.id)) ? null : c.pct,
})));
}
return named;
}
function mount(host, { reeks, assets, page, data, selectionIds = null, controls = null, stored = null, onPeriod = null }) {
const length = reeks?.dagen?.length || 0;
const periods = periodChoices(length, page);
const state = { data, period: startPeriod(periods, stored), chart: null, gen: 0, lines: null };
const view = {
render() {
if (!host.isConnected) { view.destroy(); return; }
if (!length) { host.innerHTML = `<div class="t-muted-cell">${t('wealth.noChart')}</div>`; return; }
const lines = new Map(periods.map(p => [p.key, lineForData({ reeks, assets, data: state.data, venster: p.venster, selectionIds })]));
state.lines = lines;
if (controls) controls.innerHTML = dataSelect(assets, state.data, selectionIds);
host.innerHTML = `
<div class="return-periods" role="group">${periods.map(p => periodButton(p, lines.get(p.key), state.period)).join('')}</div>
<div class="return-plot"><canvas data-return-chart></canvas></div>`;
drawChart(lines.get(state.period)?.points || []);
},
destroy() {
live.delete(view);
state.chart?.destroy();
state.chart = null;
},
};
async function drawChart(points) {
const gen = ++state.gen;
state.chart?.destroy();
state.chart = null;
const canvas = host.querySelector('[data-return-chart]');
if (!canvas) return;
if (points.length < 2) {
canvas.parentElement.innerHTML = `<div class="t-muted-cell">${t('wealth.noChart')}</div>`;
return;
}
const chart = await drawLine(canvas, reeks, points).catch(() => null);
if (gen !== state.gen || !host.isConnected) { chart?.destroy(); return; }
state.chart = chart;
}
host.addEventListener('click', e => {
const btn = e.target.closest('[data-period]');
if (!btn || btn.disabled || btn.dataset.period === state.period) return;
state.period = btn.dataset.period;
onPeriod?.(state.period);
for (const b of host.querySelectorAll('[data-period]')) b.classList.toggle('is-on', b === btn);
if (!host.querySelector('[data-return-chart]')) {
host.querySelector('.return-plot').innerHTML = '<canvas data-return-chart></canvas>';
}
drawChart(state.lines?.get(state.period)?.points || []);
});
controls?.addEventListener('change', e => {
const sel = e.target.closest('[data-return-data]');
if (!sel) return;
state.data = sel.value;
view.render();
});
live.add(view);
view.render();
return view;
}
function periodLabel(p) {
if (p.key === 'page') return p.label ?? DASH;
if (p.days == null) return t('wealth.sinceStart');
if (p.days === 1) return t('wealth.periodDay');
if (p.days === 365) return t('wealth.periodYear');
return t('wealth.periodDays', { n: p.days });
}
const tone = v => (v == null ? '' : v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : '');
function periodButton(p, line, current) {
const on = p.key === current ? ' is-on' : '';
const off = p.venster ? '' : ' disabled';
const amount = line?.amount == null ? DASH : `<span class="val">${esc(fmtBaseSigned(line.amount, colouredSigns()))}</span>`;
const pct = line?.pct == null ? '' : fmtPct(line.pct, { fine: p.days === 1, signs: colouredSigns() });
return `<button type="button" class="return-period${on}" data-period="${esc(p.key)}"${off}>
<span class="return-period-label">${esc(periodLabel(p))}</span>
<span class="return-period-amount ${tone(line?.amount)}">${amount}</span>
<span class="return-period-pct">${pct ? `<span class="delta ${pctTone(line.pct, { fine: p.days === 1 })}">${esc(pct)}</span>` : ''}</span>
</button>`;
}
function dataSelect(assets, current, selectionIds) {
const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}</option>`;
const kinds = ENUMS.ASSET_KIND.filter(k => assets.some(a => a.kind === k));
const sorted = [...assets].sort(byKindAndLabel);
return selectLabel(`<select data-return-data aria-label="${esc(t('wealth.returnData'))}">
${opt('total', t('wealth.returnTotal'))}
${selectionIds?.length > 1 ? opt('selection', t('wealth.returnSelection', { n: selectionIds.length })) : ''}
<optgroup label="${esc(t('wealth.returnKinds'))}">${kinds.map(k => opt(`kind:${k}`, t('enums.asset_kind.' + k))).join('')}</optgroup>
<optgroup label="${esc(t('wealth.returnAssets'))}">${sorted.map(a => opt(`asset:${a.id}`, assetLabel(a))).join('')}</optgroup>
</select>`);
}