/* 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', })); }