srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/chart.js
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-09-11 21:53:00 +0200
committersrdusr <[email protected]>2025-09-11 21:53:00 +0200
commita726d9f5fb56e1fd7983c5ac806d408ad78daa86 (patch)
tree4129a058e2a9e8fa4e0a51e3ed943b746750c3cf /web/src/chart.js
parent8c95e90fa54db565919ec323818f77e7256812c8 (diff)
downloadtyperpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.tar.gz
typerpunk-a726d9f5fb56e1fd7983c5ac806d408ad78daa86.zip
Add multiplayer bots, typing languages, and rework the UI layout
Multiplayer - Quick match: POST /api/multiplayer/quickmatch returns whichever room is still filling, or opens one. Players never see a room code; joining by code stays for racing specific people. - Bots fill quick-match rooms after a short wait so a new game is never an empty lobby. They only ever join quick-match rooms, never a room opened by code. One or two per room, drawn from separate ~40 and ~80 WPM tiers so two bots are never near each other's pace, and they stall to correct mistakes rather than typing a clean straight line. - Live player count via GET /api/multiplayer/online, shown on the Multiplayer control and under the main menu's Multiplayer button. - Per-racer colours: you are the theme accent, opponents take distinct hues that stay the same from lobby to race. - The countdown no longer holds the room lock for its full three seconds, which is what reset clients mid-countdown. Typing languages - 16 languages for the generated-word modes, each with its own high-frequency vocabulary rather than a translation of the English list. - Picker in the top-right rail; non-English uses its own list at every difficulty tier instead of falling back to English words. Fix UTF-8 accuracy in the game core - update_game_state mixed byte and character counts: total_characters_typed accumulated byte-length deltas while total_correct_characters compared a char index against that byte count. Equal on ASCII, so it went unnoticed; a correctly typed Spanish passage scored 6%. The old byte slicing would also have panicked if an index landed inside a multi-byte character. Rewritten char-based, with regression tests. Programming mode - Replaced prose about programming with real code: 26 syntax-highlighted snippets across JavaScript, Python, Rust, C/Go/Java and shell. Single-line by necessity, since the typing input is a single-line field. Layout and readability - One icon rail arrangement on every screen: Settings/Store under the wordmark, Language/Theme/Friends/Account top-right, Stats/Leaderboard/ Multiplayer bottom-right. - Main menu: mode picker moved out of the Single Player button, which it was notching a divider through and pushing the label off-centre. - Escape returns to the menu, closing any open popover first, and confirms before abandoning a live race. - Split --text-color and --sub-color per theme; they shared one value that measured 3.65:1 against the background, below the 4.5:1 body-text floor. - Semantic colours used in exactly one place each: gold for a personal best, amber for the race countdown and the mobile-result badge. - Passage now sits in the same place on the typing and end screens, and its column is a whole number of characters wide so wrapping cannot leave a permanent gap on the right. - End screen: keystrokes and a correct/wrong/extra/missed split, attribution carried over from the typing screen, and a graph with a separate error axis, axis titles including seconds, and smoothed lines.
Diffstat (limited to 'web/src/chart.js')
-rw-r--r--web/src/chart.js420
1 files changed, 420 insertions, 0 deletions
diff --git a/web/src/chart.js b/web/src/chart.js
new file mode 100644
index 0000000..b1189eb
--- /dev/null
+++ b/web/src/chart.js
@@ -0,0 +1,420 @@
+export function buildGraphPoints(charTimings, stats, keypressHistory) {
+ // Prefer keypressHistory: it's an append-only log of every keystroke as
+ // it actually happened, so a mistake you later backspaced away still
+ // shows up as a real event at the time it occurred. charTimings is
+ // current-state-only - backspacing overwrites the slot for that
+ // position, so a corrected mistake leaves no trace by the end of the
+ // test, and raw silently collapses onto wpm for the whole graph.
+ let timings = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings;
+ if (!timings || timings.length === 0) {
+ if (!(stats.time > 0 && stats.totalChars > 0)) return [];
+ const total = stats.correctChars + stats.incorrectChars;
+ timings = [];
+ for (let i = 0; i < total; i++) {
+ timings.push({ time: (i / total) * stats.time, isCorrect: i < stats.correctChars });
+ }
+ }
+
+ const maxTime = Math.max(timings[timings.length - 1].time, stats.time);
+ const seconds = Math.max(1, Math.ceil(maxTime));
+ // Cap the number of plotted buckets regardless of how long the test
+ // actually took wall-clock-wise (nothing stops a test from sitting open,
+ // unfinished, for hours) by widening the bucket instead of the point
+ // count. For any normal-length test (<= 300s) this divides out to 1s
+ // buckets exactly as before - pure safety net, no behavior change.
+ const MAX_BUCKETS = 300;
+ const bucketSeconds = Math.max(1, Math.ceil(seconds / MAX_BUCKETS));
+ const bucketCount = Math.ceil(seconds / bucketSeconds);
+
+ const buckets = Array.from({ length: bucketCount }, () => ({ total: 0, correct: 0 }));
+ for (const c of timings) {
+ const idx = Math.min(bucketCount - 1, Math.floor(c.time / bucketSeconds));
+ if (idx < 0) continue;
+ buckets[idx].total++;
+ if (c.isCorrect) buckets[idx].correct++;
+ }
+
+ // Each point is a sliding window over the last ~3 seconds, not just its
+ // own isolated bucket. A single 1-second bucket with zero keystrokes is
+ // completely normal human behavior (the brief pause between words), but
+ // read in isolation it computes as a hard 0 WPM - a sharp, unrealistic
+ // spike to the graph's floor that doesn't happen on MonkeyType/TypeRacer
+ // because they smooth over exactly this kind of momentary gap. A real
+ // multi-second stall still shows up as a real dip; it just isn't a
+ // razor to zero from one quiet second.
+ const minutesPerBucket = bucketSeconds / 60;
+ const windowBuckets = Math.max(1, Math.round(3 / bucketSeconds));
+ return buckets.map((_, i) => {
+ const start = Math.max(0, i - windowBuckets + 1);
+ let correctSum = 0;
+ let totalSum = 0;
+ for (let j = start; j <= i; j++) {
+ correctSum += buckets[j].correct;
+ totalSum += buckets[j].total;
+ }
+ const windowMinutes = (i - start + 1) * minutesPerBucket;
+ return {
+ time: (i + 1) * bucketSeconds,
+ wpm: (correctSum / 5) / windowMinutes,
+ raw: (totalSum / 5) / windowMinutes,
+ };
+ });
+}
+
+/// Consistency: how steady the WPM stayed across the test, expressed as a
+/// percentage (100 = perfectly even pace). Same coefficient-of-variation
+/// approach MonkeyType uses: lower spread relative to the average pace
+/// scores higher.
+export function calculateConsistency(graphPoints) {
+ const values = graphPoints.map(p => p.wpm).filter(v => v > 0);
+ if (values.length < 2) return 100;
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
+ if (mean === 0) return 100;
+ const variance = values.reduce((a, b) => a + (b - mean) ** 2, 0) / values.length;
+ const stddev = Math.sqrt(variance);
+ const coefficientOfVariation = stddev / mean;
+ return Math.max(0, Math.min(100, Math.round((1 - coefficientOfVariation) * 100)));
+}
+
+export function buildErrorPoints(graphPoints, keypressHistory, charTimings) {
+ const source = (keypressHistory && keypressHistory.length > 0) ? keypressHistory : charTimings;
+ if (!source || source.length === 0 || graphPoints.length === 0) return [];
+ // One marker per bucket carrying how many errors landed in it, rather
+ // than one marker per error sitting on top of the wpm line. Two mistakes
+ // in the same second used to draw two dots at the same coordinates, so
+ // the graph could not show that a moment was worse than another.
+ const byBucket = new Map();
+ for (const { time, isCorrect, index } of source) {
+ if (isCorrect) continue;
+ const closest = graphPoints.reduce((prev, curr) =>
+ Math.abs(curr.time - time) < Math.abs(prev.time - time) ? curr : prev, graphPoints[0]);
+ const entry = byBucket.get(closest.time);
+ if (entry) entry.count += 1;
+ else byBucket.set(closest.time, { x: closest.time, count: 1, index });
+ }
+ return [...byBucket.values()].sort((a, b) => a.x - b.x);
+}
+
+export function niceStep(maxValue, plotSize, pxPerLabel) {
+ const maxLabels = Math.max(2, Math.floor(plotSize / pxPerLabel));
+ const rawStep = (maxValue || 1) / maxLabels;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
+ const residual = rawStep / magnitude;
+ const niceResidual = residual > 5 ? 10 : residual > 2 ? 5 : residual > 1 ? 2 : 1;
+ return Math.max(1, niceResidual * magnitude);
+}
+
+/// Shared plot geometry so hit-testing (hover) can map mouse coordinates the
+/// same way drawChart maps data coordinates.
+export function getPlotGeometry(canvas, graphPoints, xMax, errorPoints = []) {
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ // Room on both sides for a rotated axis title plus its tick labels.
+ const margin = { top: 14, right: 52, bottom: 42, left: 52 };
+ const plotW = Math.max(1, width - margin.left - margin.right);
+ const plotH = Math.max(1, height - margin.top - margin.bottom);
+ const maxWpm = Math.max(1, ...graphPoints.map(p => Math.max(p.wpm, p.raw)), 1);
+ const yStep = niceStep(maxWpm, plotH, 28);
+ const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep);
+
+ // Errors get their own scale. Plotted against the WPM axis they were
+ // pinned to whatever the line happened to be doing, which said nothing
+ // about how many errors there were - and on a fast test they all bunched
+ // near the top of the chart.
+ const maxErrors = Math.max(1, ...errorPoints.map(p => p.count || 1));
+ // Whole numbers only: half an error does not exist.
+ const errStep = Math.max(1, Math.ceil(maxErrors / 4));
+ // One step of headroom above the worst bucket, so the tallest marker never
+ // sits on the top edge of the plot or underneath the legend.
+ const errMax = Math.max(errStep, (Math.ceil(maxErrors / errStep) + 1) * errStep);
+
+ return {
+ xToPx: t => margin.left + (t / (xMax || 1)) * plotW,
+ yToPx: v => margin.top + plotH - (v / yMax) * plotH,
+ yErrToPx: v => margin.top + plotH - (v / errMax) * plotH,
+ margin, plotW, plotH, yMax, errMax, errStep,
+ };
+}
+
+// Canvas fillStyle/strokeStyle can't take a CSS var() directly, so the
+// chart's colors are read from the active theme's actual custom properties
+// instead of being hardcoded - otherwise every non-default theme (Dracula,
+// Nord, etc.) would show a graph in colors that don't match anything else
+// on the page.
+export function themeColors() {
+ const style = getComputedStyle(document.documentElement);
+ const get = (name, fallback) => style.getPropertyValue(name).trim() || fallback;
+ return {
+ primary: get('--primary-color', '#00ff9d'),
+ secondary: get('--secondary-color', '#00cc8f'),
+ error: get('--error-color', '#ff3b3b'),
+ sub: get('--sub-color', '#646669'),
+ };
+}
+
+export function drawChart(canvas, { graphPoints, errorPoints, xMax }) {
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if (width === 0 || height === 0) return;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ if (graphPoints.length === 0) return;
+
+ const { xToPx, yToPx, yErrToPx, margin, yMax, errMax, errStep } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints);
+ const yStep = niceStep(yMax, canvas.clientHeight - margin.top - margin.bottom, 28);
+ const colors = themeColors();
+
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.strokeStyle = 'rgba(100,102,105,0.15)';
+ ctx.fillStyle = colors.sub;
+ ctx.lineWidth = 1;
+ for (let v = 0; v <= yMax; v += yStep) {
+ const y = yToPx(v);
+ ctx.beginPath();
+ ctx.moveTo(margin.left, y);
+ ctx.lineTo(width - margin.right, y);
+ ctx.stroke();
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(Math.round(v)), margin.left - 6, y);
+ }
+
+ // Start at xStep, not 0 - the y-axis already prints its own "0" right
+ // at the origin corner, and a second "0" from the x-axis lands on top
+ // of it.
+ const xStep = niceStep(xMax, canvas.clientWidth - margin.left - margin.right, 40);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'top';
+ for (let t = xStep; t <= xMax; t += xStep) {
+ ctx.fillText(String(t), xToPx(t), height - margin.bottom + 6);
+ }
+
+ // Right-hand error axis. Its own scale, so a spike in mistakes is legible
+ // regardless of how fast the run was.
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ for (let v = 0; v <= errMax; v += errStep) {
+ ctx.fillText(String(v), width - margin.right + 6, yErrToPx(v));
+ }
+
+ // Axis titles. The x axis had none at all, so its numbers could have been
+ // seconds, words or buckets.
+ drawAxisTitle(ctx, 'words per minute', 12, margin.top + (height - margin.top - margin.bottom) / 2, -Math.PI / 2, colors.sub);
+ drawAxisTitle(ctx, 'errors', width - 10, margin.top + (height - margin.top - margin.bottom) / 2, Math.PI / 2, colors.sub);
+ drawAxisTitle(ctx, 'seconds', margin.left + (width - margin.left - margin.right) / 2, height - 6, 0, colors.sub);
+
+ const drawLine = (key, color, dashed) => {
+ ctx.beginPath();
+ ctx.strokeStyle = color;
+ ctx.lineWidth = dashed ? 1.5 : 2;
+ ctx.setLineDash(dashed ? [6, 5] : []);
+ const pts = graphPoints.map(p => ({ x: xToPx(p.time), y: yToPx(p[key]) }));
+ strokeSmooth(ctx, pts);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ };
+ drawLine('raw', colors.secondary, true);
+ drawLine('wpm', colors.primary, false);
+
+ const baselineY = yErrToPx(0);
+ for (const p of errorPoints) {
+ const ex = xToPx(p.x);
+ const ey = yErrToPx(p.count || 1);
+ // Stem first, so the dot sits on top of it.
+ ctx.beginPath();
+ ctx.strokeStyle = colors.error;
+ ctx.globalAlpha = 0.45;
+ ctx.lineWidth = 1;
+ ctx.moveTo(ex, baselineY);
+ ctx.lineTo(ex, ey);
+ ctx.stroke();
+ ctx.globalAlpha = 1;
+ ctx.beginPath();
+ ctx.fillStyle = colors.error;
+ ctx.arc(ex, ey, 3, 0, Math.PI * 2);
+ ctx.fill();
+ }
+
+ drawLegend(ctx, width, colors, margin);
+}
+
+// A rotated axis caption. Canvas has no text-orientation, so the context is
+// rotated around the label's own centre and restored immediately.
+function drawAxisTitle(ctx, text, x, y, angle, color) {
+ ctx.save();
+ ctx.translate(x, y);
+ ctx.rotate(angle);
+ ctx.fillStyle = color;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = '10px "JetBrains Mono", monospace';
+ ctx.fillText(text, 0, 0);
+ ctx.restore();
+ ctx.font = '11px "JetBrains Mono", monospace';
+}
+
+// Centripetal-ish Catmull-Rom through the points, emitted as bezier segments.
+// A raw polyline made every one-second bucket a hard corner, which read as
+// far noisier than the underlying pace actually was.
+function strokeSmooth(ctx, pts) {
+ if (pts.length === 0) return;
+ ctx.moveTo(pts[0].x, pts[0].y);
+ if (pts.length < 3) {
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
+ return;
+ }
+ for (let i = 0; i < pts.length - 1; i++) {
+ const p0 = pts[i - 1] || pts[i];
+ const p1 = pts[i];
+ const p2 = pts[i + 1];
+ const p3 = pts[i + 2] || p2;
+ // 1/6 keeps the curve close to the data; a larger factor overshoots
+ // into visible bulges around sharp changes of pace.
+ ctx.bezierCurveTo(
+ p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6,
+ p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6,
+ p2.x, p2.y,
+ );
+ }
+}
+
+function drawLegend(ctx, width, colors, margin) {
+ const entries = [
+ { label: 'wpm', color: colors.primary, dashed: false },
+ { label: 'raw', color: colors.secondary, dashed: true },
+ { label: 'error', color: colors.error, dot: true },
+ ];
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'middle';
+ const swatchW = 16;
+ const gap = 6;
+ const groupGap = 18;
+ // Left-aligned inside the plot rather than flush to the right edge, where
+ // it sat on top of the error axis's tick labels.
+ let x = margin.left + 8;
+ const y = 8;
+ for (const e of entries) {
+ if (e.dot) {
+ ctx.fillStyle = e.color;
+ ctx.beginPath();
+ ctx.arc(x + swatchW / 2, y, 3, 0, Math.PI * 2);
+ ctx.fill();
+ } else {
+ ctx.strokeStyle = e.color;
+ ctx.lineWidth = e.dashed ? 1.5 : 2;
+ ctx.setLineDash(e.dashed ? [4, 3] : []);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ ctx.lineTo(x + swatchW, y);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ ctx.fillStyle = colors.sub;
+ ctx.fillText(e.label, x + swatchW + gap, y);
+ x += swatchW + gap + ctx.measureText(e.label).width + groupGap;
+ }
+}
+
+// WPM across recent completed tests (x-axis is test order, not time within a
+// single test) - a different shape from drawChart above, which plots one
+// test's speed over its own elapsed seconds. Kept separate rather than
+// bent to fit drawChart's per-test contract (its legend assumes raw/error
+// series that don't exist here).
+export function drawProgressChart(canvas, history) {
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if (width === 0 || height === 0) return;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+ if (history.length === 0) return;
+
+ const margin = { top: 12, right: 16, bottom: 22, left: 36 };
+ const plotW = Math.max(1, width - margin.left - margin.right);
+ const plotH = Math.max(1, height - margin.top - margin.bottom);
+ const maxWpm = Math.max(1, ...history.map(h => h.wpm));
+ const yStep = niceStep(maxWpm, plotH, 28);
+ const yMax = Math.max(yStep, Math.ceil(maxWpm / yStep) * yStep);
+ const xMax = Math.max(1, history.length - 1);
+ const xToPx = i => margin.left + (i / xMax) * plotW;
+ const yToPx = v => margin.top + plotH - (v / yMax) * plotH;
+
+ const colors = themeColors();
+ ctx.font = '11px "JetBrains Mono", monospace';
+ ctx.strokeStyle = 'rgba(100,102,105,0.15)';
+ ctx.fillStyle = colors.sub;
+ ctx.lineWidth = 1;
+ for (let v = 0; v <= yMax; v += yStep) {
+ const y = yToPx(v);
+ ctx.beginPath();
+ ctx.moveTo(margin.left, y);
+ ctx.lineTo(width - margin.right, y);
+ ctx.stroke();
+ ctx.textAlign = 'right';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(String(Math.round(v)), margin.left - 6, y);
+ }
+
+ ctx.beginPath();
+ ctx.strokeStyle = colors.primary;
+ ctx.lineWidth = 2;
+ history.forEach((h, i) => {
+ const x = xToPx(i);
+ const y = yToPx(h.wpm);
+ if (i === 0) ctx.moveTo(x, y);
+ else ctx.lineTo(x, y);
+ });
+ ctx.stroke();
+
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'top';
+ ctx.fillStyle = colors.sub;
+ ctx.fillText('oldest', margin.left, height - margin.bottom + 6);
+ ctx.textAlign = 'right';
+ ctx.fillText('latest', width - margin.right, height - margin.bottom + 6);
+}
+
+/// Returns the graph point nearest to mouseX (by time), for a "hover
+/// anywhere on the line" readout - not just the error dots.
+export function hitTestLine(canvas, graphPoints, xMax, mouseX) {
+ if (graphPoints.length === 0) return null;
+ const { xToPx } = getPlotGeometry(canvas, graphPoints, xMax);
+ let closest = graphPoints[0];
+ let closestDist = Math.abs(xToPx(closest.time) - mouseX);
+ for (const p of graphPoints) {
+ const dist = Math.abs(xToPx(p.time) - mouseX);
+ if (dist < closestDist) {
+ closestDist = dist;
+ closest = p;
+ }
+ }
+ return closest;
+}
+
+/// Returns the closest error point to (mouseX, mouseY) in canvas-local
+/// pixels, if within `radius` px, else null. Used to make the error dots
+/// respond to hover instead of being purely decorative.
+export function hitTestError(canvas, errorPoints, graphPoints, xMax, mouseX, mouseY, radius = 8) {
+ if (errorPoints.length === 0) return null;
+ const { xToPx, yErrToPx } = getPlotGeometry(canvas, graphPoints, xMax, errorPoints);
+ let closest = null;
+ let closestDist = radius;
+ for (const p of errorPoints) {
+ const px = xToPx(p.x);
+ const py = yErrToPx(p.count || 1);
+ const dist = Math.hypot(px - mouseX, py - mouseY);
+ if (dist <= closestDist) {
+ closestDist = dist;
+ closest = p;
+ }
+ }
+ return closest;
+}