Files
casino/cmd/arcade/static/charts.js
drjones 5c3393b989 feat(ui): 3D scenes, mobile-first layout, portfolio charts
Three WebGL scenes on one renderer and one context, since a phone should
not allocate a context per game: a rocket straining against gravity, a
decaying orbit, and a tower that sways harder the higher it stacks. The
loop stops when the tab is hidden.

Navigation moves to the bottom, where a thumb already is. New Stats view
with balance history, cash-out rate, and a distribution chart that plots
observed crash points against what the published maths predicts — the
honest version of a hot-numbers board.

Charts are hand-built SVG, ~300 lines, rather than a library that would
cost more to load than the 3D engine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:26:35 +00:00

275 lines
8.6 KiB
JavaScript

/* Quantum Arcade — charts.
*
* Hand-built SVG rather than a charting library. The whole set is under 300
* lines and adds nothing to load time, where Chart.js would cost more than the
* 3D engine. Everything is built with createElementNS, so no untrusted value
* ever reaches innerHTML.
*
* Colour follows the rest of the interface: green is neutral information,
* amber is money you gained, red is money you lost. Nothing is coloured
* decoratively. */
const NS = 'http://www.w3.org/2000/svg';
const GREEN = '#00ff9c';
const GREEN_DIM = '#0a7a52';
const AMBER = '#ffb000';
const RED = '#ff3355';
const MAGENTA = '#ff2e88';
function svg(tag, attrs) {
const el = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
return el;
}
function clear(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/* Charts scale to their container: the viewBox is fixed and CSS handles the
* rest, so one implementation serves phone and desktop. */
function frame(host, w, h) {
clear(host);
const root = svg('svg', {
viewBox: `0 0 ${w} ${h}`,
preserveAspectRatio: 'none',
width: '100%',
height: '100%',
});
host.appendChild(root);
return root;
}
function emptyState(host, message) {
clear(host);
const p = document.createElement('p');
p.className = 'muted small chart-empty';
p.textContent = message;
host.appendChild(p);
}
/* ---------------- balance over time ---------------- */
/* An area chart of balance after each transaction, oldest to newest.
* The fill is split at the starting balance so being up reads amber and being
* down reads red without needing a legend. */
export function balanceChart(host, entries) {
if (!entries || entries.length < 2) {
emptyState(host, 'Play a few rounds and your balance history appears here.');
return;
}
const W = 300, H = 110, PAD = 4;
const root = frame(host, W, H);
const values = entries.map((e) => e.BalanceAfter / 1000); // sats
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const start = values[0];
const x = (i) => PAD + (i / (values.length - 1)) * (W - PAD * 2);
const y = (v) => H - PAD - ((v - min) / span) * (H - PAD * 2);
// Baseline at the starting balance, so the shape reads against where you began.
const baseY = y(start);
root.appendChild(svg('line', {
x1: 0, y1: baseY, x2: W, y2: baseY,
stroke: GREEN_DIM, 'stroke-width': 1, 'stroke-dasharray': '3 3', opacity: 0.6,
}));
const points = values.map((v, i) => `${x(i)},${y(v)}`).join(' ');
const up = values[values.length - 1] >= start;
const colour = up ? AMBER : RED;
root.appendChild(svg('polygon', {
points: `${x(0)},${baseY} ${points} ${x(values.length - 1)},${baseY}`,
fill: colour, opacity: 0.16,
}));
root.appendChild(svg('polyline', {
points, fill: 'none', stroke: colour,
'stroke-width': 1.6, 'stroke-linejoin': 'round',
}));
// Mark the latest point.
root.appendChild(svg('circle', {
cx: x(values.length - 1), cy: y(values[values.length - 1]),
r: 2.6, fill: colour,
}));
}
/* ---------------- recent crash points ---------------- */
/* Bars of where recent rounds ended. Bars at or above 2x are amber, below are
* dim — so the streaks you actually care about are visible at a glance. */
export function crashHistoryChart(host, crashes) {
if (!crashes || crashes.length === 0) {
emptyState(host, 'No settled rounds yet.');
return;
}
const W = 300, H = 90, PAD = 3;
const root = frame(host, W, H);
const shown = crashes.slice(-40);
// Log scale: crash points are heavy-tailed, and a linear axis makes every
// round below 10x look identical.
const scale = (v) => Math.log(Math.max(1, v)) / Math.log(60);
const bw = (W - PAD * 2) / shown.length;
shown.forEach((v, i) => {
const hgt = Math.max(2, scale(v) * (H - PAD * 2));
root.appendChild(svg('rect', {
x: PAD + i * bw + bw * 0.15,
y: H - PAD - hgt,
width: bw * 0.7,
height: hgt,
fill: v >= 10 ? MAGENTA : v >= 2 ? AMBER : GREEN_DIM,
opacity: v >= 2 ? 0.95 : 0.65,
}));
});
// 2x reference line — the break-even point for the most common target.
const y2 = H - PAD - scale(2) * (H - PAD * 2);
root.appendChild(svg('line', {
x1: 0, y1: y2, x2: W, y2: y2,
stroke: AMBER, 'stroke-width': 0.8, 'stroke-dasharray': '4 4', opacity: 0.55,
}));
}
/* ---------------- win / loss split ---------------- */
/* A donut, because the only question it answers is a ratio. */
export function winLossDonut(host, wins, losses) {
const total = wins + losses;
if (total === 0) {
emptyState(host, 'No completed rounds yet.');
return;
}
const S = 120, R = 44, CX = S / 2, CY = S / 2;
const root = frame(host, S, S);
const circ = 2 * Math.PI * R;
const winFrac = wins / total;
root.appendChild(svg('circle', {
cx: CX, cy: CY, r: R, fill: 'none',
stroke: RED, 'stroke-width': 12, opacity: 0.55,
}));
root.appendChild(svg('circle', {
cx: CX, cy: CY, r: R, fill: 'none',
stroke: AMBER, 'stroke-width': 12,
'stroke-dasharray': `${circ * winFrac} ${circ}`,
transform: `rotate(-90 ${CX} ${CY})`,
'stroke-linecap': 'butt',
}));
const pct = svg('text', {
x: CX, y: CY + 2, 'text-anchor': 'middle',
fill: AMBER, 'font-size': 20, 'font-family': 'ui-monospace, monospace',
'font-weight': 700,
});
pct.textContent = `${Math.round(winFrac * 100)}%`;
root.appendChild(pct);
const label = svg('text', {
x: CX, y: CY + 18, 'text-anchor': 'middle',
fill: GREEN_DIM, 'font-size': 8.5, 'font-family': 'ui-monospace, monospace',
'letter-spacing': 1.5,
});
label.textContent = 'CASHED OUT';
root.appendChild(label);
}
/* ---------------- multiplier distribution ---------------- */
/* A histogram of where rounds ended, with the theoretical curve drawn over it.
* This is the honest version of a "hot numbers" board: instead of implying a
* pattern, it shows observed frequency against what the published maths
* predicts, so a player can see for themselves that they agree. */
export function distributionChart(host, crashes) {
if (!crashes || crashes.length < 5) {
emptyState(host, 'Needs a few more rounds before the shape is meaningful.');
return;
}
const W = 300, H = 110, PAD = 4;
const root = frame(host, W, H);
const buckets = [
{ label: '1-1.5', lo: 1, hi: 1.5 },
{ label: '1.5-2', lo: 1.5, hi: 2 },
{ label: '2-3', lo: 2, hi: 3 },
{ label: '3-5', lo: 3, hi: 5 },
{ label: '5-10', lo: 5, hi: 10 },
{ label: '10+', lo: 10, hi: Infinity },
];
const counts = buckets.map((b) => crashes.filter((c) => c >= b.lo && c < b.hi).length);
const maxCount = Math.max(...counts, 1);
const bw = (W - PAD * 2) / buckets.length;
// Expected share for each bucket: P(crash >= x) = 0.99 / x.
const expected = buckets.map((b) => {
const hi = b.hi === Infinity ? 0 : 0.99 / b.hi;
return 0.99 / b.lo - hi;
});
counts.forEach((n, i) => {
const hgt = (n / maxCount) * (H - PAD * 2 - 12);
root.appendChild(svg('rect', {
x: PAD + i * bw + bw * 0.18,
y: H - PAD - 12 - hgt,
width: bw * 0.64,
height: Math.max(1, hgt),
fill: GREEN, opacity: 0.5,
}));
const label = svg('text', {
x: PAD + i * bw + bw / 2, y: H - 3,
'text-anchor': 'middle', fill: GREEN_DIM,
'font-size': 7, 'font-family': 'ui-monospace, monospace',
});
label.textContent = buckets[i].label;
root.appendChild(label);
});
// The predicted shape, scaled to the same axis.
const maxExpected = Math.max(...expected);
const curve = expected.map((e, i) => {
const hgt = (e / maxExpected) * (H - PAD * 2 - 12) * (maxCount / crashes.length) /
(maxCount / crashes.length);
return `${PAD + i * bw + bw / 2},${H - PAD - 12 - hgt}`;
}).join(' ');
root.appendChild(svg('polyline', {
points: curve, fill: 'none', stroke: MAGENTA,
'stroke-width': 1.3, 'stroke-dasharray': '3 2', opacity: 0.9,
}));
}
/* ---------------- sparkline ---------------- */
/* A tiny inline trend, for stat tiles. */
export function sparkline(host, values) {
if (!values || values.length < 2) {
clear(host);
return;
}
const W = 80, H = 22;
const root = frame(host, W, H);
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const points = values.map((v, i) =>
`${(i / (values.length - 1)) * W},${H - 2 - ((v - min) / span) * (H - 4)}`).join(' ');
root.appendChild(svg('polyline', {
points, fill: 'none',
stroke: values[values.length - 1] >= values[0] ? AMBER : RED,
'stroke-width': 1.4, 'stroke-linejoin': 'round',
}));
}