srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens/endScreen.js
blob: d9dc6fcb32d37ee1f357e408e54366733e2922a7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
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
        ? '<div class="end-stat pb-new"><div class="stat-label">PB</div><div class="stat-value">NEW BEST</div></div>'
        : `<div class="end-stat"><div class="stat-label">PB</div><div class="stat-value">${Math.round(previous)}</div></div>`;

    root.innerHTML = `
        <div class="end-screen">
            ${logoLockup()}
            <div class="end-screen-text"><div class="text-display"></div></div>
            <div class="attribution end-screen-attribution">&mdash; ${escapeHtml(attribution || 'Unknown')}${category ? `<span class="attribution-pack">${escapeHtml(category)}</span>` : ''}</div>
            ${explanation ? `<div class="code-explainer"><span class="code-explainer-label">What this does</span>${escapeHtml(explanation)}</div>` : ''}
            <div class="end-screen-graph-row">
                <div class="endscreen-side-stat wpm">
                    <div class="end-stat headline"><div class="stat-label">WPM</div><div class="stat-value">${Math.round(stats.wpm)}</div></div>
                    <div class="end-stat"><div class="stat-label">RAW</div><div class="stat-value">${Math.round(stats.rawWpm)}</div></div>
                    ${pbLine}
                </div>
                <div class="graph-container"><canvas></canvas></div>
                <div class="endscreen-side-stat acc">
                    <div class="end-stat headline"><div class="stat-label">ACC</div><div class="stat-value">${Math.round(stats.accuracy)}%</div></div>
                    <div class="end-stat"><div class="stat-label">ERR</div><div class="stat-value">${stats.incorrectChars}</div></div>
                    <div class="end-stat"><div class="stat-label">CON</div><div class="stat-value">${consistency}%</div></div>
                </div>
            </div>
            <div class="end-screen-stat-row">
                <div class="end-stat"><div class="stat-label">KEYSTROKES</div><div class="stat-value">${stats.keystrokes}</div></div>
                <div class="end-stat headline"><div class="stat-label">TIME</div><div class="stat-value">${stats.time.toFixed(1)}s</div></div>
                <div class="end-stat" data-tooltip="Correct / wrong / typed past the end / left untyped">
                    <div class="stat-label">CHARACTERS</div><div class="stat-value">${stats.correctChars}/${stats.incorrectChars}/${extraChars}/${missedChars}</div>
                </div>
            </div>
            ${standings && standings.length ? `
            <div class="mp-standings">
                <div class="mp-standings-heading">Standings</div>
                <div class="mp-standings-rows"></div>
            </div>` : ''}
            <div class="end-screen-buttons">
                <button class="end-screen-button primary" data-action="again">Play Again</button>
            </div>
        </div>
    `;

    // 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 = `
            <div class="mp-standings-row mp-standings-head">
                <span></span><span>Racer</span><span>WPM</span><span>Acc</span><span>Time</span>
            </div>
        ` + list.map(r => `
            <div class="mp-standings-row${r.me ? ' me' : ''}${r.place ? '' : ' racing'}" data-racer-color="${r.color || 'var(--primary-color)'}">
                <span class="mp-standings-place">${r.place ? r.place : '&middot;'}</span>
                <span class="mp-standings-name">${escapeHtml(r.name)}${r.me ? ' (you)' : ''}</span>
                <span class="mp-standings-wpm">${Math.round(r.wpm)}</span>
                <span class="mp-standings-acc">${r.place ? `${Math.round(r.accuracy)}%` : `${Math.round(r.percent)}%`}</span>
                <span class="mp-standings-time">${r.place && r.time != null ? `${r.time.toFixed(1)}s` : 'racing'}</span>
            </div>
        `).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('<span>');
        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(`<span class="${className}">${escapeHtml(displayChar)}</span>`);
            charIndex++;
        }
        html.push('</span>');
    }
    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();
}