Visualisation / app.js
GwendalTsang's picture
Upload folder using huggingface_hub
5096233 verified
Raw
History Blame Contribute Delete
23.6 kB
/* ═══════════════════════════════════════════════════════════════════════════
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
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);
})();