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
? '
`;
// 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 = `
`).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();
}