143 lines
5.4 KiB
JavaScript
143 lines
5.4 KiB
JavaScript
// Generated, do not edit. sha256:80acd8f6f37bf4f64acd4a1259ffb1f609bdfe3d99f67a807eaad2154af68875
|
|||
|
|
import { loadCharting, readPalette } from '../ui/chart-setup.js';
|
||
|
|
import { hexToRgb } from '../ui/charting.js';
|
||
|
|
import { t } from '../i18n/index.js';
|
||
|
|
import { fmtBaseSigned, fmtPct, colouredSigns } from '../ui/format.js';
|
||
|
|
|
||
|
|
let tipEl = null;
|
||
|
|
const hide = () => tipEl?.classList.remove('is-visible');
|
||
|
|
|
||
|
|
const tone = v => (v > 0.005 ? 't-pos' : v < -0.005 ? 't-neg' : '');
|
||
|
|
|
||
|
|
function row(label, amount, pct, extra = '') {
|
||
|
|
const r = document.createElement('span');
|
||
|
|
r.className = `ring-tip-row ${extra}`.trim();
|
||
|
|
const name = document.createElement('span');
|
||
|
|
name.className = 'ring-tip-pct';
|
||
|
|
name.textContent = label;
|
||
|
|
const value = document.createElement('span');
|
||
|
|
value.className = `val ${tone(amount)}`;
|
||
|
|
value.textContent = fmtBaseSigned(amount, colouredSigns()) + (pct == null ? '' : ` (${fmtPct(pct, { fine: true, signs: colouredSigns() })})`);
|
||
|
|
r.append(name, value);
|
||
|
|
return r;
|
||
|
|
}
|
||
|
|
|
||
|
|
function tip({ chart, tooltip }, points, labels) {
|
||
|
|
if (!tipEl || !tipEl.isConnected) {
|
||
|
|
tipEl = document.createElement('div');
|
||
|
|
tipEl.className = 'ring-tip';
|
||
|
|
tipEl.setAttribute('role', 'tooltip');
|
||
|
|
document.body.appendChild(tipEl);
|
||
|
|
window.addEventListener('scroll', hide, { capture: true, passive: true });
|
||
|
|
}
|
||
|
|
if (!tooltip.opacity || !tooltip.dataPoints?.length) { hide(); return; }
|
||
|
|
const n = tooltip.dataPoints[0].dataIndex;
|
||
|
|
const p = points[n];
|
||
|
|
const head = document.createElement('span');
|
||
|
|
head.className = 'ring-tip-label';
|
||
|
|
head.textContent = labels[n];
|
||
|
|
const rows = [head];
|
||
|
|
if (p.day != null) rows.push(row(t('wealth.returnDay'), p.day, p.pct));
|
||
|
|
for (const c of p.top || []) rows.push(row(c.label, c.day, c.pct, 'ring-tip-row--sub'));
|
||
|
|
rows.push(row(t('wealth.returnSoFar'), p.cumulative, null));
|
||
|
|
if (p.carried) {
|
||
|
|
const note = document.createElement('span');
|
||
|
|
note.className = 'ring-tip-row ring-tip-row--sub t-muted';
|
||
|
|
note.textContent = t('wealth.returnCarried');
|
||
|
|
rows.push(note);
|
||
|
|
}
|
||
|
|
tipEl.replaceChildren(...rows);
|
||
|
|
const box = chart.canvas.getBoundingClientRect();
|
||
|
|
tipEl.style.left = `${Math.round(box.left + tooltip.caretX)}px`;
|
||
|
|
tipEl.style.top = `${Math.round(box.top + tooltip.caretY)}px`;
|
||
|
|
tipEl.classList.add('is-visible');
|
||
|
|
}
|
||
|
|
|
||
|
|
const rgba = (hex, a) => { const c = hexToRgb(hex); return c ? `rgba(${c.r},${c.g},${c.b},${a})` : null; };
|
||
|
|
|
||
|
|
function gradientFill(green, red, pal) {
|
||
|
|
return {
|
||
|
|
id: 'return-gradient-fill',
|
||
|
|
afterLayout(chart) {
|
||
|
|
const { top, bottom } = chart.chartArea || {};
|
||
|
|
const zero = chart.scales?.y?.getPixelForValue(0);
|
||
|
|
if (![top, bottom, zero].every(Number.isFinite)) return;
|
||
|
|
const upTop = rgba(green, 0.42), upZero = rgba(green, 0.04);
|
||
|
|
const downZero = rgba(red, 0.04), downBottom = rgba(red, 0.42);
|
||
|
|
if (!upTop || !downBottom) return;
|
||
|
|
const { ctx } = chart;
|
||
|
|
const up = ctx.createLinearGradient(0, top, 0, Math.max(zero, top + 1));
|
||
|
|
up.addColorStop(0, upTop);
|
||
|
|
up.addColorStop(1, upZero);
|
||
|
|
const down = ctx.createLinearGradient(0, Math.min(zero, bottom - 1), 0, bottom);
|
||
|
|
down.addColorStop(0, downZero);
|
||
|
|
down.addColorStop(1, downBottom);
|
||
|
|
chart.data.datasets[0].fill = { target: 'origin', above: up, below: down };
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function hoverLine(pal) {
|
||
|
|
return {
|
||
|
|
id: 'return-hover-line',
|
||
|
|
afterDatasetsDraw(chart) {
|
||
|
|
const active = chart.tooltip?.getActiveElements?.() || [];
|
||
|
|
if (!active.length) return;
|
||
|
|
const x = active[0].element.x;
|
||
|
|
const { top, bottom } = chart.chartArea;
|
||
|
|
const { ctx } = chart;
|
||
|
|
ctx.save();
|
||
|
|
ctx.strokeStyle = pal.textSec;
|
||
|
|
ctx.globalAlpha = 0.6;
|
||
|
|
ctx.lineWidth = 1;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.moveTo(Math.round(x) + 0.5, top);
|
||
|
|
ctx.lineTo(Math.round(x) + 0.5, bottom);
|
||
|
|
ctx.stroke();
|
||
|
|
ctx.restore();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function drawReturnChart(canvas, points, labels) {
|
||
|
|
const Chart = await loadCharting();
|
||
|
|
const pal = readPalette();
|
||
|
|
const green = pal.sigGreen, red = pal.sigRed;
|
||
|
|
const chart = new Chart(canvas, {
|
||
|
|
type: 'line',
|
||
|
|
plugins: [hoverLine(pal), gradientFill(green, red, pal)],
|
||
|
|
data: {
|
||
|
|
labels,
|
||
|
|
datasets: [{
|
||
|
|
data: points.map(p => p.cumulative),
|
||
|
|
borderColor: green, borderWidth: 2, pointRadius: 0, pointHoverRadius: 3,
|
||
|
|
segment: {
|
||
|
|
borderColor: c => (c.p1.parsed.y < 0 ? red : green),
|
||
|
|
borderDash: c => (points[c.p0DataIndex]?.carried || points[c.p1DataIndex]?.carried ? [4, 4] : undefined),
|
||
|
|
},
|
||
|
|
pointHoverBackgroundColor: c => ((c.parsed?.y ?? 0) < 0 ? red : green), cubicInterpolationMode: 'monotone',
|
||
|
|
fill: { target: 'origin', above: pal.greenZone, below: pal.redZone },
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
options: {
|
||
|
|
responsive: true,
|
||
|
|
maintainAspectRatio: false,
|
||
|
|
animation: false,
|
||
|
|
interaction: { mode: 'index', intersect: false },
|
||
|
|
layout: { padding: { top: 6, right: 0, bottom: 6, left: 0 } },
|
||
|
|
scales: {
|
||
|
|
x: { display: false },
|
||
|
|
y: { beginAtZero: true, ticks: { display: false }, border: { display: false }, afterFit: s => { s.width = 0; },
|
||
|
|
grid: { color: c => (c.tick?.value === 0 ? pal.textSec : 'transparent') } },
|
||
|
|
},
|
||
|
|
plugins: {
|
||
|
|
legend: { display: false },
|
||
|
|
tooltip: { enabled: false, external: ctx => tip(ctx, points, labels) },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
canvas.addEventListener('mouseleave', hide);
|
||
|
|
const destroy = chart.destroy.bind(chart);
|
||
|
|
chart.destroy = () => { hide(); destroy(); };
|
||
|
|
return chart;
|
||
|
|
}
|