Spaces:
Running on Zero
Running on Zero
Download avatar/transcript.js from WolfDavid/japanese-learning-avatar: direct link, hf CLI and curl.
- Browser
- Download file 18.7 kB
-
https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/fd38c7cef051678f03510b433e58275960e449fc/avatar/transcript.js
- Command line
-
hf download hf://spaces/WolfDavid/japanese-learning-avatar@fd38c7cef051678f03510b433e58275960e449fc/avatar/transcript.js
-
curl -L -o transcript.js https://huggingface.co/spaces/WolfDavid/japanese-learning-avatar/resolve/fd38c7cef051678f03510b433e58275960e449fc/avatar/transcript.js
18.7 kB
| // avatar/transcript.js | |
| // | |
| // DOM ONLY. Knows nothing about the facade, the transports or the host framework; builds | |
| // children with createElement/textContent so glosses and learner text are never parsed as | |
| // HTML. tests/test_transport_seam.py enforces it. | |
| // | |
| // The renderer behind #transcript-text (plan 02-07). host.js hands it the container and | |
| // feeds it lines and the analyzer's token records; it turns each token's ruby spans into | |
| // real <ruby><rt> elements, gates the readings by mode and level on the KANJI axis | |
| // (D-02 / D-13), and publishes the numbers that prove the ruby rendered - the rt count, | |
| // the rendered rt height and the line height - on debug.furigana, so a stylesheet that | |
| // hides the annotation cannot pass a count (research Pitfall 4, the T-pose lesson). | |
| // | |
| // Every line's tokens are retained, so a mode or level change re-renders every line from | |
| // data with no round trip (D-14), and a tap on a word (plan 02-08) reads its reading, its | |
| // dictionary form, its level and its JMdict glosses straight out of the record the line | |
| // already holds - the lookup issues no request of any kind (D-05 / D-07, research § Q3). | |
| // Plan 02-09 adds the translation reveal under each line. | |
| // | |
| // The card is a positioned <div>, not the Popover API: iOS 16 Safari is still in the field | |
| // without it and its top-layer behaviour inside the Hub's cross-origin frame is unverified | |
| // (research § Q6). Anchoring is 40 lines of getBoundingClientRect + clamp that publish | |
| // their numbers on debug.popover, so "it stayed inside the phone column" is an assertion | |
| // rather than a screenshot. Press-and-hold is deliberately not a gesture here (D-04), and | |
| // the card is read-only: no save, no tap history (D-08). | |
| // | |
| // The token record (plan 02-05, nlp/analyzer.TOKEN_KEYS): `surface`, `tappable`, `jlpt`, | |
| // `kanji_levels` ({kanji: "N5".."N1" | null}) and `ruby` ([[text, rt | null], ...] - one | |
| // span per kanji run with its reading, kana spans with null). Non-tappable units carry | |
| // [[surface, null]] and never get an annotation. | |
| /** Difficulty order for the gate; mirrors nlp/levels.LEVEL_RANK. */ | |
| export const LEVEL_RANK = { N5: 1, N4: 2, N3: 3, N2: 4, N1: 5 }; | |
| /** The three-way control (D-02). 'above' = "above my level". */ | |
| export const FURIGANA_MODES = ['always', 'above', 'never']; | |
| /** The level picker's range (D-14). N1 is not offered: at N1 "above my level" is 'never'. */ | |
| export const LEVELS = ['N5', 'N4', 'N3', 'N2']; | |
| const LABELS = { you: 'You: ', avatar: 'Avatar: ', slower: 'Avatar (slower): ' }; | |
| /** | |
| * The badge text for a token's WORD-level axis. N5..N1 read as themselves; the two values | |
| * the analyser uses for "not on a list" are spelled out on the card so the learner is never | |
| * told a level the data does not claim (D-10 / D-12). | |
| */ | |
| const LEVEL_LABELS = { 'N1+': 'N1+ / beyond lists', name: 'name' }; | |
| /** D-07: the card shows the first two or three senses, English glosses only. */ | |
| const MAX_SENSES = 3; | |
| /** The gap between the tapped word and the card, and the inset from the column's edges. */ | |
| const ANCHOR_GAP_PX = 6; | |
| const EDGE_INSET_PX = 8; | |
| /** The card never grows past this, nor past the room beside the word (see position). */ | |
| const CARD_MAX_HEIGHT_PX = 240; | |
| /** Below this a card is not worth showing at all, so it is allowed to overflow instead. */ | |
| const MIN_CARD_HEIGHT_PX = 48; | |
| function clamp(value, low, high) { | |
| return Math.min(Math.max(value, low), high); | |
| } | |
| /** | |
| * D-02 / D-13: a run gets its rt when the mode says so; 'above' keys on the KANJI axis - | |
| * any kanji in the run above the learner's level, or unlisted (null, D-10), keeps the whole | |
| * run annotated. A run whose every kanji is at or below the level renders bare. | |
| * | |
| * @param {string} runText the base text of one ruby span | |
| * @param {object} kanjiLevels the token's {kanji: level | null} | |
| * @param {string} mode one of FURIGANA_MODES | |
| * @param {string} level one of LEVELS | |
| */ | |
| export function showRt(runText, kanjiLevels, mode, level) { | |
| if (mode === 'always') return true; | |
| if (mode === 'never') return false; | |
| const mine = LEVEL_RANK[level] ?? 1; | |
| const levels = kanjiLevels || {}; | |
| for (const ch of runText) { | |
| if (!(ch in levels)) continue; // kana inside a run never happens; defensive | |
| const lv = levels[ch]; | |
| if (lv == null || (LEVEL_RANK[lv] ?? 99) > mine) return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * @param {Element} container the project-owned #transcript-text element | |
| * @param {object} [opts] | |
| * @param {Document} [opts.doc] | |
| */ | |
| export function createTranscript(container, { doc = document } = {}) { | |
| if (!container) throw new Error('createTranscript: a container element is required'); | |
| // Every key seeded here so the key set on getDebug() never depends on timing. | |
| const debug = { | |
| furigana: { | |
| mode: 'always', | |
| level: 'N5', | |
| storage: 'unknown', // host.js sets 'ok' | 'unavailable' after its browser-storage probe | |
| lines: 0, | |
| rtTotal: 0, | |
| lastLineId: null, | |
| lastLineRt: 0, | |
| lastLineKanjiRuns: 0, | |
| lastLineRtHeightPx: 0, | |
| lastLineHeightPx: 0, | |
| }, | |
| // The lookup card's numbers (plan 02-08, research § Q6). insideTranscript is the phone | |
| // assertion: the card's rect within the column's rect. null until the first open, so | |
| // "never opened" and "opened outside" are different readings. | |
| popover: { | |
| open: false, | |
| lineId: null, | |
| tokenIndex: null, | |
| surface: null, | |
| reading: null, | |
| level: null, | |
| glossCount: 0, | |
| left: 0, | |
| top: 0, | |
| width: 0, | |
| insideTranscript: null, | |
| openCount: 0, | |
| closeCount: 0, | |
| }, | |
| }; | |
| /** lineId -> { el, said, who, text } in insertion order. */ | |
| const lines = new Map(); | |
| /** lineId -> the retained token records, so a re-render is pure. */ | |
| const tokensByLine = new Map(); | |
| let count = 0; | |
| let mode = 'always'; | |
| let level = 'N5'; | |
| /** The number of [text, rt] spans WITH a reading across a line's tokens: the potential. */ | |
| function kanjiRuns(tokens) { | |
| let runs = 0; | |
| for (const token of tokens) { | |
| for (const span of token.ruby || []) if (span[1]) runs += 1; | |
| } | |
| return runs; | |
| } | |
| function renderTokens(said, tokens) { | |
| said.replaceChildren(); | |
| tokens.forEach((token, index) => { | |
| const el = doc.createElement('span'); | |
| if (token.tappable) { | |
| el.className = 'tok'; | |
| el.dataset.token = String(index); | |
| el.setAttribute('role', 'button'); | |
| el.tabIndex = 0; | |
| if (token.jlpt) el.dataset.level = token.jlpt; | |
| } else { | |
| el.className = 'plain'; | |
| } | |
| const spans = | |
| Array.isArray(token.ruby) && token.ruby.length > 0 | |
| ? token.ruby | |
| : [[String(token.surface ?? ''), null]]; | |
| for (const [text, rt] of spans) { | |
| if (rt && showRt(text, token.kanji_levels, mode, level)) { | |
| const ruby = doc.createElement('ruby'); | |
| ruby.append(doc.createTextNode(text)); | |
| const rtEl = doc.createElement('rt'); | |
| rtEl.textContent = rt; | |
| ruby.append(rtEl); | |
| el.append(ruby); | |
| } else { | |
| el.append(doc.createTextNode(text)); | |
| } | |
| } | |
| said.append(el); | |
| }); | |
| } | |
| /** | |
| * The published numbers, read from the rendered DOM after every (re)render. The line | |
| * height is the `.turn` block's: an inline `.said` box reports only its own font's | |
| * content area, which does not grow when an annotation sits above it, while the block | |
| * that contains the line boxes does - so the block is where "a ruby line is taller than | |
| * a bare one" is measurable. | |
| */ | |
| function measure(lineId) { | |
| const line = lines.get(lineId); | |
| const f = debug.furigana; | |
| f.lines = lines.size; | |
| f.rtTotal = container.querySelectorAll('rt').length; | |
| if (!line) return; | |
| f.lastLineId = lineId; | |
| f.lastLineRt = line.said.querySelectorAll('rt').length; | |
| f.lastLineKanjiRuns = kanjiRuns(tokensByLine.get(lineId) || []); | |
| const rt = line.said.querySelector('rt'); | |
| f.lastLineRtHeightPx = rt ? rt.getBoundingClientRect().height : 0; | |
| f.lastLineHeightPx = line.el.getBoundingClientRect().height; | |
| } | |
| function render(lineId) { | |
| const line = lines.get(lineId); | |
| if (!line) return; | |
| const tokens = tokensByLine.get(lineId); | |
| if (tokens && tokens.length > 0) renderTokens(line.said, tokens); | |
| else line.said.textContent = line.text; // no tokens (yet, or the analysis failed) | |
| measure(lineId); | |
| } | |
| function renderAll() { | |
| for (const lineId of lines.keys()) render(lineId); | |
| } | |
| /** | |
| * Append a line as plain text; tokens arrive through setTokens (immediately for the | |
| * avatar's lines, after the analyze round trip for the learner's). | |
| * | |
| * @param {{who: 'you'|'avatar'|'slower', text: string}} line | |
| * @returns {{lineId: string, el: Element}} | |
| */ | |
| function addLine({ who, text }) { | |
| count += 1; | |
| const lineId = `L${count}`; | |
| const el = doc.createElement('div'); | |
| el.className = `turn turn-${who}`; | |
| el.dataset.line = lineId; | |
| el.dataset.who = who; | |
| const label = doc.createElement('span'); | |
| label.className = 'who'; | |
| label.textContent = LABELS[who] ?? LABELS.avatar; | |
| const said = doc.createElement('span'); | |
| said.className = 'said'; | |
| said.textContent = String(text ?? ''); | |
| el.append(label, said); | |
| container.append(el); | |
| container.scrollTop = container.scrollHeight; | |
| lines.set(lineId, { el, said, who, text: String(text ?? '') }); | |
| measure(lineId); | |
| return { lineId, el }; | |
| } | |
| /** | |
| * Attach (or replace) a line's tokens and rebuild its `.said` children from them. | |
| * @returns {boolean} whether the line exists | |
| */ | |
| function setTokens(lineId, tokens) { | |
| if (!lines.has(lineId)) return false; | |
| closePopover(); // the line's spans are rebuilt below; the card's anchor would be stale | |
| tokensByLine.set(lineId, Array.isArray(tokens) ? tokens : []); | |
| render(lineId); | |
| return true; | |
| } | |
| // ------------------------------------------------------------- the lookup card (02-08) | |
| const popover = debug.popover; | |
| /** Built on the first open and reused; `card.el` is the one #lookup-popover element. */ | |
| let card = null; | |
| let pointerBound = false; | |
| function buildCard() { | |
| const el = doc.createElement('div'); | |
| el.id = 'lookup-popover'; | |
| el.className = 'lookup-popover'; | |
| el.setAttribute('role', 'dialog'); | |
| el.setAttribute('aria-label', 'Word lookup'); | |
| el.hidden = true; | |
| const head = doc.createElement('div'); | |
| head.className = 'lk-head'; | |
| const surface = doc.createElement('span'); | |
| surface.className = 'lk-surface'; | |
| const reading = doc.createElement('span'); | |
| reading.className = 'lk-reading'; | |
| const badge = doc.createElement('span'); | |
| badge.className = 'lk-level'; | |
| head.append(surface, reading, badge); | |
| const lemma = doc.createElement('div'); | |
| lemma.className = 'lk-lemma'; | |
| const gloss = doc.createElement('ol'); | |
| gloss.className = 'lk-gloss'; | |
| const none = doc.createElement('div'); | |
| none.className = 'lk-none'; | |
| none.textContent = 'no dictionary entry'; | |
| none.hidden = true; | |
| el.append(head, lemma, gloss, none); | |
| container.append(el); | |
| card = { el, surface, reading, badge, lemma, gloss, none }; | |
| return card; | |
| } | |
| /** | |
| * Anchor the card under (or above) the tapped word and publish where it landed. | |
| * | |
| * Offsets are in the container's PADDING-BOX coordinates - what `position: absolute` | |
| * inside `#transcript-text` resolves against - so `clientLeft` / `clientTop` (the border | |
| * widths) come off the viewport rects, and `scrollTop` converts the visible position into | |
| * the scrolled content's. The card is clamped horizontally into the column and placed on | |
| * whichever side of the word has more room, never growing past that room - it scrolls | |
| * internally instead. Those two rules together are what make the published numbers true | |
| * by construction rather than by luck: the card cannot leave the column | |
| * (insideTranscript) and cannot cover the word that was tapped, which is what would | |
| * otherwise make "tap the next word to re-anchor" impossible on a short phone column. | |
| */ | |
| function position(el, tokEl) { | |
| el.style.left = '0px'; | |
| el.style.top = '0px'; | |
| el.style.maxHeight = `${CARD_MAX_HEIGHT_PX}px`; | |
| const contRect = container.getBoundingClientRect(); | |
| const tokRect = tokEl.getBoundingClientRect(); | |
| const viewTop = container.scrollTop; | |
| const viewBottom = viewTop + container.clientHeight; | |
| const tokTop = tokRect.top - contRect.top - container.clientTop + viewTop; | |
| const tokBottom = tokTop + tokRect.height; | |
| const roomBelow = viewBottom - (tokBottom + ANCHOR_GAP_PX) - 2; | |
| const roomAbove = tokTop - ANCHOR_GAP_PX - viewTop - 2; | |
| const below = roomBelow >= roomAbove; | |
| const room = Math.max(MIN_CARD_HEIGHT_PX, below ? roomBelow : roomAbove); | |
| el.style.maxHeight = `${Math.round(Math.min(CARD_MAX_HEIGHT_PX, room))}px`; | |
| const width = el.offsetWidth; | |
| const height = el.offsetHeight; | |
| const maxLeft = Math.max(EDGE_INSET_PX, container.clientWidth - width - EDGE_INSET_PX); | |
| const left = clamp( | |
| tokRect.left - contRect.left - container.clientLeft, | |
| EDGE_INSET_PX, | |
| maxLeft | |
| ); | |
| const top = clamp( | |
| below ? tokBottom + ANCHOR_GAP_PX : tokTop - ANCHOR_GAP_PX - height, | |
| viewTop, | |
| Math.max(viewTop, viewBottom - height) | |
| ); | |
| el.style.left = `${Math.round(left)}px`; | |
| el.style.top = `${Math.round(top)}px`; | |
| const rect = el.getBoundingClientRect(); | |
| popover.left = Math.round(left); | |
| popover.top = Math.round(top); | |
| popover.width = rect.width; | |
| popover.insideTranscript = | |
| rect.left >= contRect.left - 0.5 && | |
| rect.right <= contRect.right + 0.5 && | |
| rect.top >= contRect.top - 0.5 && | |
| rect.bottom <= contRect.bottom + 0.5; | |
| } | |
| /** | |
| * Show the card for one token of one line, filled from THAT token's own record. | |
| * | |
| * Every field is written with textContent: the glosses are dictionary data and the | |
| * surfaces can be anything the learner typed. Returns a copy of the published numbers. | |
| * | |
| * @param {string} lineId | |
| * @param {number} tokenIndex the index the `.tok` carries in data-token | |
| */ | |
| function openPopover(lineId, tokenIndex) { | |
| const line = lines.get(lineId); | |
| const token = (tokensByLine.get(lineId) || [])[tokenIndex]; | |
| if (!line || !token) return { ...popover }; | |
| const tokEl = line.said.querySelector(`.tok[data-token="${tokenIndex}"]`); | |
| if (!tokEl) return { ...popover }; | |
| const c = card || buildCard(); | |
| const surface = String(token.surface ?? ''); | |
| const badgeValue = token.jlpt == null ? '' : String(token.jlpt); | |
| c.surface.textContent = surface; | |
| c.reading.textContent = String(token.reading ?? ''); | |
| c.badge.textContent = LEVEL_LABELS[badgeValue] ?? badgeValue; | |
| c.badge.hidden = badgeValue === ''; | |
| if (badgeValue) c.badge.dataset.level = badgeValue; | |
| else delete c.badge.dataset.level; | |
| const lemma = String(token.lemma ?? ''); | |
| const showLemma = lemma !== '' && lemma !== surface; | |
| c.lemma.textContent = showLemma ? `dictionary form: ${lemma}` : ''; | |
| c.lemma.hidden = !showLemma; | |
| const senses = Array.isArray(token.gloss) ? token.gloss.slice(0, MAX_SENSES) : []; | |
| c.gloss.replaceChildren(); | |
| for (const sense of senses) { | |
| const item = doc.createElement('li'); | |
| item.textContent = Array.isArray(sense) ? sense.join('; ') : String(sense); | |
| c.gloss.append(item); | |
| } | |
| c.gloss.hidden = senses.length === 0; | |
| c.none.hidden = senses.length > 0; | |
| c.el.hidden = false; | |
| position(c.el, tokEl); | |
| popover.open = true; | |
| popover.lineId = lineId; | |
| popover.tokenIndex = tokenIndex; | |
| popover.surface = surface; | |
| popover.reading = c.reading.textContent; | |
| popover.level = badgeValue || null; | |
| popover.glossCount = senses.length; | |
| popover.openCount += 1; | |
| return { ...popover }; | |
| } | |
| /** Hide the card. A no-op (and not counted) when it is already closed. */ | |
| function closePopover() { | |
| if (!popover.open) return { ...popover }; | |
| if (card) card.el.hidden = true; | |
| popover.open = false; | |
| popover.closeCount += 1; | |
| return { ...popover }; | |
| } | |
| function openFromElement(tok) { | |
| const line = tok.closest('[data-line]'); | |
| if (!line) return null; | |
| return openPopover(line.dataset.line, Number(tok.dataset.token)); | |
| } | |
| /** | |
| * Install the tap wiring. Idempotent: the host calls it once at bind, the standalone | |
| * harness calls it too, so there is exactly one implementation of the gesture. | |
| * | |
| * `pointerup` on the column covers mouse, touch and pen alike, with no 300 ms delay | |
| * (the page has a viewport meta and `.tok` is touch-action: manipulation). The dismiss | |
| * listener is a CAPTURE-phase pointerdown on the document, so a tap on another word | |
| * falls through to the pointerup above and re-anchors WITHOUT passing through a closed | |
| * state. Scrolling the column moves the anchor, so it closes (research § Q6). No | |
| * press-and-hold gesture exists (D-04). | |
| */ | |
| function bindPointer() { | |
| if (pointerBound) return false; | |
| pointerBound = true; | |
| container.addEventListener('pointerup', (event) => { | |
| const tok = event.target?.closest?.('.tok'); | |
| if (!tok) return; | |
| openFromElement(tok); | |
| }); | |
| doc.addEventListener( | |
| 'pointerdown', | |
| (event) => { | |
| if (event.target?.closest?.('#lookup-popover, .tok')) return; | |
| closePopover(); | |
| }, | |
| true | |
| ); | |
| doc.addEventListener('keydown', (event) => { | |
| if (event.key === 'Escape') { | |
| closePopover(); | |
| return; | |
| } | |
| if (event.key !== 'Enter' && event.key !== ' ') return; | |
| const tok = event.target?.closest?.('.tok'); | |
| if (!tok) return; | |
| event.preventDefault(); | |
| openFromElement(tok); | |
| }); | |
| container.addEventListener('scroll', () => closePopover()); | |
| return true; | |
| } | |
| function setMode(value) { | |
| if (!FURIGANA_MODES.includes(value)) return mode; | |
| closePopover(); // Pitfall 10: a re-render replaces the .tok the card is anchored to | |
| mode = value; | |
| debug.furigana.mode = mode; | |
| renderAll(); | |
| return mode; | |
| } | |
| function setLevel(value) { | |
| if (!LEVELS.includes(value)) return level; | |
| closePopover(); | |
| level = value; | |
| debug.furigana.level = level; | |
| renderAll(); | |
| return level; | |
| } | |
| return { | |
| addLine, | |
| setTokens, | |
| setMode, | |
| setLevel, | |
| getMode: () => mode, | |
| getLevel: () => level, | |
| getTokens: (lineId) => tokensByLine.get(lineId) || null, | |
| openPopover, | |
| closePopover, | |
| bindPointer, | |
| debug, | |
| }; | |
| } | |