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>
275 lines
8.6 KiB
JavaScript
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',
|
|
}));
|
|
}
|