| """Template HTML autonome (vis-network) pour visualiser un sous-graphe JDM. |
| |
| Le template est paramétré par cinq placeholders remplacés par `str.replace` : |
| - `{{TITLE}}` — titre de la page (terme racine + profondeur) |
| - `{{SUBTITLE}}` — sous-titre explicatif |
| - `{{LEGEND}}` — HTML des chips de légende (relations actives) |
| - `{{NODES_JSON}}` — tableau JSON de nœuds vis-network |
| - `{{EDGES_JSON}}` — tableau JSON d'arêtes vis-network |
| |
| Le rendu côté navigateur (physique forceAtlas2Based, hover highlight, zoom, |
| recentrage) reproduit fidèlement plat_asiatique_subgraph.html. |
| """ |
| from __future__ import annotations |
|
|
| HTML_TEMPLATE = r"""<!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="utf-8"> |
| <title>{{TITLE}}</title> |
| <script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script> |
| <style> |
| /* Fond gris doux — un cran au-dessus du sombre précédent, pour ne pas |
| écraser les couleurs des nœuds tout en restant reposant pour l'œil. */ |
| html, body { margin:0; padding:0; height:100%; background:#c8ccd0; font-family: system-ui, sans-serif; color:#1a1a1a; } |
| header { padding:14px 20px; border-bottom:1px solid #a8acb0; background:#d4d7da; color:#1a1a1a; } |
| h1 { margin:0; font-size:18px; } |
| .sub { color:#3c4043; font-size:13px; margin-top:2px; } |
| #net { width:100%; height: calc(100vh - 110px); background:#d4d7da; } |
| #controls { position:absolute; top:70px; right:24px; z-index:10; display:flex; gap:6px; align-items:center; } |
| #controls button { padding:6px 10px; border:1px solid #9aa0a6; background:#e8eaed; border-radius:6px; cursor:pointer; font-size:13px; color:#1a1a1a; font-weight:500; } |
| #controls button:hover { background:#f1f3f4; } |
| #controls select { padding:6px 8px; border:1px solid #9aa0a6; background:#e8eaed; border-radius:6px; cursor:pointer; font-size:13px; color:#1a1a1a; font-weight:500; } |
| #btn-reset { background:#fff3e0; border-color:#a04500; } |
| #btn-reset .rot { display:inline-block; } |
| #btn-reset:hover .rot { animation: jdm-spin 0.7s linear; } |
| @keyframes jdm-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } |
| .legend { padding:8px 20px; font-size:12px; color:#202124; background:#d4d7da; border-top:1px solid #a8acb0; } |
| .legend span { display:inline-block; padding:2px 8px; border-radius:4px; margin-right:8px; } |
| </style> |
| </head> |
| <body> |
| <header> |
| <h1>{{TITLE}}</h1> |
| <div class="sub">{{SUBTITLE}}</div> |
| </header> |
| <div id="controls"> |
| <button id="btn-reset" onclick="resetGraph()" style="display:none;" |
| title="Revenir à l'état de départ"> |
| <span class="rot">↻</span> Réinitialiser |
| </button> |
| <select id="degree-threshold" |
| title="Seuil : « Masquer isolés » cachera les nœuds ayant moins d'arêtes que cette valeur" |
| onchange="onThresholdChange()"></select> |
| <button id="btn-degree" onclick="toggleLowDegree()" |
| title="Masquer les nœuds en dessous du seuil d'arêtes choisi">Masquer isolés</button> |
| <button id="btn-negation" onclick="toggleNegation()" |
| title="Approfondir (ou non) les nœuds atteints par une relation négative">Approfondir négations : OFF</button> |
| <button onclick="network.fit({animation:true})">Recentrer</button> |
| <button onclick="zoomBy(1.3)">Zoom +</button> |
| <button onclick="zoomBy(0.77)">Zoom -</button> |
| </div> |
| <div id="net"></div> |
| <div class="legend">{{LEGEND}}</div> |
| |
| <script> |
| const NODES_DATA = {{NODES_JSON}}; |
| const EDGES_DATA = {{EDGES_JSON}}; |
| |
| // Copies vierges pour le bouton "Réinitialiser" : les objets passés à |
| // vis.DataSet sont mutés par les .update() ultérieurs (fixed, x/y, hidden…), |
| // donc on en garde un instantané immuable. |
| const PRISTINE_NODES = JSON.parse(JSON.stringify(NODES_DATA)); |
| const PRISTINE_EDGES = JSON.parse(JSON.stringify(EDGES_DATA)); |
| |
| const nodes = new vis.DataSet(NODES_DATA); |
| const edges = new vis.DataSet(EDGES_DATA); |
| |
| const container = document.getElementById('net'); |
| const data = { nodes, edges }; |
| const options = { |
| layout: { improvedLayout: true }, |
| physics: { |
| enabled: true, |
| solver: 'forceAtlas2Based', |
| forceAtlas2Based: { |
| gravitationalConstant: -90, |
| centralGravity: 0.05, |
| springLength: 110, |
| springConstant: 0.12, |
| avoidOverlap: 1 |
| }, |
| stabilization: { iterations: 600 } |
| }, |
| edges: { width: 1.4, selectionWidth: 3, hoverWidth: 2 }, |
| interaction: { |
| hover: true, dragNodes: true, zoomView: true, zoomSpeed: 0.6, |
| minZoom: 0.1, maxZoom: 4, |
| hoverConnectedEdges: true, selectConnectedEdges: true |
| }, |
| nodes: { borderWidth: 1.5, borderWidthSelected: 3 } |
| }; |
| const network = new vis.Network(container, data, options); |
| |
| // À chaque fin de stabilisation (chargement initial OU recentrage de |
| // gravité OU réinitialisation), on fige la physique et on recadre. |
| network.on('stabilizationIterationsDone', () => { |
| network.setOptions({ physics: { enabled: false } }); |
| network.fit({ animation: { duration: 400 } }); |
| }); |
| |
| function zoomBy(factor) { |
| const s = network.getScale(); |
| network.moveTo({ scale: s * factor, animation: { duration: 200 } }); |
| } |
| |
| // ---------- État dynamique ---------- |
| const INITIAL_CENTER = 'ROOT'; // nœud central d'origine |
| let currentCenter = 'ROOT'; // centre de gravité courant |
| let hideLowDegree = false; // filtre "masquer isolés" actif ? |
| let deepenNegation = false; // approfondir les nœuds atteints par négation ? |
| |
| // Le degré = nb total d'arêtes (entrantes + sortantes) sur le graphe complet. |
| function nodeDegree(id) { return network.getConnectedEdges(id).length; } |
| |
| function graphMaxDegree() { |
| let m = 0; |
| nodes.getIds().forEach(id => { const d = nodeDegree(id); if (d > m) m = d; }); |
| return m; |
| } |
| |
| // Remplit la liste déroulante du seuil : 2 .. degré max du graphe. |
| function initThresholdDropdown() { |
| const sel = document.getElementById('degree-threshold'); |
| sel.innerHTML = ''; |
| const max = Math.max(2, graphMaxDegree()); |
| for (let i = 2; i <= max; i++) { |
| const opt = document.createElement('option'); |
| opt.value = i; |
| opt.textContent = '< ' + i + ' arêtes'; |
| sel.appendChild(opt); |
| } |
| sel.value = '2'; |
| } |
| |
| function currentThreshold() { |
| const sel = document.getElementById('degree-threshold'); |
| return parseInt(sel.value, 10) || 2; |
| } |
| |
| // ---------- Filtrage unifié ---------- |
| // computeHidden() recalcule les drapeaux `hidden` des nœuds ET des arêtes |
| // à partir de l'état des deux boutons (négation + isolés). Trois étapes : |
| // 1) arêtes : on masque les arêtes `_from_negation` si le bouton est OFF. |
| // 2) nœuds : filtre de degré (seuil de la liste déroulante). |
| // 3) nettoyage itératif des orphelins : tout nœud qui n'a plus aucune |
| // arête visible vers un nœud visible est masqué (cascade gérée). |
| function computeHidden() { |
| const showNeg = deepenNegation; |
| // (1) arêtes |
| edges.update(edges.getIds().map(id => { |
| const e = edges.get(id); |
| return { id, hidden: (!showNeg && !!e._from_negation) }; |
| })); |
| // (2) nœuds : filtre de degré |
| const threshold = currentThreshold(); |
| const hidden = {}; |
| nodes.getIds().forEach(id => { |
| if (id === currentCenter || id === INITIAL_CENTER) { hidden[id] = false; return; } |
| hidden[id] = hideLowDegree && nodeDegree(id) < threshold; |
| }); |
| // (3) orphelins : itère jusqu'à stabilité (gère les cascades en profondeur) |
| let changed = true; |
| while (changed) { |
| changed = false; |
| nodes.getIds().forEach(id => { |
| if (id === currentCenter || id === INITIAL_CENTER) return; |
| if (hidden[id]) return; |
| const conn = network.getConnectedEdges(id); |
| const hasVisible = conn.some(eid => { |
| const e = edges.get(eid); |
| if (e.hidden) return false; |
| const other = e.from === id ? e.to : e.from; |
| return !hidden[other]; |
| }); |
| if (!hasVisible) { hidden[id] = true; changed = true; } |
| }); |
| } |
| nodes.update(nodes.getIds().map(id => ({ id, hidden: !!hidden[id] }))); |
| } |
| |
| // applyFilters() = recalcul des masques uniquement. On NE relance PAS la |
| // physique : sur les gros graphes la re-stabilisation prenait plusieurs |
| // secondes à chaque clic. Les nœuds gardent leur position ; seuls les |
| // drapeaux `hidden` changent (affichage instantané). |
| function applyFilters() { |
| computeHidden(); |
| } |
| |
| function toggleLowDegree() { |
| hideLowDegree = !hideLowDegree; |
| document.getElementById('btn-degree').textContent = |
| hideLowDegree ? 'Tout afficher' : 'Masquer isolés'; |
| applyFilters(); |
| } |
| |
| function toggleNegation() { |
| deepenNegation = !deepenNegation; |
| document.getElementById('btn-negation').textContent = |
| 'Approfondir négations : ' + (deepenNegation ? 'ON' : 'OFF'); |
| applyFilters(); |
| } |
| |
| // Changement du seuil : si le filtre de degré est actif, on réapplique. |
| function onThresholdChange() { |
| if (hideLowDegree) applyFilters(); |
| } |
| |
| initThresholdDropdown(); |
| // État initial : négations NON approfondies (bouton OFF) → masquage appliqué |
| // avant la 1re stabilisation pour que la physique l'intègre directement. |
| computeHidden(); |
| |
| // ---------- Clic simple : centrer le nœud + cadrer ses connexions ---------- |
| network.on('click', params => { |
| if (params.nodes.length === 1) { |
| const id = params.nodes[0]; |
| const fitIds = [id, ...network.getConnectedNodes(id)]; |
| network.fit({ nodes: fitIds, animation: { duration: 500 } }); |
| } |
| }); |
| |
| // ---------- Double-clic : faire du nœud le centre de gravité ---------- |
| network.on('doubleClick', params => { |
| if (params.nodes.length === 1) recenterGravity(params.nodes[0]); |
| }); |
| |
| function recenterGravity(newId) { |
| if (newId === currentCenter) return; |
| // Libère l'ancien centre (il pourra de nouveau bouger). |
| nodes.update({ id: currentCenter, fixed: false, mass: 1 }); |
| // Fixe le nouveau centre au milieu, masse élevée. |
| nodes.update({ id: newId, x: 0, y: 0, fixed: { x: true, y: true }, mass: 5 }); |
| currentCenter = newId; |
| // Relance la physique pour réorganiser le graphe autour du nouveau centre. |
| network.setOptions({ physics: { enabled: true } }); |
| network.stabilize(); |
| // Le bouton "Réinitialiser" devient disponible. |
| document.getElementById('btn-reset').style.display = ''; |
| } |
| |
| // ---------- Réinitialiser à l'état de départ ---------- |
| function resetGraph() { |
| hideLowDegree = false; |
| deepenNegation = false; |
| currentCenter = INITIAL_CENTER; |
| // Recharge les DataSets depuis les copies vierges. |
| nodes.clear(); |
| edges.clear(); |
| nodes.add(JSON.parse(JSON.stringify(PRISTINE_NODES))); |
| edges.add(JSON.parse(JSON.stringify(PRISTINE_EDGES))); |
| rebuildEdgeBase(); |
| initThresholdDropdown(); // remet le seuil à 2 |
| document.getElementById('btn-degree').textContent = 'Masquer isolés'; |
| document.getElementById('btn-negation').textContent = 'Approfondir négations : OFF'; |
| document.getElementById('btn-reset').style.display = 'none'; |
| computeHidden(); // ré-applique l'état initial (négations non approfondies) |
| network.setOptions({ physics: { enabled: true } }); |
| network.stabilize(); |
| } |
| |
| // ---------- Hover : met en évidence le nœud survolé et ses voisins ---------- |
| // Les couleurs d'origine des arêtes (teintées par famille + opacité par |
| // niveau) sont snapshottées pour être restaurées proprement au blur. |
| let _edgeBase = {}; |
| function rebuildEdgeBase() { |
| _edgeBase = {}; |
| edges.forEach(e => { |
| _edgeBase[e.id] = { |
| color: e.color ? { ...e.color } : { color: '#9aa0a6', opacity: 1 }, |
| font: e.font ? { ...e.font } : { color: '#555', size: 14, background: '#d4d7daee' } |
| }; |
| }); |
| } |
| rebuildEdgeBase(); |
| |
| function highlight(focusId) { |
| const connected = new Set(network.getConnectedNodes(focusId)); |
| connected.add(focusId); |
| const connectedEdges = new Set(network.getConnectedEdges(focusId)); |
| nodes.update(nodes.getIds().map(id => ({ |
| id, |
| opacity: connected.has(id) ? 1 : 0.15, |
| font: { color: connected.has(id) ? (id === INITIAL_CENTER ? '#fff' : '#222') : '#bbb' } |
| }))); |
| edges.update(edges.getIds().map(id => { |
| const base = _edgeBase[id]; |
| if (connectedEdges.has(id)) { |
| return { id, color: { ...base.color, opacity: 1 }, font: base.font }; |
| } |
| return { |
| id, |
| color: { color: '#e0e0e0', opacity: 0.25 }, |
| font: { color: '#ddd', size: 13, background: '#d4d7dacc' } |
| }; |
| })); |
| } |
| function resetHighlight() { |
| nodes.update(nodes.getIds().map(id => ({ |
| id, opacity: 1, font: { color: id === INITIAL_CENTER ? '#fff' : '#222' } |
| }))); |
| edges.update(edges.getIds().map(id => { |
| const base = _edgeBase[id]; |
| return base ? { id, color: base.color, font: base.font } : { id }; |
| })); |
| } |
| network.on('hoverNode', p => highlight(p.node)); |
| network.on('blurNode', resetHighlight); |
| </script> |
| </body> |
| </html> |
| """ |
|
|