ihki / static /trace.html
3v324v23's picture
Refactor Hebrew phoneme mapping and enhance test coverage
611de4c
Raw History Blame Contribute Delete
56.2 kB
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ืœื•ื— ืื‘ื—ื•ืŸ ืฆื™ื•ืŸ</title>
<!--
The trace dashboard: one recording through the scorer, for engineers, linguists and teachers. It reads a scoring
result with its teacher's trace (schema: docs/trace-schema.ts), from the page that opened it (the lab, the phoneme
dialog: localStorage "ihki.trace"), from a JSON file, or the mock of scripts/mock_trace.py.
Four panels follow one selected sound: hovering it anywhere (a letter of the sentence, its block on the time
strip, its node on the winning path) shows it everywhere; a click pins it.
1. the frames around it: target, competitor and neighbour posteriors, energy, and the window it was read from
(the end of the sound before to the start of the one after, where a substitution peaks)
2. its score stage by stage (GOP โ†’ exemption โ†’ cluster cap โ†’ native baseline โ†’ teacher floor)
3. the readings the audio was aligned to, and the different-sentence gauge
4. the sentence by word and sound, and how sounds add up to words and words to the sentence; under each sound,
one row per native baseline (TTS voice), the voice it was measured against (the lowest) in bold
-->
<style>
:root{
color-scheme:light;
--page:#f9f9f7; --surface:#fcfcfb; --raise:#f2f1ed;
--ink:#0b0b0b; --ink-2:#52514e; --muted:#898781; --grid:#e1e0d9; --axis:#c3c2b7; --ring:rgba(11,11,11,.10);
--s1:#2a78d6; --s2:#eb6834; --wash1:rgba(42,120,214,.10);
--good:#0ca30c; --warn:#fab219; --crit:#d03b3b; --good-bg:#e4f4e4; --warn-bg:#fdf1d4; --crit-bg:#f8e1e1;
--good-ink:#006300; --warn-ink:#7a5200; --crit-ink:#a32626;
--neutral:#898781; --font:system-ui,-apple-system,"Segoe UI",sans-serif;
--f-ar:"Noto Naskh Arabic","Geeza Pro","Arabic Typesetting","Traditional Arabic",serif;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
color-scheme:dark;
--page:#0d0d0d; --surface:#1a1a19; --raise:#232321;
--ink:#f0efec; --ink-2:#c3c2b7; --muted:#898781; --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10);
--s1:#3987e5; --s2:#d95926; --wash1:rgba(57,135,229,.16);
--good-bg:#16301a; --warn-bg:#36290b; --crit-bg:#3a1c1c; --good-ink:#5cc95c; --warn-ink:#f2c45a; --crit-ink:#ef8080;
}
}
:root[data-theme="dark"]{
color-scheme:dark;
--page:#0d0d0d; --surface:#1a1a19; --raise:#232321;
--ink:#f0efec; --ink-2:#c3c2b7; --muted:#898781; --grid:#2c2c2a; --axis:#383835; --ring:rgba(255,255,255,.10);
--s1:#3987e5; --s2:#d95926; --wash1:rgba(57,135,229,.16);
--good-bg:#16301a; --warn-bg:#36290b; --crit-bg:#3a1c1c; --good-ink:#5cc95c; --warn-ink:#f2c45a; --crit-ink:#ef8080;
}
*{box-sizing:border-box}
body{margin:0;background:var(--page);color:var(--ink);font:15px/1.5 var(--font)}
main{max-width:1280px;margin:0 auto;padding:16px;display:grid;gap:14px;grid-template-columns:minmax(0,1fr)}
header{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px}
h1{margin:0;font-size:22px;font-weight:700}
h2{margin:0;font-size:16px;font-weight:700}
.sub{color:var(--ink-2);font-size:13px}
.muted{color:var(--muted)}
.ltr{direction:ltr;unicode-bidi:isolate}
.ipa{direction:ltr;unicode-bidi:isolate;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.ar{font-family:var(--f-ar);direction:rtl}
.spacer{flex:1}
button,select,label.file{font:600 13px var(--font);border:1px solid var(--axis);background:var(--surface);color:var(--ink);border-radius:8px;padding:6px 10px;cursor:pointer}
button:hover,label.file:hover{background:var(--raise)}
label.file input{display:none}
dialog{border:1px solid var(--axis);border-radius:14px;background:var(--surface);color:var(--ink);padding:18px;width:min(92vw,460px)}
dialog::backdrop{background:rgba(0,0,0,.48)}
.exclude-form{display:flex;flex-direction:column;gap:12px}
.exclude-form h2{font-size:18px}.exclude-form label{display:flex;flex-direction:column;gap:5px}
.exclude-form select,.exclude-form input{width:100%;padding:8px 10px;border:1px solid var(--axis);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit}
.dialog-actions{display:flex;gap:8px;justify-content:flex-end}
.panel{background:var(--surface);border:1px solid var(--ring);border-radius:14px;padding:14px 16px;min-width:0;display:flex;flex-direction:column;gap:10px}
.panel>.head{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px}
.grid2{display:grid;gap:14px;grid-template-columns:minmax(0,1.6fr) minmax(0,1fr)}
@media (max-width:900px){.grid2{grid-template-columns:minmax(0,1fr)}}
svg{display:block;width:100%;height:auto;overflow:visible}
svg text{font:11px var(--font);fill:var(--muted)}
svg .lbl{fill:var(--ink-2)}
svg .strong{fill:var(--ink);font-weight:700}
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:12px;color:var(--ink-2)}
.legend i{display:inline-block;width:14px;height:3px;border-radius:2px;vertical-align:middle;margin-inline-end:5px}
.legend i.box{height:10px;width:14px;border-radius:3px}
.facts{display:flex;flex-wrap:wrap;gap:6px}
.facts span{background:var(--raise);border-radius:8px;padding:3px 8px;font-size:12px;color:var(--ink-2)}
.facts b{color:var(--ink);font-weight:700}
.frame-tools{display:flex;align-items:center;gap:8px;margin-inline-start:auto}
.frame-tools button{padding:4px 9px}
.frame-race{display:grid;gap:4px;direction:ltr;min-height:84px}
.race-row{display:grid;grid-template-columns:54px minmax(80px,1fr) 54px;gap:7px;align-items:center;font-size:12px}
.race-row .name{font:600 12px ui-monospace,monospace;text-align:right;overflow:hidden;text-overflow:ellipsis}
.race-row .track{height:9px;border-radius:6px;background:var(--grid);overflow:hidden}
.race-row .bar{height:100%;border-radius:6px;background:var(--muted);transition:width 55ms linear}
.race-row.target .bar{background:var(--s1)} .race-row.competitor .bar{background:var(--s2)}
.race-row .value{font-variant-numeric:tabular-nums;color:var(--ink-2)}
/* status: never colour alone (a mark and a word) */
.band-good{background:var(--good-bg);color:var(--good-ink)} .band-warn{background:var(--warn-bg);color:var(--warn-ink)} .band-crit{background:var(--crit-bg);color:var(--crit-ink)}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-inline-end:4px;vertical-align:middle}
.dot.good{background:var(--good)} .dot.warn{background:var(--warn)} .dot.crit{background:var(--crit)}
/* panel 4: the sentence */
.sentence{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start}
.word{position:relative;border-radius:12px;padding:8px 10px;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:64px;border:2px solid transparent}
.word.playable{cursor:pointer;padding-inline-start:28px;transition:transform .12s,box-shadow .12s}
.word.playable::before{content:"โ–ถ";position:absolute;inset-inline-start:9px;top:10px;font-size:11px;color:var(--ink-2)}
.word.playable:hover,.word.playable:focus-visible{outline:2px solid var(--s1);outline-offset:2px;transform:translateY(-1px)}
.word.playing{box-shadow:0 0 0 3px var(--wash1);border-color:var(--s1)}
.word.excluded{opacity:.58;background:var(--raise);text-decoration:line-through}
.word-exclude{font-size:11px;padding:3px 7px;border-style:dashed}
.word.excluded .word-exclude{text-decoration:none;opacity:1}
.word.hl{border-color:var(--ink-2)}
.word .tr{font-size:20px;font-weight:700;line-height:1.3}
.word .ar{font-size:17px;color:var(--ink-2);line-height:1.2}
.word .ws{font-size:12px;font-weight:600}
.phones{display:flex;gap:3px;direction:rtl}
.ph{font:600 13px var(--font);min-width:24px;padding:3px 5px;border-radius:6px;background:var(--surface);border:1px solid var(--ring);cursor:pointer;text-align:center;color:var(--ink)}
.ph.good{box-shadow:inset 0 -3px 0 var(--good)} .ph.warn{box-shadow:inset 0 -3px 0 var(--warn)} .ph.crit{box-shadow:inset 0 -3px 0 var(--crit)}
.ph.flag::after{content:"!";color:var(--crit-ink);margin-inline-start:2px}
.ph.sel{outline:2px solid var(--s1);outline-offset:1px}
/* the native baselines under each sound: one row per voice, the one the score was measured against in bold */
.phcol{display:flex;flex-direction:column;align-items:stretch;gap:2px}
.phcol.lab{justify-content:flex-end}
.phcol.lab .ph{visibility:hidden}
.reflab{font:10.5px var(--font);color:var(--muted);line-height:16px;white-space:nowrap;text-align:start}
.refcell{font:10.5px var(--font);font-variant-numeric:tabular-nums;line-height:16px;text-align:center;color:var(--ink-2);background:var(--raise);border-radius:4px;padding:0 2px;cursor:default}
.refcell.good{box-shadow:inset 0 -2px 0 var(--good)} .refcell.warn{box-shadow:inset 0 -2px 0 var(--warn)} .refcell.crit{box-shadow:inset 0 -2px 0 var(--crit)}
.refcell.used{color:var(--ink);font-weight:700;outline:1px solid var(--ink-2)}
.refcell.none{color:var(--muted);background:none}
.refbar{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;font-size:12px;color:var(--ink-2)}
.refbar .chip{background:var(--raise);border-radius:8px;padding:3px 8px}
.refbar .chip b{color:var(--ink)}
.refbar label{display:inline-flex;align-items:center;gap:5px;cursor:pointer}
.tree{display:flex;flex-direction:column;gap:8px}
.tree .node{background:var(--raise);border-radius:10px;padding:6px 10px;font-size:13px}
.tree .node b{font-size:15px}
.tree .row{display:flex;flex-wrap:wrap;gap:8px}
.tree .wnode{background:var(--raise);border-radius:10px;padding:6px 10px;font-size:12px;cursor:default;border:2px solid transparent}
.tree .wnode.hl{border-color:var(--s1)}
.tree .wnode.excluded{opacity:.5;text-decoration:line-through}
.tree .formula{font-size:12px;color:var(--ink-2)}
/* panel 3 */
.paths{display:flex;flex-direction:column;gap:6px}
.path{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px;align-items:center}
.path .lp{font:12px ui-monospace,monospace;color:var(--ink-2);direction:ltr;text-align:left;min-width:64px}
.path .lp b{color:var(--ink)}
.chain{display:flex;flex-wrap:wrap;gap:2px;direction:ltr;align-items:center}
.node{font:12px ui-monospace,monospace;padding:2px 5px;border-radius:5px;border:1px solid var(--axis);color:var(--ink-2)}
.path.win .node{background:var(--s1);border-color:var(--s1);color:#fff;cursor:pointer}
.path.win .node.sel{outline:2px solid var(--ink);outline-offset:1px}
.node.diff{border-color:var(--s2);color:var(--ink);font-weight:700}
.chain .sep{width:6px;height:1px;background:var(--axis)}
.path.win .lp::after{content:" โœ“ ื ื‘ื—ืจ";color:var(--ink);font-family:var(--font)}
/* tooltip */
#tip{position:fixed;z-index:10;pointer-events:none;max-width:320px;background:var(--surface);color:var(--ink);border:1px solid var(--ring);border-radius:10px;padding:8px 10px;font-size:12.5px;line-height:1.45;box-shadow:0 6px 20px rgba(0,0,0,.18);display:none}
#tip .t{font-weight:700;font-size:13px;margin-bottom:2px}
#tip .row{display:flex;justify-content:space-between;gap:12px}
#tip .row span:first-child{color:var(--ink-2)}
#tip .tiptext{margin-top:4px;padding-top:4px;border-top:1px solid var(--grid);color:var(--ink-2)}
.empty{padding:30px;text-align:center;color:var(--ink-2)}
table.data{border-collapse:collapse;font-size:12px;width:100%}
table.data th,table.data td{padding:3px 6px;border-bottom:1px solid var(--grid);text-align:right;white-space:nowrap}
details summary{cursor:pointer;color:var(--ink-2);font-size:12px}
.scroll{overflow-x:auto}
.chat-payload{width:100%;min-height:320px;resize:vertical;padding:10px;border:1px solid var(--axis);border-radius:9px;background:var(--page);color:var(--ink);font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;direction:ltr;text-align:left;white-space:pre}
.copy-row{display:flex;align-items:center;justify-content:flex-end;gap:10px}
.copy-status{color:var(--ink-2);font-size:13px}
</style>
<script src="/static/shadda.js?v=1"></script>
</head>
<body>
<main>
<header>
<h1>ืœื•ื— ืื‘ื—ื•ืŸ ืฆื™ื•ืŸ</h1>
<span class="sub" id="what"></span>
<span class="spacer"></span>
<button id="prev" title="ื”ื”ื’ื” ื”ืงื•ื“ื (โ†)">โ†’</button><span class="sub" id="pos"></span><button id="next" title="ื”ื”ื’ื” ื”ื‘ื (โ†’)">โ†</button>
<button id="mock">ื“ื•ื’ืžื”</button>
<label class="file">ื˜ืขื™ื ืช JSON<input type="file" id="file" accept="application/json,.json"></label>
</header>
<section class="panel" id="p4" aria-labelledby="p4h">
<div class="head"><h2 id="p4h">ื”ืžืฉืคื˜, ืžื™ืœื” ืื—ืจ ืžื™ืœื”</h2><span class="sub" id="wordHint">โ–ถ ืœื—ืฆื• ืขืœ ืžื™ืœื” ื›ื“ื™ ืœืฉืžื•ืข ืื•ืชื” ยท ืžืขื‘ืจ ืขื ื”ืขื›ื‘ืจ ืขืœ ื”ื’ื” ืžืฆื™ื’ ืื•ืชื• ื‘ื›ืœ ื”ืœื•ื—ื•ืช; ืœื—ื™ืฆื” ืžืงื‘ืขืช</span></div>
<div class="refbar" id="refbar"></div>
<div class="sentence" id="sentence"></div>
<div class="legend"><span><span class="dot good"></span>75 ื•ืžืขืœื”</span><span><span class="dot warn"></span>50โ€“74</span><span><span class="dot crit"></span>ืžืชื—ืช ืœึพ50</span><span>! ืกื•ืžืŸ ื›ื˜ืขื•ืช</span></div>
<details><summary>ืขืฅ ื”ืฆื‘ื™ืจื”: ื”ื’ืื™ื โ† ืžื™ืœื™ื โ† ืžืฉืคื˜</summary><div class="tree" id="tree"></div></details>
</section>
<div class="grid2">
<section class="panel" id="p1" aria-labelledby="p1h">
<div class="head"><h2 id="p1h">ืคืจื™ื™ืžื™ื ื•ื—ืœื•ืŸ</h2><span class="sub" id="p1sub"></span><span class="frame-tools"><button id="framePlay" disabled>โ–ถ ื ื’ืŸ ืคืจื™ื™ืžื™ื</button><span class="sub ltr" id="frameNow"></span></span></div>
<div id="overview" dir="ltr"></div>
<div id="frames" dir="ltr"></div>
<div class="frame-race" id="frameRace" aria-live="polite"></div>
<div class="legend" id="p1legend"></div>
<div class="facts" id="p1facts"></div>
</section>
<section class="panel" id="p2" aria-labelledby="p2h">
<div class="head"><h2 id="p2h">ื”ืฆื™ื•ืŸ ืฉืœื‘ ืื—ืจ ืฉืœื‘</h2><span class="sub" id="p2sub"></span></div>
<div id="waterfall" dir="ltr"></div>
<div class="facts" id="p2facts"></div>
</section>
</div>
<section class="panel" id="p3" aria-labelledby="p3h">
<div class="head"><h2 id="p3h">ืงืจื™ืื•ืช ืžื•ืขืžื“ื•ืช (Viterbi) ื•ืžื“ื“ "ืžืฉืคื˜ ืื—ืจ"</h2><span class="sub" id="p3sub"></span></div>
<div class="grid2">
<div class="paths" id="paths"></div>
<div><div id="gauge" dir="ltr"></div><div class="facts" id="p3facts"></div></div>
</div>
</section>
<section class="panel">
<details><summary>ื˜ื‘ืœืช ื”ื’ืื™ื (ื›ืœ ื”ืžืกืคืจื™ื)</summary><div class="scroll"><table class="data" id="table"></table></div></details>
</section>
<section class="panel" aria-labelledby="chatPayloadTitle">
<div class="head"><h2 id="chatPayloadTitle">ืžื™ื“ืข ืœื”ืชื™ื™ืขืฆื•ืช ืขืœ ื”ึพpipeline</h2><span class="sub">ืื‘ื—ื•ืŸ ืžืœื ืœื”ืขืชืงื” ืœืฆืณืื˜, ืœืœื ื–ื”ื•ืช ื”ืžืฉืชืžืฉ ื•ืœืœื ืงื•ื‘ืฅ ืงื•ืœ</span></div>
<textarea class="chat-payload" id="chatPayload" readonly aria-label="ืžื™ื“ืข ืžืœื ืœื”ืขืชืงื” ืœืฆืณืื˜"></textarea>
<div class="copy-row"><span class="copy-status" id="copyStatus" role="status"></span><button id="copyPayload">ื”ืขืชืงื” ืœืฆืณืื˜</button></div>
</section>
</main>
<dialog id="excludeDlg" aria-labelledby="excludeTitle">
<form method="dialog" class="exclude-form">
<h2 id="excludeTitle">ืกื™ื‘ืช ื”ื”ื—ืจื’ื”</h2>
<label>ืžื“ื•ืข ื”ืžื™ืœื” ืื™ื ื” ืชืงื™ื ื”?
<select id="excludeReason"><option>ื—ื™ืชื•ืš ื”ืื•ื“ื™ื• ืคื’ื•ื</option><option>ื”ื˜ืงืกื˜ ืื™ื ื• ืชื•ืื ืœื”ืงืœื˜ื”</option><option>ืคื•ื ืžื•ืช ืฆืคื•ื™ื•ืช ืœื ื ื›ื•ื ื•ืช</option><option>ื—ืคื™ืคืช ืงื•ืœื•ืช</option><option>ื•ืจื™ืื ื˜ ืชืงื™ืŸ ืฉืœื ืชื•ืขื“</option><option>ืจื•ืขืฉ</option><option value="other">ืื—ืจ</option></select>
</label>
<input id="excludeOther" maxlength="200" placeholder="ืคื™ืจื•ื˜ ื”ืกื™ื‘ื”" hidden>
<div class="dialog-actions"><button value="cancel" formnovalidate>ื‘ื™ื˜ื•ืœ</button><button value="ok">ื”ื—ืจื’ื”</button></div>
</form>
</dialog>
<div id="tip" role="tooltip"></div>
<script>
"use strict";
const $=id=>document.getElementById(id);
const NS="http://www.w3.org/2000/svg";
function el(tag,attrs={},...kids){ const e=document.createElement(tag); for(const [k,v] of Object.entries(attrs)) if(v!=null) k==="class"?e.className=v:k==="text"?e.textContent=v:e.setAttribute(k,v); for(const k of kids) if(k!=null) e.append(k); return e; }
function sv(tag,attrs={},text){ const e=document.createElementNS(NS,tag); for(const [k,v] of Object.entries(attrs)) if(v!=null) e.setAttribute(k,v); if(text!=null) e.textContent=text; return e; }
const fmt=(x,d=1)=>x==null?"โ€”":String(Math.round(x*10**d)/10**d);
const pct=x=>x==null?"โ€”":x>0&&x<.001?"<0.1%":fmt(100*x,1)+"%";
const band=s=>s>=75?"good":s>=50?"warn":"crit";
const BAND_NAME={good:"ื˜ื•ื‘",warn:"ื›ืžืขื˜",crit:"ื—ืœืฉ"};
const STAGE_NAME={gop:"GOP ื’ื•ืœืžื™",exemption:"ืคื˜ื•ืจื™ื",insertion:"ืชืงืจืช ืฆืจื•ืจ",baseline:"ืงื• ื‘ืกื™ืก ื™ืœื™ื“ื™",calibration:"ืจืฆืคืช ื›ื™ื•ืœ",final:"ืกื•ืคื™"};
const EXEMPT_NAME={glottal_dropped:"ืขืฆื™ืจื” ืกื•ืชืžื™ืช ืฉื”ื•ืฉืžื˜ื”",onset_transient:"ืงืคื™ืฆื” ื‘ืคืจื™ื™ื ืื—ื“ ืœืคื ื™ ื”ืขื™ืฆื•ืจ ื”ืจืืฉื•ืŸ (ื›ืœืœ 31)",article_vowel_elided:"ืชื ื•ืขืช ุงู„ ืื—ืจื™ ืชื ื•ืขื”",near_vowel:"ืชื ื•ืขื” ืงืจื•ื‘ื”",final_b_devoicing:"b ืกื•ืคื™ืช ืœืคื ื™ ืขื™ืฆื•ืจ ืื˜ื•ื (ื›ืœืœ 28)",t_d_voicing:"t ื•ึพd ืœื ืžืชื—ืœืคื•ืช (ื›ืœืœ 32)",word_final_vowel_linking:"ืชื ื•ืขื” ืกื•ืคื™ืช ืœืคื ื™ ืชื ื•ืขื” (ื›ืœืœ 29)",unmarked_short_vowel:"ืชื ื•ืขื” ืงืฆืจื” ืฉืื™ื ื” ื ื‘ื“ืงืช (ื›ืœืœ 30)"};
const PHONE_HEBREW={
"ส”":"ื","b":"ื‘ึผ","t":"ืช","dส’":"ื’ืณ","ฤง":"ื—","x":"ื—ืณ","ฯ‡":"ื—ืณ","d":"ื“","r":"ืจ","ษพ":"ืจ","z":"ื–",
"s":"ืก","สƒ":"ืฉ","sหค":"ืฆ","dหค":"ื“ึฟ","tหค":"ื˜","รฐหค":"ื˜ืณ","ส•":"ืข","ษฃ":"ืขืณ","ส":"ืขืณ","f":"ืค",
"q":"ืง","k":"ื›ึผ","g":"ื’","ษก":"ื’","l":"ืœ","ษซ":"ืœ","m":"ืž","n":"ื ","h":"ื”","ษฆ":"ื”","w":"ื•","j":"ื™",
"tสƒ":"ืฆืณ","ฮธ":"ืชืณ","รฐ":"ื“ืด","p":"ืคึผ","v":"ื‘","ฮฒ":"ื‘","ส‹":"ื‘","ts":"ื˜ืก",
"a":"ืึท","i":"ืึด","u":"ืึป","e":"ืึถ","o":"ืื•ึน","aห":"ืึทื","iห":"ืึดื™","uห":"ืื•ึผ","eห":"ืึตื™","oห":"ืื•ึน"
};
const phoneHebrew=p=>PHONE_HEBREW[p]||p;
/* ---------- native baselines: one or more TTS voices scored by the same model ---------- */
let showRefs=true; try{ showRefs=localStorage.getItem("ihki.trace.refs")!=="0"; }catch{}
const refList=()=>(T&&T.references)||[];
const refName=k=>(refList()[k]||{}).voice||`ืงื•ืœ ${k+1}`;
const refTag=k=>{ const n=refName(k); return n.length<=5?n:n.slice(0,4)+"โ€ฆ"; };
/* each voice's score on sound t (null: that voice has no such sound); older traces carry only the lowest, as `ref` */
function refScores(t){
const b=t.stages.find(s=>s.stage==="baseline")||{};
if(Array.isArray(b.refs)) return b.refs;
return refList().length===1?[b.ref??null]:[];
}
/* the voice the sound was measured against: the lowest score, as Scorer._baseline takes it */
const REFUSED={protected:"ื–ื•ื’ ืžื•ื’ืŸ: ืงื•ืœ ื™ืœื™ื“ื™ ืœื ืžื•ื—ืœ ืขืœ ื”ื”ื—ืœืคื” ื”ื–ื•",kept:"ื”ืงื•ืœื•ืช ื”ื™ืœื™ื“ื™ื™ื ืฉืžืจื• ืขืœ ื”ื”ื’ื”: ืœื ื ืžื“ื“ ืžื•ืœื"};
const refHeard=t=>{ const b=t.stages.find(s=>s.stage==="baseline")||{}; return Array.isArray(b.heard)?b.heard:[]; };
function refUsed(t){
const b=t.stages.find(s=>s.stage==="baseline")||{}; if(b.refused) return -1; // compensation refused: measured against no voice
const xs=refScores(t); let best=-1;
xs.forEach((x,k)=>{ if(x!=null&&(best<0||x<xs[best])) best=k; });
return best>=0&&xs[best]<100?best:-1; // at 100 the baseline changes nothing: no voice was measured against
}
function drawRefBar(){
const box=$("refbar"), n=refList().length; box.replaceChildren();
if(!n){ box.append(el("span",{text:"ืงื• ื‘ืกื™ืก ื™ืœื™ื“ื™: ืื™ืŸ. ื”ืฆื™ื•ืŸ ืœื ื ืžื“ื“ ืžื•ืœ ืงื•ืœ ื™ืœื™ื“ (ืื™ืŸ ืžืคืชื— TTS, ื”ืงื•ืœ ืœื ื”ื™ื” ืžื•ื›ืŸ ื‘ื–ืžืŸ, ืื• ืžื‘ื—ืŸ TTS ืฉื‘ื• ื”ื‘ืกื™ืก ื›ื‘ื•ื™)."})); return; }
box.append(el("span",{text:n===1?"ืงื• ื‘ืกื™ืก ื™ืœื™ื“ื™:":`${n} ืงื•ื•ื™ ื‘ืกื™ืก ื™ืœื™ื“ื™ื™ื (ื”ื”ื’ื” ื ืžื“ื“ ืžื•ืœ ื”ื ืžื•ืš ืฉื‘ื”ื):`}));
refList().forEach((r,k)=>box.append(el("span",{class:"chip"},el("b",{text:refName(k)}),` ยท ืžืžื•ืฆืข ${fmt(r.mean,0)} ยท ${r.sounds} ื”ื’ืื™ื`)));
const cb=el("input",{type:"checkbox"}); cb.checked=showRefs;
cb.onchange=()=>{ showRefs=cb.checked; try{ localStorage.setItem("ihki.trace.refs",showRefs?"1":"0"); }catch{} drawSentence(); drawSelected(); };
box.append(el("label",{},cb,"ื”ืฆื’ื” ืžืชื—ืช ืœื›ืœ ื”ื’ื”"));
}
function refTip(i){
const t=T.tokens[i], xs=refScores(t), used=refUsed(t);
return [el("div",{class:"t",text:`ืงื• ื‘ืกื™ืก ื™ืœื™ื“ื™ ยท ${t.ipa}`}),
...xs.map((x,k)=>trow(refName(k),x==null?"ืื™ืŸ ื”ื’ื” ื›ื–ื” ื‘ืงืจื™ืื” ืฉืœื•":`${fmt(x,0)}`+(refHeard(t)[k]?` ยท ื ืฉืžืข ${refHeard(t)[k]}`:"")+(k===used?" ยท ื ืžื“ื“ ืžื•ืœื•":""))),
...(()=>{ const b=t.stages.find(s=>s.stage==="baseline")||{}; return b.refused?[trow("ืงื• ื”ื‘ืกื™ืก",REFUSED[b.refused]||b.refused)]:[]; })(),
trow("ื”ืœื•ืžื“: ืœืคื ื™ โ†’ ืื—ืจื™",(()=>{ const st=Object.fromEntries(t.stages.map(s=>[s.stage,s.score])); return `${fmt(st.length??st.insertion)} โ†’ ${fmt(st.baseline)}`; })())];
}
let R=null, T=null, PH=[], sel=0, pinned=false, traceSource="";
let wordAudio=null, wordAudioEnd=0, wordAudioNode=null;
let frameAudio=null, frameRaf=0, frameAudioTimer=0, frameCursor=null, frameX=null, frameCurve=null;
const FRAME_ANIMATION_MS=10000;
let wordAudioUrl=new URLSearchParams(location.search).get("src")==="app"?(localStorage.getItem("ihki.traceAudio")||""):"";
let traceContext={}; try{ traceContext=JSON.parse(localStorage.getItem("ihki.traceContext")||"{}"); }catch{}
let excludedWords={...(traceContext.excluded_words||{})};
function diagnosticChatText(result){
const t=result.trace||{}, context={...(result.phrase||{}),...(result._diagnostic||{})};
const trace={...t,
tokens:(t.tokens||[]).map(({curves,...token})=>token),
frames:t.frames?{count:t.frames.count,ms:t.frames.ms}:null
};
const publicResult={...result,trace};
delete publicResult.email; delete publicResult.audio; delete publicResult.phoneme_heatmap; delete publicResult._diagnostic;
return [
"Please analyze this Palestinian-Arabic pronunciation-scoring diagnostic.",
"Identify the most likely failure point and distinguish between acoustic-model, segmentation/alignment, pronunciation-rule, threshold/calibration, and feedback-generation problems.",
"Propose prioritized, concrete pipeline changes and regression tests. Do not infer the speaker's identity.",
"",
JSON.stringify({context,result:publicResult},null,2)
].join("\n");
}
/* ---------- loading ---------- */
function load(result,source){
if(!result||!result.trace||!result.trace.tokens){ show("ื‘ืงื•ื‘ืฅ ืื™ืŸ trace. ืคืชื—ื• ืืช ื”ืœื•ื— ืžื”ืžืขื‘ื“ื” ืื• ืžื ื™ืชื•ื— ื”ืคื•ื ืžื•ืช ืฉืœ ืžื•ืจื”, ืื• ื˜ืขื ื• ืืช ื”ื“ื•ื’ืžื”."); return; }
if(source!=="ืžื”ืืคืœื™ืงืฆื™ื”"){ traceContext={}; excludedWords={}; wordAudioUrl=""; }
R=result; T=result.trace; PH=(R.words||[]).flatMap(w=>w.phonemes||[]); traceSource=source;
$("chatPayload").value=diagnosticChatText(result); $("copyStatus").textContent="";
$("framePlay").disabled=!wordAudioUrl;
$("framePlay").title=wordAudioUrl?"ื ื’ื™ื ืช ื—ืœื•ืŸ ื”ืคืจื™ื™ืžื™ื ืขื ืกืžืŸ ืžืกื•ื ื›ืจืŸ":"ืื™ืŸ ืื•ื“ื™ื• ืžืฆื•ืจืฃ ืœึพtrace";
const worst=T.tokens.reduce((b,t)=>t.final<b.final?t:b,T.tokens[0]), asked=+new URLSearchParams(location.search).get("i");
sel=asked>=0&&asked<T.tokens.length&&new URLSearchParams(location.search).has("i")?asked:worst?worst.i:0; pinned=new URLSearchParams(location.search).has("i");
$("what").textContent=`${source} ยท ืฆื™ื•ืŸ ${R.score}`+(R.phrase?` ยท ${R.phrase.he}`:"");
drawAll();
}
function show(msg){ for(const id of ["sentence","overview","frames","waterfall","paths","gauge"]) $(id).replaceChildren(); $("sentence").append(el("div",{class:"empty",text:msg})); }
async function loadMock(){
try{ const res=await fetch("/static/assets/trace-mock.json",{cache:"no-store"}); if(!res.ok) throw new Error(res.status); load(await res.json(),"ื“ื•ื’ืžื” (scripts/mock_trace.py)"); }
catch(e){ show(`ื”ื“ื•ื’ืžื” ืœื ื ื˜ืขื ื” (${e.message}). ื”ืจื™ืฆื• python scripts/mock_trace.py`); }
}
$("mock").onclick=loadMock;
$("copyPayload").onclick=async()=>{
const field=$("chatPayload"), status=$("copyStatus");
try{ await navigator.clipboard.writeText(field.value); status.textContent="ื”ื•ืขืชืง."; }
catch{ field.focus(); field.select(); status.textContent="ื”ื˜ืงืกื˜ ืกื•ืžืŸ; ื”ืขืชื™ืงื• ืขื Ctrl/Cmd+C."; }
};
$("file").onchange=async e=>{ const f=e.target.files[0]; if(!f) return; try{ load(JSON.parse(await f.text()),f.name); }catch(err){ show(`ืœื JSON ืชืงื™ืŸ: ${err.message}`); } };
addEventListener("keydown",e=>{ if(!T||e.target.tagName==="INPUT") return; if(e.key==="ArrowLeft"){ step(1); e.preventDefault(); } if(e.key==="ArrowRight"){ step(-1); e.preventDefault(); } });
$("next").onclick=()=>step(1); $("prev").onclick=()=>step(-1);
function step(d){ if(!T) return; sel=(sel+d+T.tokens.length)%T.tokens.length; pinned=true; drawSelected(); }
/* ---------- selection: one sound everywhere ---------- */
function hover(i){ if(pinned||i===sel) return; sel=i; drawSelected(); }
function pick(i){ pinned=!(pinned&&sel===i); sel=i; drawSelected(); }
function drawAll(){ drawRefBar(); drawSentence(); drawTree(); drawPaths(); drawGauge(); drawTable(); drawSelected(); }
function drawSelected(){
$("pos").textContent=`${sel+1}/${T.tokens.length}`+(pinned?" ๐Ÿ“Œ":"");
for(const b of document.querySelectorAll(".ph")) b.classList.toggle("sel",+b.dataset.i===sel);
for(const b of document.querySelectorAll(".path.win .node")) b.classList.toggle("sel",+b.dataset.i===sel);
const w=T.tokens[sel].word;
for(const b of document.querySelectorAll(".word,.wnode")) b.classList.toggle("hl",+b.dataset.w===w);
drawOverview(); drawFrames(); drawWaterfall();
}
/* ---------- tooltip ---------- */
const tip=$("tip");
function tipShow(e,html){ tip.replaceChildren(...html); tip.style.display="block"; tipMove(e); }
function tipMove(e){ const r=tip.getBoundingClientRect(), x=Math.min(innerWidth-r.width-8,Math.max(8,e.clientX+14)), y=e.clientY+r.height+20>innerHeight?e.clientY-r.height-12:e.clientY+16; tip.style.left=x+"px"; tip.style.top=y+"px"; }
function tipHide(){ tip.style.display="none"; }
const trow=(k,v)=>el("div",{class:"row"},el("span",{text:k}),typeof v==="string"?el("span",{text:v}):v);
function soundTip(i){
const t=T.tokens[i], p=PH[i]||{}, w=(R.words||[])[t.word]||{};
const heard=t.deleted?"ืœื ื ืฉืžืข":t.competitor?`${t.competitor} (${pct(t.p_competitor)})`:"โ€”";
const out=[el("div",{class:"t"},el("span",{class:"ipa",text:t.sound!==t.ipa?`${t.ipa} ยท ${t.sound}`:t.ipa})," ื‘ืžื™ืœื” ",el("span",{class:"ar",text:w.ar||""}))];
out.push(trow("ืฆืคื•ื™",el("span",{class:"ipa",text:`${t.ipa} (${pct(t.p_target)})`})));
out.push(trow("ื”ืžืชื—ืจื” ื”ื—ื–ืง",el("span",{class:"ipa",text:heard})));
out.push(trow("ืฆื™ื•ืŸ",`${fmt(t.final)} ยท ${BAND_NAME[band(t.final)]}`+(t.flagged?" ยท ืกื•ืžืŸ ื›ื˜ืขื•ืช":"")));
const rx=refScores(t), ru=refUsed(t);
if(rx.length) out.push(trow("ืงื• ื‘ืกื™ืก",rx.map((x,k)=>`${refName(k)} ${x==null?"โ€”":fmt(x,0)}${k===ru&&rx.length>1?"*":""}`).join(" ยท ")));
out.push(trow("ืžืจื•ื•ื— (ื ื“ืจืฉ / ื‘ืคื•ืขืœ)",el("span",{class:"ltr",text:`${fmt(t.margin.needed,2)} / ${fmt(t.margin.got,2)}`+(t.margin.beyond?" โœ“ ืขื‘ืจ":" โœ— ืœื ืขื‘ืจ")})));
out.push(trow("ื—ืœื•ืŸ",el("span",{class:"ltr",text:`${t.window[0]}โ€“${t.window[1]}`})));
if(t.rule) out.push(trow("ื›ืœืœ",`${t.rule.title} ยท ื—ื•ืžืจื” ${t.rule.severity}`));
const tp=(R.tips||[]).find(x=>x.word===w.ar&&(x.key.startsWith(t.sound+">")||x.key.startsWith(t.ipa+">")));
const letter=(w.letters||[]).find(l=>l.tip||l.why);
const text=tp?tp.text:t.flagged&&letter?(letter.tip||letter.why):"";
if(text) out.push(el("div",{class:"tiptext",text}));
return out;
}
/* ---------- panel 4: the sentence, and the aggregation tree ---------- */
function drawSentence(){
const box=$("sentence"); box.replaceChildren();
(R.words||[]).forEach((w,wi)=>{
const excluded=!!excludedWords[wi], b=band(w.score), word=el("div",{class:`word band-${b}${wordAudioUrl?" playable":""}${excluded?" excluded":""}`,"data-w":wi});
if(wordAudioUrl){
word.setAttribute("role","button"); word.tabIndex=0; word.title="ื”ืฉืžืขืช ื”ืžื™ืœื”";
word.onclick=()=>playWord(wi,word);
word.onkeydown=e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); playWord(wi,word); } };
}
word.append(el("span",{class:"tr",text:w.tr}),el("span",{class:"ar",text:w.ar}),el("span",{class:"ws",text:`${fmt(w.score,0)} ยท ${BAND_NAME[b]}`}));
const phones=el("div",{class:"phones"}), nref=showRefs?refList().length:0;
if(nref){ const lab=el("div",{class:"phcol lab"},el("span",{class:"ph",text:"ยท"}));
for(let k=0;k<nref;k++) lab.append(el("span",{class:"reflab",title:refName(k),text:refTag(k)}));
phones.append(lab); }
T.tokens.forEach(t=>{ if(t.word!==wi) return;
const c=el("button",{class:`ph ${band(t.final)}${t.flagged?" flag":""}`,"data-i":t.i,
"aria-label":`${phoneHebrew(t.ipa)}, IPA ${t.ipa}, ืฆื™ื•ืŸ ${fmt(t.final,0)}`,title:`IPA: ${t.ipa}`,text:phoneHebrew(t.ipa)});
c.onmouseenter=e=>{ hover(t.i); tipShow(e,soundTip(t.i)); }; c.onmousemove=tipMove; c.onmouseleave=tipHide;
c.onfocus=()=>hover(t.i); c.onclick=e=>{ e.stopPropagation(); pick(t.i); };
if(!nref){ phones.append(c); return; }
const col=el("div",{class:"phcol"},c), xs=refScores(t), used=refUsed(t);
for(let k=0;k<nref;k++){ const x=xs[k];
const cell=el("span",{class:`refcell ${x==null?"none":band(x)}${k===used?" used":""}`,text:x==null?"โ€”":fmt(x,0),
"aria-label":`${refName(k)}: ${x==null?"ืื™ืŸ":fmt(x,0)}`});
cell.onmouseenter=e=>{ hover(t.i); tipShow(e,refTip(t.i)); }; cell.onmousemove=tipMove; cell.onmouseleave=tipHide;
cell.onclick=e=>{ e.stopPropagation(); pick(t.i); };
col.append(cell); }
phones.append(col); });
word.append(phones);
if(traceContext.sample!=null){
const toggle=el("button",{class:"word-exclude",text:excluded?"ื”ื—ื–ืจื” ืœืฆื™ื•ืŸ":"ืœื ืชืงื™ื ื” ยท ื”ื—ืจื’ื”"});
toggle.onclick=e=>{ e.stopPropagation(); toggleWord(wi,w,toggle); }; word.append(toggle);
}
box.append(word);
});
const valid=validSentence();
$("what").textContent=`${traceSource} ยท ืฆื™ื•ืŸ ${valid.final??"โ€”"}`+(valid.excluded?` (ืžืงื•ืจื™ ${R.score})`:"")+(R.phrase?` ยท ${R.phrase.he}`:"");
$("p4h").textContent=`ื”ืžืฉืคื˜, ืžื™ืœื” ืื—ืจ ืžื™ืœื” ยท ืฆื™ื•ืŸ ${valid.final??"โ€”"}`+(valid.excluded?` ยท ืžืงื•ืจื™ ${R.score} ยท ${valid.excluded} ื”ื•ื—ืจื’ื•`:"")+(R.unrelated?" ยท ืžืฉืคื˜ ืื—ืจ?":"");
$("wordHint").textContent=(wordAudioUrl?"โ–ถ ืœื—ืฆื• ืขืœ ืžื™ืœื” ื›ื“ื™ ืœืฉืžื•ืข ืื•ืชื” ยท ":"")+(traceContext.sample!=null?"โ€žืœื ืชืงื™ื ื”โ€ ืžื—ืจื™ื’ ืืช ื”ืžื™ืœื” ืžื”ืฆื™ื•ืŸ ื”ื›ื•ืœืœ ยท ":"")+"ืžืขื‘ืจ ืขื ื”ืขื›ื‘ืจ ืขืœ ื”ื’ื” ืžืฆื™ื’ ืื•ืชื• ื‘ื›ืœ ื”ืœื•ื—ื•ืช; ืœื—ื™ืฆื” ืžืงื‘ืขืช";
}
function validSentence(){
const keep=(R.words||[]).map((_,i)=>i).filter(i=>!excludedWords[i]), tokens=T.tokens.filter(t=>keep.includes(t.word));
if(!keep.length||!tokens.length) return {final:null,excluded:(R.words||[]).length};
const weight=t=>t.vowel?T.config.vowel_weight:1, total=tokens.reduce((n,t)=>n+weight(t),0);
const phoneMean=tokens.reduce((n,t)=>n+t.final*weight(t),0)/total;
const wordMean=keep.reduce((n,i)=>n+T.words[i].score,0)/keep.length, before=T.sentence.phone_weight*phoneMean+T.sentence.word_weight*wordMean;
return {phoneMean,wordMean,before,final:Math.round(R.unrelated?Math.min(before,T.config.unrelated_cap):before),excluded:(R.words||[]).length-keep.length};
}
async function toggleWord(wi,w,button){
const excluding=!excludedWords[wi]; let reason="";
if(excluding){ reason=await chooseExclusionReason(); if(reason===null) return; }
button.disabled=true;
try{
const res=await fetch(`/api/lab/samples/${traceContext.sample}/exclude-word`,{method:"POST",headers:{"Content-Type":"application/json",...(traceContext.token?{Authorization:`Bearer ${traceContext.token}`}:{})},body:JSON.stringify({word:wi,excluded:excluding,reason})});
const d=await res.json().catch(()=>({})); if(!res.ok) throw new Error(d.detail||res.status);
excludedWords=d.excluded_words||{}; traceContext.excluded_words=excludedWords; localStorage.setItem("ihki.traceContext",JSON.stringify(traceContext));
drawSentence(); drawTree();
}catch(e){ button.disabled=false; $("wordHint").textContent=`ืฉืžื™ืจืช ื”ื”ื—ืจื’ื” ื ื›ืฉืœื”: ${e.message}`; }
}
function chooseExclusionReason(){
const dlg=$("excludeDlg"), select=$("excludeReason"), other=$("excludeOther");
dlg.returnValue=""; select.value=select.options[0].value; other.value=""; other.hidden=true; other.required=false;
select.onchange=()=>{ other.hidden=select.value!=="other"; other.required=!other.hidden; if(!other.hidden) other.focus(); };
dlg.showModal();
return new Promise(resolve=>{ dlg.onclose=()=>{ if(dlg.returnValue!=="ok") return resolve(null); resolve(select.value==="other"?(other.value.trim()||"ืื—ืจ"):select.value); }; });
}
function playWord(wi,node){
const tokens=T.tokens.filter(t=>t.word===wi); if(!wordAudioUrl||!tokens.length) return;
stopFramePlayback(true);
if(wordAudio){ wordAudio.pause(); if(wordAudioNode) wordAudioNode.classList.remove("playing"); }
const ms=T.frames.ms||20, offset=(T.audio&&T.audio.trim_start_ms||0)/1000,
first=Math.min(...tokens.map(t=>t.span[0])), last=Math.max(...tokens.map(t=>t.span[1])),
previous=T.tokens.filter(t=>t.word<wi).map(t=>t.span[1]), following=T.tokens.filter(t=>t.word>wi).map(t=>t.span[0]),
leftLimit=previous.length?Math.max(...previous):0, rightLimit=following.length?Math.min(...following):T.frames.count,
start=Math.max(0,offset+Math.max(leftLimit,first-150/ms)*ms/1000),
end=Math.max(start+.08,offset+Math.min(rightLimit,last+150/ms)*ms/1000);
const audio=wordAudio=new Audio(wordAudioUrl); wordAudioEnd=end; wordAudioNode=node; node.classList.add("playing");
let stopTimer=null;
const finish=()=>{ if(stopTimer) clearTimeout(stopTimer); audio.pause(); audio.currentTime=start; node.classList.remove("playing"); };
audio.ontimeupdate=()=>{ if(audio.currentTime>=wordAudioEnd) finish(); };
audio.onended=finish;
audio.onerror=()=>{ finish(); $("wordHint").textContent="ืœื ื ื™ืชืŸ ืœื”ืฉืžื™ืข ืืช ื”ื”ืงืœื˜ื”"; };
const startPlaying=()=>{ audio.currentTime=start; audio.play().then(()=>{ stopTimer=setTimeout(finish,Math.max(80,(end-start)*1000)); }).catch(()=>{ finish(); $("wordHint").textContent="ื”ื“ืคื“ืคืŸ ื—ืกื ืืช ื”ื”ืฉืžืขื”"; }); };
if(audio.readyState>=1) startPlaying(); else audio.onloadedmetadata=startPlaying;
}
function drawTree(){
const box=$("tree"), s=T.sentence, c=T.config, valid=validSentence(); box.replaceChildren();
box.append(el("div",{class:"node"},el("b",{text:`ืžืฉืคื˜ ${valid.final??"โ€”"}`+(valid.excluded?` (${valid.excluded} ืžื™ืœื™ื ื”ื•ื—ืจื’ื•; ืžืงื•ืจื™ ${s.final})`:"")}),el("div",{class:"formula ltr",
text:valid.final==null?"ืื™ืŸ ืžื™ืœื™ื ืชืงื™ื ื•ืช ืœื—ื™ืฉื•ื‘":`${s.phone_weight}ยทphone_mean(${fmt(valid.phoneMean)}) + ${s.word_weight}ยทword_mean(${fmt(valid.wordMean)}) = ${fmt(valid.before)}`+(s.capped?` โ†’ capped at ${c.unrelated_cap}`:"")}),
el("div",{class:"formula",text:`ืžืžื•ืฆืข ื”ื”ื’ืื™ื ืžืฉื•ืงืœืœ: ืชื ื•ืขื” ื ืกืคืจืช ${c.vowel_weight}, ืขื™ืฆื•ืจ 1`})));
const row=el("div",{class:"row"});
T.words.forEach((w,wi)=>{
const n=el("div",{class:`wnode${excludedWords[wi]?" excluded":""}`,"data-w":wi});
n.append(el("div",{},el("b",{text:w.tr})," ",el("span",{class:"dot "+band(w.score)}),el("span",{text:fmt(w.score)})),
el("div",{class:"formula ltr",text:`${c.word_min_weight}ยทmin(${fmt(w.min)}) + ${fmt(1-c.word_min_weight)}ยทmean(${fmt(w.mean)})`}),
el("div",{class:"formula ltr",text:w.scores.map(x=>fmt(x,0)).join(" ยท ")}));
n.onmouseenter=()=>{ const first=T.tokens.find(t=>t.word===wi); if(first) hover(first.i); };
row.append(n);
});
box.append(row);
}
/* ---------- panel 1: the time strip and the frames around the sound ---------- */
function width(id){ return Math.max(280,$(id).clientWidth||600); }
function drawOverview(){
const box=$("overview"), W=width("overview"), H=46, n=T.frames.count, x=f=>f/n*W;
const svg=sv("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":"ืฆื™ืจ ื”ื–ืžืŸ: ืื ืจื’ื™ื” ื•ื”ื’ืื™ื"});
const e=T.frames.energy;
if(e){ let d=`M0 ${H-14}`; e.forEach((v,f)=>{ d+=` L${x(f+.5)} ${H-14-v*28}`; }); d+=` L${W} ${H-14} Z`; svg.append(sv("path",{d,fill:"var(--grid)"})); }
T.tokens.forEach(t=>{
const g=sv("g",{style:"cursor:pointer"}), a=x(t.span[0]), w=Math.max(2,x(t.span[1])-a);
const col={good:"var(--good)",warn:"var(--warn)",crit:"var(--crit)"}[band(t.final)];
g.append(sv("rect",{x:a,y:H-12,width:w,height:10,rx:2,fill:col,opacity:t.i===sel?1:.55}));
if(t.i===sel) g.append(sv("rect",{x:x(t.window[0]),y:2,width:x(t.window[1])-x(t.window[0]),height:H-4,fill:"none",stroke:"var(--s1)","stroke-width":1.5,rx:3}));
g.append(sv("rect",{x:a-2,y:0,width:w+4,height:H,fill:"transparent"}));
g.onmouseenter=ev=>{ hover(t.i); tipShow(ev,soundTip(t.i)); }; g.onmousemove=tipMove; g.onmouseleave=tipHide; g.onclick=()=>pick(t.i);
svg.append(g);
});
box.replaceChildren(svg);
}
function drawFrames(){
stopFramePlayback(true);
const t=T.tokens[sel], c=t.curves, L=c.target.length, ms=T.frames.ms;
const box=$("frames"), W=width("frames"), padL=34, padR=10, top=10, ph=170, eh=36, gap=14, H=top+ph+gap+eh+26;
const x=f=>padL+(f-c.start)/Math.max(1,L-1)*(W-padL-padR), yP=v=>top+ph-v*ph;
const svg=sv("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":`ืคืจื™ื™ืžื™ื ืกื‘ื™ื‘ ${t.ipa}`});
// the window the sound was read from: the end of the sound before to the start of the one after
const win=t.window;
svg.append(sv("rect",{x:x(win[0]-.5),y:top,width:x(win[1]-.5)-x(win[0]-.5),height:ph,fill:"var(--wash1)",stroke:"var(--s1)","stroke-width":1.5,rx:4}));
// the emission span on the axis
svg.append(sv("rect",{x:x(t.span[0]-.5),y:top+ph+1,width:x(t.span[1]-.5)-x(t.span[0]-.5),height:4,rx:2,fill:"var(--ink-2)"}));
// grid & axis
for(const v of [0,.5,1]){ svg.append(sv("line",{x1:padL,x2:W-padR,y1:yP(v),y2:yP(v),stroke:"var(--grid)"})); svg.append(sv("text",{x:padL-6,y:yP(v)+4,"text-anchor":"end"},v===0?"0":v===1?"1":".5")); }
svg.append(sv("line",{x1:padL,x2:W-padR,y1:yP(0),y2:yP(0),stroke:"var(--axis)"}));
const tickEvery=Math.max(1,Math.ceil(L/10));
for(let f=c.start;f<c.start+L;f+=tickEvery) svg.append(sv("text",{x:x(f),y:H-4,"text-anchor":"middle"},String(f)));
// lines: blank and neighbours recessive, the two that decide the score in colour
const line=(vals,stroke,w=2,extra={})=>{ if(!vals) return; let d=""; vals.forEach((v,k)=>{ d+=(k?" L":"M")+x(c.start+k)+" "+yP(v); }); svg.append(sv("path",{d,fill:"none",stroke,"stroke-width":w,"stroke-linejoin":"round","stroke-linecap":"round",...extra})); };
line(c.blank,"var(--grid)",1.5); line(c.prev,"var(--muted)",1.5,{opacity:.7}); line(c.next,"var(--muted)",1.5,{opacity:.7});
line(c.competitor,"var(--s2)"); line(c.target,"var(--s1)");
// the energy strip, its own 0..1 scale under the posteriors
const e=T.frames.energy, ey=top+ph+gap;
svg.append(sv("text",{x:padL-6,y:ey+eh/2+4,"text-anchor":"end"},"E"));
if(e){ let d=`M${x(c.start)} ${ey+eh}`; for(let k=0;k<L;k++) d+=` L${x(c.start+k)} ${ey+eh-(e[c.start+k]||0)*eh}`; d+=` L${x(c.start+L-1)} ${ey+eh} Z`;
svg.append(sv("path",{d,fill:"var(--grid)",stroke:"var(--muted)","stroke-width":1})); }
else svg.append(sv("text",{x:padL+6,y:ey+eh/2+4},"ืื™ืŸ ืื ืจื’ื™ื” (ื ื•ืงื“ ื‘ืœื™ ืฉืžืข)"));
svg.append(sv("line",{x1:padL,x2:W-padR,y1:ey+eh,y2:ey+eh,stroke:"var(--axis)"}));
// hover: a crosshair and every value of the frame
const cross=sv("line",{y1:top,y2:ey+eh,stroke:"var(--ink-2)","stroke-width":1,opacity:0});
svg.append(cross);
frameCursor=cross; frameX=x; frameCurve=c;
const hit=sv("rect",{x:padL,y:top,width:W-padL-padR,height:ey+eh-top,fill:"transparent"});
hit.onmousemove=ev=>{
const r=svg.getBoundingClientRect(), fx=(ev.clientX-r.left)/r.width*W, k=Math.max(0,Math.min(L-1,Math.round((fx-padL)/(W-padL-padR)*(L-1)))), f=c.start+k;
cross.setAttribute("x1",x(f)); cross.setAttribute("x2",x(f)); cross.setAttribute("opacity",.6);
renderFrameRace(k);
const inWin=f>=win[0]&&f<win[1];
tipShow(ev,[el("div",{class:"t",text:`ืคืจื™ื™ื ${f} ยท ${fmt(f*ms/1000,2)} s`+(inWin?" ยท ื‘ื—ืœื•ืŸ":"")}),
trow(`p(${t.ipa})`,pct(c.target[k])), c.competitor?trow(`p(${t.competitor||"ืžืชื—ืจื”"})`,pct(c.competitor[k])):null,
c.prev?trow("p(ื”ื”ื’ื” ื”ืงื•ื“ื)",pct(c.prev[k])):null, c.next?trow("p(ื”ื”ื’ื” ื”ื‘ื)",pct(c.next[k])):null,
trow("p(blank)",pct(c.blank[k])), e?trow("ืื ืจื’ื™ื”",fmt(e[f],2)):null].filter(Boolean));
};
hit.onmouseleave=()=>{ cross.setAttribute("opacity",0); tipHide(); };
svg.append(hit);
box.replaceChildren(svg);
renderFrameRace(0);
const nb=i=>i>=0&&i<T.tokens.length?T.tokens[i].ipa:"โ€”";
$("p1sub").replaceChildren(el("span",{class:"ipa",text:t.ipa}),` ยท ื—ืœื•ืŸ: ืคืจื™ื™ืžื™ื ${win[0]}โ€“${win[1]} (ืคืจื™ื™ื = ${fmt(ms,0)} ms)`);
$("p1legend").replaceChildren(...[
el("span",{},el("i",{style:"background:var(--s1)"}),"p(",el("span",{class:"ipa",text:t.ipa}),")"),
el("span",{},el("i",{style:"background:var(--s2)"}),"p(",el("span",{class:"ipa",text:t.competitor||"ืžืชื—ืจื”"}),")"),
el("span",{},el("i",{style:"background:var(--muted)"}),"ืฉื›ื ื™ื, ืžื•ืกืชืจื™ื ื›ืžืชื—ืจื™ื (",el("span",{class:"ipa",text:`${nb(sel-1)}, ${nb(sel+1)}`}),")"),
el("span",{},el("i",{style:"background:var(--grid)"}),"blank"),
el("span",{},el("i",{class:"box",style:"background:var(--wash1);outline:1.5px solid var(--s1)"}),"ื”ื—ืœื•ืŸ: ืžืกื•ืฃ ื”ื”ื’ื” ื”ืงื•ื“ื ืขื“ ืชื—ื™ืœืช ื”ื‘ื"),
el("span",{},el("i",{style:"background:var(--ink-2)"}),"ืžืชื™ ื ืคืœื˜ ื”ื”ื’ื” (span)")].filter(Boolean));
$("p1facts").replaceChildren(...[
fact("p_target",pct(t.p_target)), fact("p_comp",pct(t.p_competitor)),
fact("GOP",fmt(t.gop,3)), fact("ื ืžื—ืง?",t.deleted?"ื›ืŸ":"ืœื"), t.class?fact("ืžื™ืžื•ืฉื™ื",t.class.join(" / ")+(t.realised?` ยท ื ืืžืจ ${t.realised}`:"")):null].filter(Boolean));
}
function fact(k,v){ return el("span",{},`${k} `,el("b",{class:"ltr",text:v})); }
/* ---------- synchronized frame animation ---------- */
function frameCandidates(k){
const t=T.tokens[sel], c=t.curves, nb=i=>i>=0&&i<T.tokens.length?T.tokens[i].ipa:"โ€”", rows=[
{name:t.ipa,value:c.target&&c.target[k],kind:"target"},
{name:t.competitor||"ืžืชื—ืจื”",value:c.competitor&&c.competitor[k],kind:"competitor"},
{name:nb(sel-1),value:c.prev&&c.prev[k],kind:"neighbor"},
{name:nb(sel+1),value:c.next&&c.next[k],kind:"neighbor"},
{name:"blank",value:c.blank&&c.blank[k],kind:"blank"}
];
return rows.filter((r,i)=>r.value!=null&&!rows.slice(0,i).some(x=>x.name===r.name)).sort((a,b)=>b.value-a.value);
}
function renderFrameRace(k){
if(!T||!frameCurve) return;
const c=frameCurve, safe=Math.max(0,Math.min(c.target.length-1,k)), f=c.start+safe, t=T.tokens[sel], inSpan=f>=t.span[0]&&f<t.span[1], inWindow=f>=t.window[0]&&f<t.window[1];
$("frameNow").textContent=`frame ${f} ยท ${fmt(f*T.frames.ms/1000,2)}s ยท ${inSpan?"span":inWindow?"GOP window":"context"}`;
const box=$("frameRace"); box.replaceChildren(...frameCandidates(safe).map(r=>{
const row=el("div",{class:`race-row ${r.kind}`});
row.append(el("span",{class:"name",text:r.name}),el("span",{class:"track"},el("span",{class:"bar",style:`width:${Math.max(0,Math.min(100,r.value*100))}%`})),el("span",{class:"value",text:pct(r.value)}));
return row;
}));
if(frameCursor&&frameX){ frameCursor.setAttribute("x1",frameX(f)); frameCursor.setAttribute("x2",frameX(f)); frameCursor.setAttribute("opacity",.9); }
}
function stopFramePlayback(reset=false){
if(frameRaf) cancelAnimationFrame(frameRaf); frameRaf=0;
if(frameAudioTimer) clearTimeout(frameAudioTimer); frameAudioTimer=0;
if(frameAudio){ frameAudio.pause(); frameAudio=null; }
const b=$("framePlay"); if(b) b.textContent="โ–ถ ื ื’ืŸ ืคืจื™ื™ืžื™ื";
if(reset&&frameCursor) frameCursor.setAttribute("opacity",0);
}
async function playFrames(){
if(!T||!wordAudioUrl) return;
if(frameAudio){ stopFramePlayback(true); return; }
if(wordAudio){ wordAudio.pause(); if(wordAudioNode) wordAudioNode.classList.remove("playing"); wordAudio=null; }
const t=T.tokens[sel], c=t.curves, ms=T.frames.ms||20, offset=(T.audio&&T.audio.trim_start_ms||0)/1000,
start=offset+c.start*ms/1000, end=offset+(c.start+c.target.length)*ms/1000, audio=frameAudio=new Audio(wordAudioUrl), button=$("framePlay");
button.textContent="โ–  ืขืฆื™ืจื”";
let animationStart=0;
const tick=now=>{
if(frameAudio!==audio) return;
const progress=Math.max(0,Math.min(1,(now-animationStart)/FRAME_ANIMATION_MS)), k=Math.round(progress*(c.target.length-1));
renderFrameRace(k);
if(progress>=1){ stopFramePlayback(false); return; }
frameRaf=requestAnimationFrame(tick);
};
const begin=async()=>{ audio.currentTime=Math.max(0,start); try{
await audio.play();
// The visualization is deliberately stretched to ten seconds; speech stays natural and is heard once.
frameAudioTimer=setTimeout(()=>audio.pause(),Math.max(80,(end-start)*1000));
animationStart=performance.now(); frameRaf=requestAnimationFrame(tick);
}catch{ stopFramePlayback(true); $("frameNow").textContent="ื”ื“ืคื“ืคืŸ ื—ืกื ืืช ื”ื”ืฉืžืขื”"; } };
audio.onerror=()=>{ stopFramePlayback(true); $("frameNow").textContent="ืœื ื ื™ืชืŸ ืœื”ืฉืžื™ืข ืืช ื”ื”ืงืœื˜ื”"; };
if(audio.readyState>=1) begin(); else audio.onloadedmetadata=begin;
}
$("framePlay").onclick=playFrames;
/* ---------- panel 2: the waterfall ---------- */
function drawWaterfall(){
const t=T.tokens[sel], c=T.config, steps=[{name:"gop",from:0,to:t.stages[0].score,note:`100 ร— ${t.gop_factor} ร— ${t.confidence_factor}`}];
let cur=t.stages[0].score;
for(const s of t.stages.slice(1)){
let note="";
if(s.stage==="exemption"&&s.applied) note=EXEMPT_NAME[s.applied]||s.applied;
if(s.stage==="insertion"&&s.applied) note=`ืชื ื•ืขื” ${s.applied} ื‘ืฆืจื•ืจ`;
if(s.stage==="length"&&s.applied) note=`ืชื ื•ืขื” ืืจื•ื›ื” ืงืฆืจื”: ${fmt(s.applied.ratio,2)}ร— ืžืชื ื•ืขื” ืงืฆืจื” (ืกืฃ ${fmt(s.applied.min_ratio,2)})`;
if(s.stage==="baseline"){ const xs=refScores(t), u=refUsed(t);
note=s.refused?(REFUSED[s.refused]||s.refused)+(refHeard(t).some(Boolean)?` (ื ืฉืžืข ื‘ืงื•ืœื•ืช: ${refHeard(t).map(h=>h||"โ€”").join(" ยท ")})`:"")
:s.ref==null?(refList().length?"ืœืืฃ ืงื•ืœ ื™ืœื™ื“ื™ ืื™ืŸ ืืช ื”ื”ื’ื” ื”ื–ื”":"ืื™ืŸ ืงื•ืœ ื™ืœื™ื“ื™")
:xs.length>1?xs.map((x,k)=>`${refName(k)} ${x==null?"โ€”":fmt(x,0)}`).join(" ยท ")+(u>=0?` โ†’ ื ืžื“ื“ ืžื•ืœ ${refName(u)} (${fmt(s.ref,0)}): 100ยทs/${fmt(s.ref,0)}`:" (100: ืœื ืžืฉื ื”)")
:`${refName(0)}: ${fmt(s.ref,0)}`+(s.ref<100?` โ†’ 100ยทs/${fmt(s.ref,0)}`:" (100: ืœื ืžืฉื ื”)"); }
if(s.stage==="calibration") note=s.floor==null?"ืื™ืŸ ืจืฆืคื”":`ืจืฆืคื” ${fmt(s.floor,0)}`;
steps.push({name:s.stage,from:cur,to:s.score,note}); cur=s.score;
}
steps.push({name:"final",from:0,to:t.final,note:t.flagged?"ืกื•ืžืŸ ื›ื˜ืขื•ืช":t.final<c.issue_below?"ืœื ืžื“ื•ื™ืง":"ืชืงื™ืŸ"});
const box=$("waterfall"), W=width("waterfall"), padL=88, padR=40, rowH=30, top=8, H=top+steps.length*rowH+22;
const x=v=>padL+v/100*(W-padL-padR);
const svg=sv("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":"ืฉืœื‘ื™ ื”ืฆื™ื•ืŸ"});
for(const v of [0,50,100]){ svg.append(sv("line",{x1:x(v),x2:x(v),y1:top,y2:H-18,stroke:"var(--grid)"})); svg.append(sv("text",{x:x(v),y:H-4,"text-anchor":"middle"},String(v))); }
for(const [v,label] of [[c.flag_below,"flag"],[c.issue_below,"issue"]]){
svg.append(sv("line",{x1:x(v),x2:x(v),y1:top,y2:H-18,stroke:"var(--ink-2)","stroke-width":1,opacity:.5}));
svg.append(sv("text",{x:x(v)+3,y:top+8},label)); }
steps.forEach((s,k)=>{
const y=top+k*rowH, lo=Math.min(s.from,s.to), hi=Math.max(s.from,s.to), total=s.name==="gop"||s.name==="final";
const fill=total?"var(--ink-2)":s.to>s.from?"var(--s1)":s.to<s.from?"var(--s2)":"var(--axis)";
svg.append(sv("text",{x:padL-8,y:y+rowH/2+4,"text-anchor":"end",class:"lbl"},STAGE_NAME[s.name]));
const w=Math.max(hi>lo?2:1.5,x(hi)-x(lo));
svg.append(sv("rect",{x:x(lo),y:y+7,width:w,height:rowH-14,rx:4,fill}));
if(k&&!total) svg.append(sv("line",{x1:x(s.from),x2:x(s.from),y1:y-rowH+7,y2:y+7,stroke:"var(--axis)"}));
const d=s.to-s.from, label=total?fmt(s.to):d?`${d>0?"+":""}${fmt(d)} โ†’ ${fmt(s.to)}`:"ยฑ0";
svg.append(sv("text",{x:Math.min(W-2,x(hi)+5),y:y+rowH/2+4,class:total?"strong":"lbl"},label));
if(s.name==="baseline"){ const at=new Map(), u=refUsed(t); // voices with the same score share one marker
refScores(t).forEach((r,k)=>{ if(r!=null) at.set(r,[...(at.get(r)||[]),k]); });
for(const [r,ks] of at){ const cx=x(r), cy=y+rowH/2, d=5;
svg.append(sv("path",{d:`M${cx} ${cy-d}L${cx+d} ${cy}L${cx} ${cy+d}L${cx-d} ${cy}Z`,fill:ks.includes(u)?"var(--ink)":"var(--surface)",stroke:"var(--ink)","stroke-width":1.3}));
svg.append(sv("text",{x:cx,y:y+4,"text-anchor":"middle",style:"font-size:9.5px"},ks.map(refTag).join(" ยท "))); } }
const hit=sv("rect",{x:0,y,width:W,height:rowH,fill:"transparent"});
hit.onmouseenter=ev=>tipShow(ev,[el("div",{class:"t",text:STAGE_NAME[s.name]}),trow("ืœืคื ื™",fmt(s.from)),trow("ืื—ืจื™",fmt(s.to)),s.note?el("div",{class:"tiptext ltr",text:s.note}):null].filter(Boolean));
hit.onmousemove=tipMove; hit.onmouseleave=tipHide; svg.append(hit);
});
box.replaceChildren(svg);
$("p2sub").replaceChildren(el("span",{class:"ipa",text:t.ipa})," ยท ",el("span",{class:"dot "+band(t.final)}),`${fmt(t.final,0)} ยท ${BAND_NAME[band(t.final)]}`);
const rx=refScores(t), ru=refUsed(t);
const bst=t.stages.find(s=>s.stage==="baseline")||{};
$("p2facts").replaceChildren(...[...rx.map((x,k)=>fact(`ื‘ืกื™ืก ${refName(k)}`,(x==null?"โ€”":fmt(x,0))+(refHeard(t)[k]?` (ื ืฉืžืข ${refHeard(t)[k]})`:"")+(k===ru&&rx.length>1?" โ—†":""))),
...(bst.refused?[fact("ืงื• ื‘ืกื™ืก",bst.refused==="protected"?"ืœื ื”ื•ื—ืœ: ื–ื•ื’ ืžื•ื’ืŸ":"ืœื ื”ื•ื—ืœ: ื”ืงื•ืœ ืฉืžืจ ืขืœ ื”ื”ื’ื”")]:[]),fact("ืกื•ืžืŸ",t.flagged?"ื›ืŸ":"ืœื"),fact("ืžืจื•ื•ื— ื ื“ืจืฉ",fmt(t.margin.needed,2)),fact("ื‘ืคื•ืขืœ",fmt(t.margin.got,2)),
t.rule?fact("ื›ืœืœ",`${t.rule.title} (${t.rule.severity})`):null, fact("ื˜ื™ืค",t.tip?"ื›ืŸ":"ืœื")].filter(Boolean));
}
/* ---------- panel 3: the readings and the gauge ---------- */
function drawPaths(){
const box=$("paths"); box.replaceChildren();
const seen=new Map();
for(const v of T.variants){ const key=(v.tokens||[]).join(" ")||v.ipa, old=seen.get(key);
if(!old||(v.logp??-1e9)>(old.logp??-1e9)||v.chosen) seen.set(key,{...v,chosen:v.chosen||(old&&old.chosen)}); }
const list=[...seen.values()].sort((a,b)=>(b.chosen-a.chosen)||((b.logp??-1e9)-(a.logp??-1e9)));
const win=list.find(v=>v.chosen)||list[0], best=win&&win.logp;
list.slice(0,8).forEach(v=>{
const row=el("div",{class:"path"+(v===win?" win":"")}), lp=el("div",{class:"lp"});
lp.append(el("b",{text:v.logp==null?"โ€”":fmt(v.logp,1)}));
if(v!==win&&v.logp!=null&&best!=null) lp.append(el("div",{text:`ฮ” ${fmt(v.logp-best,1)}`}));
const chain=el("div",{class:"chain"});
(v.tokens||[]).forEach((tk,k)=>{
if(k) chain.append(el("span",{class:"sep"}));
const diff=v!==win&&win.tokens&&win.tokens[k]!==tk, n=el("span",{class:"node"+(diff?" diff":""),text:tk});
if(v===win){ n.dataset.i=k; n.onmouseenter=e=>{ hover(k); tipShow(e,soundTip(k)); }; n.onmousemove=tipMove; n.onmouseleave=tipHide; n.onclick=()=>pick(k); }
chain.append(n);
});
row.append(lp,el("div",{},chain,v===win||v.ar!==win.ar?el("div",{class:"sub ar",text:v.ar}):null));
box.append(row);
});
if(list.length>8) box.append(el("div",{class:"sub",text:`ื•ืขื•ื“ ${list.length-8} ืงืจื™ืื•ืช`}));
$("p3sub").textContent=`${T.variants.length} ืงืจื™ืื•ืช ื ื‘ื“ืงื•, ${seen.size} ืžืกืœื•ืœื™ื ืฉื•ื ื™ื ยท ื”ื›ืชื•ื: ื”ื’ื” ืฉื•ื ื” ืžื”ืžืกืœื•ืœ ืฉื ื‘ื—ืจ`;
}
function drawGauge(){
const u=T.unrelated, box=$("gauge"), W=260, H=150, cx=W/2, cy=128, r=100, max=1;
const ang=v=>Math.PI*(1-Math.min(max,Math.max(0,v))/max), pt=(v,rr)=>[cx+rr*Math.cos(ang(v)),cy-rr*Math.sin(ang(v))];
const arc=(a,b,rr,stroke)=>{ const [x1,y1]=pt(a,rr),[x2,y2]=pt(b,rr); return sv("path",{d:`M${x1} ${y1} A${rr} ${rr} 0 0 1 ${x2} ${y2}`,fill:"none",stroke,"stroke-width":14}); };
const svg=sv("svg",{viewBox:`0 0 ${W} ${H}`,role:"img","aria-label":`ื™ื—ืก ืžืจื—ืง ืขืจื™ื›ื” ${u.ratio}, ืกืฃ ${u.threshold}`,style:"max-width:340px;margin:auto"});
svg.append(arc(0,u.threshold,r,"var(--good-bg)"),arc(u.threshold,max,r,"var(--crit-bg)"));
const [tx1,ty1]=pt(u.threshold,r-12),[tx2,ty2]=pt(u.threshold,r+12);
svg.append(sv("line",{x1:tx1,y1:ty1,x2:tx2,y2:ty2,stroke:"var(--ink-2)","stroke-width":2}));
const [lx,ly]=pt(u.threshold,r+22); svg.append(sv("text",{x:lx,y:ly,"text-anchor":"middle"},String(u.threshold)));
const [nx,ny]=pt(u.ratio,r-4); svg.append(sv("line",{x1:cx,y1:cy,x2:nx,y2:ny,stroke:"var(--ink)","stroke-width":2.5,"stroke-linecap":"round"}));
svg.append(sv("circle",{cx,cy,r:5,fill:"var(--ink)",stroke:"var(--surface)","stroke-width":2}));
svg.append(sv("text",{x:cx,y:cy-28,"text-anchor":"middle",class:"strong",style:"font-size:22px"},fmt(u.ratio,2)));
svg.append(sv("text",{x:cx-r,y:cy+16,"text-anchor":"middle"},"0"),sv("text",{x:cx+r,y:cy+16,"text-anchor":"middle"},"1"));
box.replaceChildren(svg);
$("p3facts").replaceChildren(el("span",{},el("span",{class:"dot "+(u.unrelated?"crit":"good")}),el("b",{text:u.unrelated?"ืžืฉืคื˜ ืื—ืจ: ื”ืฆื™ื•ืŸ ืžื•ื’ื‘ืœ ืœึพ"+T.config.unrelated_cap:"ืื•ืชื• ืžืฉืคื˜"})),
fact("ืžืจื—ืง ืขืจื™ื›ื”",`${u.distance} / ${u.weight ?? u.tokens}${u.veto ? " ยท ื•ื˜ื• ื™ื™ืฉื•ืจ" : ""}`), el("span",{},"ื ืฉืžืข: ",el("b",{class:"ipa",text:u.heard})));
}
/* ---------- the table: every number ---------- */
function drawTable(){
const tb=$("table"); tb.replaceChildren();
const nref=refList().length;
const head=["#","ื”ื’ื”","ืžื™ืœื”","span","ื—ืœื•ืŸ","p_target","ืžืชื—ืจื”","p_comp","GOP","gop","ืคื˜ื•ืจ","ืฆืจื•ืจ","ืื•ืจืš",...Array.from({length:nref},(_,k)=>`ื‘ืกื™ืก ${refName(k)}`),"ื‘ืกื™ืก","ื›ื™ื•ืœ","ืกื•ืคื™","ืกื•ืžืŸ"];
tb.append(el("tr",{},...head.map(h=>el("th",{text:h}))));
for(const t of T.tokens){
const st=Object.fromEntries(t.stages.map(s=>[s.stage,s.score]));
const cells=[t.i,t.ipa,t.word,t.span.join("โ€“"),t.window.join("โ€“"),pct(t.p_target),t.competitor||"โ€”",pct(t.p_competitor),fmt(t.gop,3),fmt(st.gop),fmt(st.exemption),fmt(st.insertion),fmt(st.length),...Array.from({length:nref},(_,k)=>{ const x=refScores(t)[k]; return x==null?"โ€”":fmt(x,0); }),fmt(st.baseline),fmt(st.calibration),fmt(t.final),t.flagged?"ื›ืŸ":""];
const tr=el("tr",{},...cells.map((v,k)=>el("td",{class:[1,3,4,6].includes(k)?"ipa":"",text:String(v)})));
tr.onmouseenter=()=>hover(t.i); tr.onclick=()=>pick(t.i); tb.append(tr);
}
}
addEventListener("resize",()=>{ if(T) drawSelected(); });
/* the result the opening page left (the lab, the phoneme dialog), else the mock */
(function(){
const src=new URLSearchParams(location.search).get("src");
let r=null; try{ r=JSON.parse(localStorage.getItem("ihki.trace")||"null"); }catch{}
if(src!=="mock"&&r) load(r,"ืžื”ืืคืœื™ืงืฆื™ื”"); else loadMock();
})();
</script>
</body>
</html>