; const NATO = { A: "Alfa", B: "Bravo", C: "Charlie", D: "Delta", E: "Echo", F: "Foxtrot", G: "Golf", H: "Hotel", I: "India", J: "Juliett", K: "Kilo", L: "Lima", M: "Mike", N: "November", O: "Oscar", P: "Papa", Q: "Quebec", R: "Romeo", S: "Sierra", T: "Tango", U: "Uniform", V: "Victor", W: "Whiskey", X: "X-ray", Y: "Yankee", Z: "Zulu" }; const DIGITS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine"]; // Symbol names follow TextFormatter's natoPhonetic.js, in plain spoken form const SYMBOLS = { ".": "full stop", ",": "comma", ":": "colon", ";": "semicolon", "'": "apostrophe", "\"": "double quote", "?": "question mark", "!": "exclamation mark", "(": "open bracket", ")": "close bracket", "-": "hyphen", "_": "underscore", "+": "plus", "=": "equals", "/": "forward slash", "\\": "backslash", "&": "ampersand", "@": "at sign", "#": "hash", "*": "asterisk", "%": "percent", "$": "dollar", "£": "pound sign", "€": "euro", "<": "less than", ">": "greater than", "|": "vertical bar", "~": "tilde", "`": "backtick", "^": "caret", "{": "open curly bracket", "}": "close curly bracket", "[": "open square bracket", "]": "close square bracket", "§": "section sign", "©": "copyright", "®": "registered", "†": "dagger", "¶": "pilcrow", "±": "plus-minus", "∞": "infinity", "…": "ellipsis", "‰": "per mille", "≠": "not equal", "µ": "micro", "¬": "not sign", "√": "square root", "°": "degree", "—": "em dash", "–": "en dash", "‘": "left single quote", "’": "right single quote", "“": "left double quote", "”": "right double quote", "·": "middle dot", "×": "times", "÷": "divide" }; const SPACES = { " ": "space", "\t": "tab", "\n": "new line", "\r": "return", " ": "non-breaking space" }; const CONFUSABLE = new Set(["l", "1", "I", "|", "O", "0", "o"]); function graphemes(s) { if (window.Intl && Intl.Segmenter) return Array.from(new Intl.Segmenter("en", { granularity: "grapheme" }).segment(s), (x) => x.segment); return Array.from(s); } function describe(ch) { const base = ch.normalize("NFD").replace(/[̀-ͯ]/g, ""); const accent = base !== ch && base.length === 1 ? " with accent" : ""; if (/^[A-Z]$/.test(base)) return { kind: "upper", label: "Capital", word: NATO[base].toUpperCase() + accent }; if (/^[a-z]$/.test(base)) return { kind: "lower", label: "Lower", word: NATO[base.toUpperCase()].toLowerCase() + accent }; if (/^[0-9]$/.test(ch)) return { kind: "digit", label: "Digit", word: DIGITS[ch] }; if (SPACES[ch]) return { kind: "space", label: "Space", word: SPACES[ch] }; if (SYMBOLS[ch]) return { kind: "symbol", label: "Symbol", word: SYMBOLS[ch] }; const cp = ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0"); return { kind: "symbol", label: "Symbol", word: "character U+" + cp }; } function show(ch) { return SPACES[ch] ? (ch === " " ? "␣" : ch === "\t" ? "⇥" : "↵") : ch; } const chars = graphemes(TEXT); const counts = { upper: 0, lower: 0, digit: 0, symbol: 0, space: 0 }; const source = document.getElementById("source"); const groups = document.getElementById("groups"); let group; chars.forEach((ch, i) => { const d = describe(ch); counts[d.kind]++; const s = document.createElement("span"); s.className = "t-" + d.kind; s.style.color = "var(--c)"; s.textContent = show(ch); source.appendChild(s); if (i % 4 === 0) { group = document.createElement("div"); group.className = "group"; groups.appendChild(group); } const t = document.createElement("div"); t.className = "tile t-" + d.kind; t.title = d.word; const parts = [["idx", String(i + 1)], ["ch", show(ch)], ["word", d.word], ["kind", d.label]]; if (CONFUSABLE.has(ch)) parts.push(["warn", "⚠"]); for (const [cls, text] of parts) { const el = document.createElement("div"); el.className = cls; el.textContent = text; t.appendChild(el); } t.addEventListener("click", () => go(i)); group.appendChild(t); }); const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s"); document.getElementById("summary").textContent = plural(chars.length, "character") + ": " + [[counts.upper, "capital"], [counts.lower, "lower-case letter"], [counts.digit, "digit"], [counts.symbol, "symbol"], [counts.space, "space"]].filter(([n]) => n).map(([n, w]) => plural(n, w)).join(", "); // Read-out mode: step through the characters, fading the ones already read const tiles = document.querySelectorAll(".tile"); const marks = source.children; const progress = document.getElementById("progress"); let cur = -1; function go(n) { cur = Math.max(-1, Math.min(n, tiles.length - 1)); tiles.forEach((t, i) => { t.classList.toggle("done", i < cur); t.classList.toggle("current", i === cur); marks[i].classList.toggle("done", i < cur); marks[i].classList.toggle("current", i === cur); }); progress.textContent = cur < 0 ? "Ready" : (cur + 1) + " / " + tiles.length; if (cur >= 0) tiles[cur].scrollIntoView({ block: "nearest", behavior: "smooth" }); } document.addEventListener("keydown", (e) => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.key === " " || e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Enter") go(cur + 1); else if (e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "Backspace") go(cur - 1); else if (e.key === "Escape" || e.key === "Home") go(-1); else if (e.key === "End") go(tiles.length - 1); else return; e.preventDefault(); });