Spaces:
Running
Running
| /* ═══════════════════════════════════════════════════════════════════════════ | |
| Expression Émotionnelle — Viewer JavaScript | |
| 1 row = 1 message, all annotations shown simultaneously | |
| ═══════════════════════════════════════════════════════════════════════════ */ | |
| (() => { | |
| "use strict"; | |
| const PAGE_SIZE = 30; | |
| /* ── Colour maps ──────────────────────────────────────────────────────── */ | |
| const MODE_HL = { | |
| "Désignée": "rgba(40,80,160,.30)", | |
| "Comportementale": "rgba(0,130,100,.30)", | |
| "Suggérée": "rgba(180,100,20,.30)", | |
| "Montrée": "rgba(120,50,140,.30)", | |
| }; | |
| const MODE_HL_DEFAULT = "rgba(120,120,120,.30)"; | |
| const EMOTION_CSS = { | |
| "Colère":"pill-colere","Dégoût":"pill-degout","Joie":"pill-joie", | |
| "Peur":"pill-peur","Surprise":"pill-surprise","Tristesse":"pill-tristesse", | |
| "Admiration":"pill-admiration","Culpabilité":"pill-culpabilite", | |
| "Embarras":"pill-embarras","Fierté":"pill-fierte", | |
| "Jalousie":"pill-jalousie","Autre":"pill-autre", | |
| }; | |
| const MODE_CSS = { | |
| "Comportementale":"pill-mode-comportementale","Désignée":"pill-mode-designee", | |
| "Montrée":"pill-mode-montree","Suggérée":"pill-mode-suggeree", | |
| }; | |
| const EMOTION_COLORS = { | |
| "Colère":"#e8575a","Dégoût":"#7b6d8d","Joie":"#f4b942","Peur":"#484d6d", | |
| "Surprise":"#3ea8c2","Tristesse":"#5e7fa0","Admiration":"#e09f5b", | |
| "Culpabilité":"#9b8ea3","Embarras":"#cc8899","Fierté":"#5ea87b", | |
| "Jalousie":"#8b5d5d","Autre":"#a0a4b0", | |
| }; | |
| const MODE_COLORS = { | |
| "Comportementale":"#4361ee","Désignée":"#06d6a0", | |
| "Montrée":"#f4b942","Suggérée":"#e8575a", | |
| }; | |
| /* ── State ────────────────────────────────────────────────────────────── */ | |
| let allMessages = []; // grouped by message_id | |
| let allRecords = []; // flat (for chart counting) | |
| let texts = {}; | |
| let meta = {}; | |
| let filtered = []; | |
| let page = 0; | |
| let sortKey = null, sortAsc = true; | |
| let debounceTimer = null; | |
| let showDecl = true, showArrows = true; | |
| const $ = s => document.querySelector(s); | |
| const $$ = s => document.querySelectorAll(s); | |
| /* ── Load & group ─────────────────────────────────────────────────────── */ | |
| async function loadData() { | |
| try { | |
| const resp = await fetch("data.json"); | |
| if (!resp.ok) throw new Error(`HTTP ${resp.status}`); | |
| const payload = await resp.json(); | |
| allRecords = payload.records; | |
| texts = payload.texts || {}; | |
| meta = payload.meta; | |
| // Group records by message_id | |
| const map = new Map(); | |
| for (const r of allRecords) { | |
| const key = r.message_id; | |
| if (!map.has(key)) { | |
| map.set(key, { message_id: key, corpus: r.corpus, source_file: r.source_file, annotations: [] }); | |
| } | |
| map.get(key).annotations.push(r); | |
| } | |
| allMessages = [...map.values()]; | |
| initFilters(); | |
| applyFilters(); | |
| $("#loading-overlay").classList.add("hidden"); | |
| } catch (err) { | |
| $("#loading-overlay").innerHTML = | |
| `<p style="color:var(--danger)">Erreur : ${err.message}</p> | |
| <p style="color:var(--text-tertiary);font-size:.8rem">Vérifiez <code>data.json</code>.</p>`; | |
| } | |
| } | |
| /* ── Filters ──────────────────────────────────────────────────────────── */ | |
| function buildCheckboxGroup(id, values) { | |
| const c = document.getElementById(id); | |
| c.innerHTML = ""; | |
| values.forEach(v => { | |
| const lbl = document.createElement("label"); lbl.className = "checkbox-item"; | |
| const cb = document.createElement("input"); cb.type = "checkbox"; cb.value = v; cb.checked = true; | |
| cb.addEventListener("change", scheduleFilter); | |
| const sp = document.createElement("span"); sp.textContent = v; | |
| lbl.append(cb, sp); c.appendChild(lbl); | |
| }); | |
| } | |
| function initFilters() { | |
| buildCheckboxGroup("corpus-checks", meta.corpora); | |
| buildCheckboxGroup("type-checks", meta.unit_types); | |
| buildCheckboxGroup("emotion-checks", meta.emotions); | |
| buildCheckboxGroup("mode-checks", meta.modes); | |
| buildCheckboxGroup("nature-checks", meta.natures); | |
| $("#search-input").addEventListener("input", scheduleFilter); | |
| $$("#discontinuous-checks input").forEach(cb => cb.addEventListener("change", scheduleFilter)); | |
| // Sidebar toggle | |
| $("#btn-close-sidebar").addEventListener("click", () => $("#sidebar").classList.add("collapsed")); | |
| $("#btn-open-sidebar").addEventListener("click", () => $("#sidebar").classList.remove("collapsed")); | |
| // Display toggles | |
| const declCb = $("#toggle-decl"); | |
| declCb.addEventListener("change", () => { | |
| showDecl = declCb.checked; | |
| document.body.classList.toggle("hide-decl", !showDecl); | |
| }); | |
| const arrowCb = $("#toggle-arrows"); | |
| arrowCb.addEventListener("change", () => { | |
| showArrows = arrowCb.checked; | |
| document.body.classList.toggle("show-arrows", showArrows); | |
| renderTable(); | |
| }); | |
| // Charts toggle | |
| const chartsToggle = $("#charts-toggle"); | |
| const chartsRow = $("#charts-row"); | |
| chartsToggle.addEventListener("click", () => { | |
| const isOpen = !chartsRow.classList.contains("charts-collapsed"); | |
| chartsRow.classList.toggle("charts-collapsed", isOpen); | |
| chartsToggle.classList.toggle("open", !isOpen); | |
| chartsToggle.firstElementChild.nextSibling; // force reflow | |
| if (!isOpen) { | |
| chartsRow.style.maxHeight = chartsRow.scrollHeight + "px"; | |
| } else { | |
| chartsRow.style.maxHeight = ""; | |
| } | |
| }); | |
| // Reset | |
| $("#btn-reset").addEventListener("click", resetFilters); | |
| // Sort | |
| $$("th.sortable").forEach(th => { | |
| th.addEventListener("click", () => { | |
| const key = th.dataset.sort; | |
| if (sortKey === key) sortAsc = !sortAsc; | |
| else { sortKey = key; sortAsc = true; } | |
| $$("th.sortable").forEach(h => h.classList.remove("sort-asc","sort-desc")); | |
| th.classList.add(sortAsc ? "sort-asc" : "sort-desc"); | |
| applyFilters(); | |
| }); | |
| }); | |
| // Modal | |
| $("#modal-close").addEventListener("click", closeModal); | |
| $("#modal-backdrop").addEventListener("click", e => { if (e.target === e.currentTarget) closeModal(); }); | |
| document.addEventListener("keydown", e => { if (e.key === "Escape") closeModal(); }); | |
| } | |
| function resetFilters() { | |
| $$("#sidebar input[type='checkbox']").forEach(cb => cb.checked = true); | |
| $("#search-input").value = ""; | |
| sortKey = null; sortAsc = true; | |
| showDecl = true; showArrows = true; | |
| $("#toggle-decl").checked = true; | |
| $("#toggle-arrows").checked = true; | |
| document.body.classList.remove("hide-decl"); | |
| document.body.classList.add("show-arrows"); | |
| $$("th.sortable").forEach(h => h.classList.remove("sort-asc","sort-desc")); | |
| applyFilters(); | |
| } | |
| function scheduleFilter() { clearTimeout(debounceTimer); debounceTimer = setTimeout(applyFilters, 120); } | |
| function checkedVals(id) { | |
| return new Set([...document.querySelectorAll(`#${id} input:checked`)].map(cb => cb.value)); | |
| } | |
| function applyFilters() { | |
| const corpora = checkedVals("corpus-checks"); | |
| const types = checkedVals("type-checks"); | |
| const emotions = checkedVals("emotion-checks"); | |
| const modes = checkedVals("mode-checks"); | |
| const natures = checkedVals("nature-checks"); | |
| const discont = checkedVals("discontinuous-checks"); | |
| const searchRaw = ($("#search-input").value || "").trim().toLowerCase(); | |
| const terms = searchRaw ? searchRaw.split(/\s+/) : []; | |
| filtered = allMessages.filter(msg => { | |
| if (!corpora.has(msg.corpus)) return false; | |
| // At least one annotation must pass | |
| const ok = msg.annotations.some(a => { | |
| if (!types.has(a.unit_type)) return false; | |
| const emos = [a.emotion1, a.emotion2, a.emotion3].filter(Boolean); | |
| if (emos.length > 0 && !emos.some(e => emotions.has(e))) return false; | |
| if (a.mode && !modes.has(a.mode)) return false; | |
| if (a.nature_linguistique && !natures.has(a.nature_linguistique)) return false; | |
| const d = a.is_discontinuous ? "true" : "false"; | |
| if (!discont.has(d)) return false; | |
| return true; | |
| }); | |
| if (!ok) return false; | |
| if (terms.length) { | |
| const hay = (texts[msg.message_id] || "").toLowerCase(); | |
| if (!terms.every(t => hay.includes(t))) return false; | |
| } | |
| return true; | |
| }); | |
| if (sortKey) { | |
| filtered.sort((a, b) => { | |
| const va = (a[sortKey] || "").toLowerCase(); | |
| const vb = (b[sortKey] || "").toLowerCase(); | |
| return sortAsc ? va.localeCompare(vb) : vb.localeCompare(va); | |
| }); | |
| } | |
| page = 0; | |
| updateCharts(); | |
| renderTable(); | |
| } | |
| /* ── Charts (count individual annotations) ──────────────────────────── */ | |
| function countBy(arr, key) { | |
| const c = {}; | |
| arr.forEach(r => { const v = r[key]; if (v) c[v] = (c[v]||0)+1; }); | |
| return c; | |
| } | |
| function renderBars(id, counts, cmap) { | |
| const el = document.getElementById(id); | |
| const entries = Object.entries(counts).sort((a,b) => b[1]-a[1]); | |
| const mx = entries.length ? entries[0][1] : 1; | |
| el.innerHTML = entries.map(([l,c]) => { | |
| const pct = Math.max(c/mx*100, 1); | |
| const col = cmap[l] || "#a0a4b0"; | |
| return `<div class="bar-row"><span class="bar-label" title="${esc(l)}">${esc(l)}</span><div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${col}"></div></div><span class="bar-count">${c}</span></div>`; | |
| }).join(""); | |
| } | |
| function updateCharts() { | |
| // Collect annotations from filtered messages only | |
| const anns = filtered.flatMap(m => m.annotations); | |
| renderBars("bars-emotion", countBy(anns, "emotion1"), EMOTION_COLORS); | |
| renderBars("bars-mode", countBy(anns, "mode"), MODE_COLORS); | |
| } | |
| /* ── Annotated text rendering ─────────────────────────────────────────── */ | |
| function modeColor(mode) { return MODE_HL[mode] || MODE_HL_DEFAULT; } | |
| /** | |
| * Render full message text with all annotations highlighted. | |
| * - Segments → background coloured by mode | |
| * - Déclencheurs → red bold underline (class hl-decl) | |
| * - Arrows → for eligible (non-disc, non-overlapping) annotations | |
| */ | |
| function renderAnnotated(msg, textSlice, offsetShift) { | |
| const txt = textSlice !== undefined ? textSlice : (texts[msg.message_id] || ""); | |
| const shift = offsetShift || 0; | |
| if (!txt) return esc(msg.annotations[0]?.segment_text || "–"); | |
| const len = txt.length; | |
| const anns = msg.annotations; | |
| // Determine which annotations are eligible for arrows. | |
| // Two cases: | |
| // A) Has déclencheur → arrow on déclencheur (single span, non-overlapping with other décl.) | |
| // B) No déclencheur, has segment → arrow on segment (single span, non-overlapping with other seg.) | |
| const eligDecl = new Set(); // case A | |
| const eligSegOnly = new Set(); // case B | |
| for (let i = 0; i < anns.length; i++) { | |
| const decls = anns[i].declencheur_offsets || []; | |
| const segs = anns[i].segment_offsets || []; | |
| if (decls.length === 1) { | |
| // Case A: has déclencheur | |
| const [d1s, d1e] = decls[0]; | |
| let ov = false; | |
| for (let j = 0; j < anns.length; j++) { | |
| if (i === j) continue; | |
| for (const [d2s, d2e] of (anns[j].declencheur_offsets || [])) { | |
| if (Math.max(d1s, d2s) < Math.min(d1e, d2e)) { ov = true; break; } | |
| } | |
| if (ov) break; | |
| } | |
| if (!ov) eligDecl.add(i); | |
| } else if (decls.length === 0 && segs.length === 1 && !anns[i].is_discontinuous) { | |
| // Case B: no déclencheur, single segment | |
| const [s1, e1] = segs[0]; | |
| let ov = false; | |
| for (let j = 0; j < anns.length; j++) { | |
| if (i === j) continue; | |
| for (const [s2, e2] of (anns[j].segment_offsets || [])) { | |
| if (Math.max(s1, s2) < Math.min(e1, e2)) { ov = true; break; } | |
| } | |
| if (ov) break; | |
| } | |
| if (!ov) eligSegOnly.add(i); | |
| } | |
| } | |
| // Collect boundary events | |
| const events = []; | |
| anns.forEach((ann, ai) => { | |
| const mode = ann.mode || "Autre"; | |
| const emo = ann.emotion1 || ""; | |
| for (const [s, e] of (ann.segment_offsets || [])) { | |
| const ls = s - shift, le = e - shift; | |
| if (le <= 0 || ls >= len) continue; | |
| events.push({ pos: Math.max(0, ls), type: "seg", open: true, mode, emo, ai, elig: eligSegOnly.has(ai) }); | |
| events.push({ pos: Math.min(len, le), type: "seg", open: false, mode, emo, ai, elig: eligSegOnly.has(ai) }); | |
| } | |
| for (const [s, e] of (ann.declencheur_offsets || [])) { | |
| const ls = s - shift, le = e - shift; | |
| if (le <= 0 || ls >= len) continue; | |
| events.push({ pos: Math.max(0, ls), type: "decl", open: true, ai, emo, elig: eligDecl.has(ai) }); | |
| events.push({ pos: Math.min(len, le), type: "decl", open: false, ai, emo, elig: eligDecl.has(ai) }); | |
| } | |
| }); | |
| events.sort((a, b) => a.pos - b.pos || (a.open === b.open ? 0 : a.open ? 1 : -1)); | |
| let html = "", pos = 0; | |
| let activeSegs = []; | |
| let activeDecls = []; | |
| function flush(end) { | |
| if (end <= pos) return; | |
| const slice = esc(txt.slice(pos, end)); | |
| if (activeSegs.length > 0 && activeDecls.length > 0) { | |
| html += `<span class="hl-segment hl-decl" style="background:${modeColor(activeSegs[0].mode)}">${slice}</span>`; | |
| } else if (activeSegs.length > 0) { | |
| html += `<span class="hl-segment" style="background:${modeColor(activeSegs[0].mode)}">${slice}</span>`; | |
| } else if (activeDecls.length > 0) { | |
| html += `<span class="hl-decl">${slice}</span>`; | |
| } else { | |
| html += slice; | |
| } | |
| pos = end; | |
| } | |
| // Track open arrow wrappers (annotation index → source type) | |
| const openWrappers = new Set(); | |
| for (const ev of events) { | |
| flush(ev.pos); | |
| if (ev.type === "seg") { | |
| if (ev.open) { | |
| // Case B: segment-only arrow wrapper | |
| if (ev.elig) { | |
| html += `<span class="hl-seg-wrap">`; | |
| openWrappers.add(ev.ai); | |
| } | |
| activeSegs.push(ev); | |
| } else { | |
| const idx = activeSegs.findIndex(s => s.ai === ev.ai); | |
| if (idx >= 0) activeSegs.splice(idx, 1); | |
| // Close segment-only arrow wrapper | |
| if (ev.elig && openWrappers.has(ev.ai)) { | |
| openWrappers.delete(ev.ai); | |
| html += `<span class="hl-emo-callout"><span class="hl-callout-arrow">↓</span><span class="hl-callout-label">${esc(ev.emo)}</span></span></span>`; | |
| } | |
| } | |
| } else { // decl | |
| if (ev.open) { | |
| // Case A: déclencheur arrow wrapper | |
| if (ev.elig) { | |
| html += `<span class="hl-seg-wrap">`; | |
| openWrappers.add(ev.ai); | |
| } | |
| activeDecls.push(ev); | |
| } else { | |
| const idx = activeDecls.findIndex(d => d.ai === ev.ai); | |
| if (idx >= 0) activeDecls.splice(idx, 1); | |
| // Close déclencheur arrow wrapper | |
| if (ev.elig && openWrappers.has(ev.ai)) { | |
| openWrappers.delete(ev.ai); | |
| html += `<span class="hl-emo-callout"><span class="hl-callout-arrow">↓</span><span class="hl-callout-label">${esc(ev.emo)}</span></span></span>`; | |
| } | |
| } | |
| } | |
| } | |
| flush(len); | |
| return html; | |
| } | |
| /** | |
| * For table: extract a context window around annotations for long texts. | |
| */ | |
| function renderContext(msg) { | |
| const fullText = texts[msg.message_id]; | |
| if (!fullText) return esc(msg.annotations[0]?.segment_text || "–"); | |
| if (fullText.length <= 500) return renderAnnotated(msg); | |
| // Find bounds of all annotations | |
| const allOff = msg.annotations.flatMap(a => [...(a.segment_offsets||[]), ...(a.declencheur_offsets||[])]); | |
| if (!allOff.length) return esc(fullText.slice(0, 200)) + " …"; | |
| const minS = Math.min(...allOff.map(o => o[0])); | |
| const maxE = Math.max(...allOff.map(o => o[1])); | |
| // Expand to sentence boundaries | |
| let ctxStart = Math.max(0, minS - 150); | |
| let ctxEnd = Math.min(fullText.length, maxE + 150); | |
| // Find sentence start | |
| const left = fullText.slice(ctxStart, minS); | |
| const lm = left.match(/[.!?\n]\s+/g); | |
| if (lm) { | |
| const li = left.lastIndexOf(lm[lm.length - 1]); | |
| if (li >= 0) ctxStart += li + lm[lm.length - 1].length; | |
| } | |
| // Find sentence end | |
| const right = fullText.slice(maxE, ctxEnd); | |
| const rm = right.match(/[.!?…]\s/); | |
| if (rm) ctxEnd = maxE + rm.index + 1; | |
| while (ctxStart < minS && fullText[ctxStart] === " ") ctxStart++; | |
| const prefix = ctxStart > 0 ? "… " : ""; | |
| const suffix = ctxEnd < fullText.length ? " …" : ""; | |
| return prefix + renderAnnotated(msg, fullText.slice(ctxStart, ctxEnd), ctxStart) + suffix; | |
| } | |
| /* ── Table helpers ────────────────────────────────────────────────────── */ | |
| function esc(s) { | |
| if (!s) return ""; | |
| return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); | |
| } | |
| function emotionPill(v) { return `<span class="pill ${EMOTION_CSS[v]||'pill-autre'}">${esc(v)}</span>`; } | |
| function modePill(v) { return `<span class="pill ${MODE_CSS[v]||''}">${esc(v)}</span>`; } | |
| function uniqueValues(anns, key) { | |
| return [...new Set(anns.map(a => a[key]).filter(Boolean))]; | |
| } | |
| /* ── Table ────────────────────────────────────────────────────────────── */ | |
| function renderTable() { | |
| const total = Math.ceil(filtered.length / PAGE_SIZE); | |
| const start = page * PAGE_SIZE; | |
| const end = Math.min(start + PAGE_SIZE, filtered.length); | |
| const slice = filtered.slice(start, end); | |
| $("#table-count").textContent = filtered.length === 0 | |
| ? "Aucun résultat" | |
| : `${(start+1).toLocaleString("fr-FR")}–${end.toLocaleString("fr-FR")} sur ${filtered.length.toLocaleString("fr-FR")} messages`; | |
| const tbody = document.getElementById("table-body"); | |
| tbody.innerHTML = slice.map((msg, i) => { | |
| const emos = uniqueValues(msg.annotations, "emotion1"); | |
| const modes = uniqueValues(msg.annotations, "mode"); | |
| const nats = uniqueValues(msg.annotations, "nature_linguistique"); | |
| return ` | |
| <tr data-idx="${start + i}"> | |
| <td class="annotated-text">${renderContext(msg)}</td> | |
| <td><div class="tag-pills">${emos.map(emotionPill).join("")}</div></td> | |
| <td><div class="tag-pills">${modes.map(modePill).join("")}</div></td> | |
| <td><div class="tag-pills">${nats.map(n => `<span class="pill pill-autre">${esc(n)}</span>`).join("") || '<span style="color:var(--text-tertiary)">–</span>'}</div></td> | |
| </tr>`; | |
| }).join(""); | |
| tbody.querySelectorAll("tr").forEach(tr => { | |
| tr.addEventListener("click", () => openModal(filtered[parseInt(tr.dataset.idx, 10)])); | |
| }); | |
| renderPagination(total); | |
| } | |
| function renderPagination(total) { | |
| const c = document.getElementById("pagination"); | |
| if (total <= 1) { c.innerHTML = ""; return; } | |
| let h = `<button ${page===0?"disabled":""} data-page="${page-1}">‹</button>`; | |
| const mx = 7; | |
| let s = Math.max(0, page - Math.floor(mx/2)); | |
| let e = Math.min(total, s + mx); | |
| if (e - s < mx) s = Math.max(0, e - mx); | |
| if (s > 0) { h += `<button data-page="0">1</button>`; if (s > 1) h += `<span class="page-info">…</span>`; } | |
| for (let i = s; i < e; i++) h += `<button data-page="${i}" class="${i===page?'active':''}">${i+1}</button>`; | |
| if (e < total) { if (e < total-1) h += `<span class="page-info">…</span>`; h += `<button data-page="${total-1}">${total}</button>`; } | |
| h += `<button ${page>=total-1?"disabled":""} data-page="${page+1}">›</button>`; | |
| c.innerHTML = h; | |
| c.querySelectorAll("button").forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| const p = parseInt(btn.dataset.page, 10); | |
| if (!isNaN(p) && p >= 0 && p < total) { page = p; renderTable(); document.getElementById("table-container").scrollIntoView({behavior:"smooth",block:"start"}); } | |
| }); | |
| }); | |
| } | |
| /* ── Modal ────────────────────────────────────────────────────────────── */ | |
| function openModal(msg) { | |
| const body = document.getElementById("modal-body"); | |
| const fullText = texts[msg.message_id] || ""; | |
| // Render full annotated text | |
| const annotatedHtml = fullText | |
| ? renderAnnotated(msg) | |
| : esc(msg.annotations[0]?.segment_text || "–"); | |
| // List all annotations | |
| const annHtml = msg.annotations.map((a, i) => { | |
| const emos = [a.emotion1, a.emotion2, a.emotion3].filter(Boolean); | |
| return ` | |
| <div class="modal-ann-item"> | |
| <strong>Annotation ${i + 1}</strong> | |
| — ${a.unit_type || ""} | |
| ${a.mode ? modePill(a.mode) : ""} | |
| <div class="tag-pills">${emos.map(emotionPill).join("")}</div> | |
| <div style="margin-top:4px;font-size:var(--text-xs);color:var(--text-secondary)"> | |
| Segment : « ${esc(a.segment_text || "–")} »<br> | |
| Déclencheur : ${a.declencheur_text ? `« <span style="color:var(--hl-decl-color);font-weight:600">${esc(a.declencheur_text)}</span> »` : "–"}<br> | |
| Nature ling. : ${esc(a.nature_linguistique || "–")} | |
| ${a.is_discontinuous ? " · <em>discontinue</em>" : ""} | |
| </div> | |
| </div>`; | |
| }).join(""); | |
| body.innerHTML = ` | |
| <div class="modal-grid"> | |
| <div class="modal-field"> | |
| <div class="modal-field-label">Annotations</div> | |
| <div class="modal-field-value">${msg.annotations.length} unité(s)</div> | |
| </div> | |
| </div> | |
| <div class="modal-field"> | |
| <div class="modal-field-label">Texte annoté</div> | |
| <div class="modal-text-block annotated-text">${annotatedHtml}</div> | |
| </div> | |
| <div class="modal-field"> | |
| <div class="modal-field-label">Détail des annotations</div> | |
| <div class="modal-ann-list">${annHtml}</div> | |
| </div> | |
| `; | |
| $("#modal-backdrop").classList.remove("hidden"); | |
| document.body.style.overflow = "hidden"; | |
| } | |
| function closeModal() { | |
| $("#modal-backdrop").classList.add("hidden"); | |
| document.body.style.overflow = ""; | |
| } | |
| /* ── Init ──────────────────────────────────────────────────────────────── */ | |
| document.addEventListener("DOMContentLoaded", loadData); | |
| })(); | |