Download static/trace.html from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 48.7 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/static/trace.html
- Command line
-
hf download hf://spaces/wzender/mathly/static/trace.html
-
curl -L -o trace.html https://huggingface.co/spaces/wzender/mathly/resolve/main/static/trace.html
48.7 kB
| <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 | |
| --> | |
| <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} | |
| .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> | |
| </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="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> | |
| ; | |
| 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)",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; | |
| 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(){ 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?" · סומן כטעות":""))); | |
| 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"}); | |
| 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); }; | |
| phones.append(c); }); | |
| 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==="baseline") note=s.ref==null?"אין קול ילידי":`הקול הילידי: ${fmt(s.ref,0)}`; | |
| 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)); | |
| 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)]}`); | |
| $("p2facts").replaceChildren(...[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.tokens}`), el("span",{},"נשמע: ",el("b",{class:"ipa",text:u.heard}))); | |
| } | |
| /* ---------- the table: every number ---------- */ | |
| function drawTable(){ | |
| const tb=$("table"); tb.replaceChildren(); | |
| const head=["#","הגה","מילה","span","חלון","p_target","מתחרה","p_comp","GOP","gop","פטור","צרור","בסיס","כיול","סופי","סומן"]; | |
| 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.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> | |