import { buildGraphPoints, buildErrorPoints, drawChart, hitTestError, hitTestLine, calculateConsistency } from '../chart.js'; import { logoLockup } from '../logo.js'; import { escapeHtml } from '../util.js'; import { attachTooltips } from '../tooltip.js'; import { onThemeChange } from '../theme.js'; import { renderSiteFooter } from '../siteFooter.js'; import { recordResult } from '../pb.js'; import { recordTest } from '../profileStats.js'; import { renderCornerRail } from '../cornerRail.js'; import { renderTopRail } from '../topRail.js'; import { submitTestResult } from '../auth.js'; import { detectDeviceType } from '../deviceDetect.js'; export function renderEndScreen(root, { stats, text, attribution, category, explanation, standings, onShowPrivacy, onStandingsUpdate, onLeaveRace, userInput, charTimings, keypressHistory, modeKey, onPlayAgain, onMainMenu, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }) { // Monkeytype's four-way split. "Extra" is anything typed past the end of // the passage, "missed" is passage left untyped - neither is visible in // a plain correct/incorrect pair, and the two mean very different things // about a run that ended early. const typedLen = (userInput || '').length; const extraChars = Math.max(0, typedLen - (text || '').length); const missedChars = Math.max(0, (text || '').length - typedLen); const graphPoints = buildGraphPoints(charTimings, stats, keypressHistory); const consistency = calculateConsistency(graphPoints); const isRace = Array.isArray(standings) && standings.length > 0; // A race has no mode of its own, so it had no modeKey, so recordResult // never ran and PB was missing from the multiplayer results entirely. // Every race shares one key: the passage is whatever the server dealt, so // a per-passage best would never be beaten twice. const pbKey = modeKey || (isRace ? 'multiplayer' : null); const { isNewBest, previous } = recordResult(pbKey, stats.wpm); recordTest({ wpm: stats.wpm, accuracy: stats.accuracy, time: stats.time }); // Same exclusion as recordResult above: custom text/Zen have no stable, // repeatable challenge, so there's no meaningful leaderboard entry for // them either. if (modeKey) { // Inter-keystroke gaps (not raw timestamps) - the server's // anti-cheat check only cares about the *variance* between // consecutive keystrokes, not when the test started. const source = keypressHistory && keypressHistory.length > 1 ? keypressHistory : null; const keystrokeIntervalsMs = source ? source.slice(1).map((k, i) => (k.time - source[i].time) * 1000).filter(ms => ms >= 0) : undefined; submitTestResult({ mode_key: modeKey, wpm: stats.wpm, raw_wpm: stats.rawWpm, accuracy: stats.accuracy, time_seconds: stats.time, device_type: detectDeviceType(), keystroke_intervals_ms: keystrokeIntervalsMs, }); } // Don't announce "new best" on a mode's very first-ever run - there's // nothing to have beaten yet, so it just silently becomes the baseline. // Sits under WPM alongside RAW, in the same label-over-value shape as // every other secondary figure rather than as a floating badge. const pbLine = !pbKey || previous == null ? '' : isNewBest ? '
PB
NEW BEST
' : `
PB
${Math.round(previous)}
`; root.innerHTML = `
${logoLockup()}
— ${escapeHtml(attribution || 'Unknown')}${category ? `${escapeHtml(category)}` : ''}
${explanation ? `
What this does${escapeHtml(explanation)}
` : ''}
WPM
${Math.round(stats.wpm)}
RAW
${Math.round(stats.rawWpm)}
${pbLine}
ACC
${Math.round(stats.accuracy)}%
ERR
${stats.incorrectChars}
CON
${consistency}%
KEYSTROKES
${stats.keystrokes}
TIME
${stats.time.toFixed(1)}s
CHARACTERS
${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}
${standings && standings.length ? `
Standings
` : ''}
`; // Rendered separately from the markup above because it keeps changing: // finishing first leaves the rest of the field still typing, and their // rows fill in as they come home. const standingsRows = root.querySelector('.mp-standings-rows'); function paintStandings(list) { if (!standingsRows) return; // Place, who, speed, accuracy, time - the columns TypeRacer and // 10FastFingers both show after a race. Anyone still typing has no // place or time yet, so their row carries how far along they are. standingsRows.innerHTML = `
RacerWPMAccTime
` + list.map(r => `
${r.place ? r.place : '·'} ${escapeHtml(r.name)}${r.me ? ' (you)' : ''} ${Math.round(r.wpm)} ${r.place ? `${Math.round(r.accuracy)}%` : `${Math.round(r.percent)}%`} ${r.place && r.time != null ? `${r.time.toFixed(1)}s` : 'racing'}
`).join(''); // See multiplayerScreen.js: a style attribute is blocked by the CSP, // the same property set through element.style is not. standingsRows.querySelectorAll('[data-racer-color]').forEach(el => { el.style.setProperty('--racer-color', el.dataset.racerColor); }); } if (standings && standings.length) paintStandings(standings); const offStandings = onStandingsUpdate ? onStandingsUpdate(paintStandings) : null; const textDisplay = root.querySelector('.text-display'); const inputChars = userInput ? userInput.split('') : []; const wordMatches = text.match(/[^\s]+\s*/g) || []; let charIndex = 0; const html = []; for (const word of wordMatches) { html.push(''); for (const char of word) { const inputChar = inputChars[charIndex]; let className = 'neutral'; let displayChar = char; if (charIndex < inputChars.length) { className = inputChar === char ? 'correct' : 'incorrect'; if (className === 'incorrect') displayChar = inputChar; } html.push(`${escapeHtml(displayChar)}`); charIndex++; } html.push(''); } textDisplay.innerHTML = html.join(''); attachTooltips(root.querySelector('.end-screen-stat-row')); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); // Share here carries the result just achieved, not just the site. const cleanupFooter = renderSiteFooter(root, { result: stats, onShowPrivacy }); // Below the results and above Play Again: read after the run is over, // never during it. root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onMainMenu)); root.querySelector('[data-action="again"]').addEventListener('click', onPlayAgain); const canvas = root.querySelector('canvas'); const errorPoints = buildErrorPoints(graphPoints, keypressHistory, charTimings); const xMax = Math.max(1, Math.ceil(stats.time)); const redraw = () => drawChart(canvas, { graphPoints, errorPoints, xMax }); redraw(); window.addEventListener('resize', redraw); // The chart is drawn into a canvas, so it cannot inherit a theme change // the way the rest of the page does - it has to be redrawn. Without this // switching theme on the end screen left the graph in the old palette. const offTheme = onThemeChange(redraw); // A long passage wraps to more lines than a short one, growing // .end-screen-text's height by an amount no fixed CSS margin/graph-height // budget can account for - a static tuning only ever fits whichever // passage happened to be on screen when it was measured. This claws the // room back in stages instead, each one only kicking in if the last // wasn't enough, so the page itself never needs to scroll to reach // There was an auto-fit routine here that forced this screen into one // viewport: it shrank the graph to a 120px floor, then trimmed the Play // Again margin, then capped the passage box at 80px with its own // scrollbar. On an ordinary laptop window that left the two things the // screen exists to show - the passage you just typed and the graph of // how you typed it - both too small to read, and the passage clipped // rather than scrolled to. // // A results screen is read, not acted on under time pressure, so the // page scrolls instead. Nothing is hidden and nothing is squeezed. const tooltip = document.createElement('div'); tooltip.className = 'chart-tooltip'; root.querySelector('.graph-container').appendChild(tooltip); const handleMove = e => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; const errorHit = hitTestError(canvas, errorPoints, graphPoints, xMax, mx, my); if (errorHit) { canvas.style.cursor = 'pointer'; const word = errorHit.index != null ? wordAt(text, errorHit.index) : ''; const n = errorHit.count || 1; tooltip.textContent = n > 1 ? `${n} errors${word ? ` · first at "${word}"` : ''}` : (word ? `Error: "${word}"` : 'Error'); tooltip.style.left = `${mx}px`; tooltip.style.top = `${my}px`; tooltip.style.display = 'block'; return; } const linePoint = hitTestLine(canvas, graphPoints, xMax, mx); if (linePoint) { canvas.style.cursor = 'crosshair'; // Time last: the figures are what you are reading for, and the // second it happened is the qualifier on them. tooltip.textContent = `wpm ${Math.round(linePoint.wpm)} · raw ${Math.round(linePoint.raw)} · ${linePoint.time}s`; tooltip.style.left = `${mx}px`; tooltip.style.top = `${my}px`; tooltip.style.display = 'block'; } else { canvas.style.cursor = 'default'; tooltip.style.display = 'none'; } }; const handleLeave = () => { tooltip.style.display = 'none'; }; canvas.addEventListener('mousemove', handleMove); canvas.addEventListener('mouseleave', handleLeave); // Quick restart: Tab on the results screen is the keyboard equivalent // of clicking Play Again. const handleKeydown = e => { if (e.key === 'Tab') { e.preventDefault(); onPlayAgain(); } }; document.addEventListener('keydown', handleKeydown); const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer, onShowStore }); return () => { // Leaving the end screen is what finally drops the race connection -- // it was kept open so the remaining racers could still come in. offStandings?.(); onLeaveRace?.(); cleanupTheme(); cleanupFooter(); window.removeEventListener('resize', redraw); offTheme(); canvas.removeEventListener('mousemove', handleMove); canvas.removeEventListener('mouseleave', handleLeave); document.removeEventListener('keydown', handleKeydown); cleanupRail(); }; } function wordAt(text, index) { let start = index; let end = index; while (start > 0 && text[start - 1] !== ' ') start--; while (end < text.length && text[end] !== ' ') end++; return text.slice(start, end).trim(); }