diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca1a5c..6f5c79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [0.3.2.post4] - 2026-06-17 + +### New Features + +- **Readable node names in the interactive overflow viewer** (PR #78): the + viewer now uses each node's human-readable display label (e.g. a + voltage-level name such as `Saucats 400kV`, set via the Graphviz `label` + node attribute by the upstream recommender) instead of only the raw node + id. Search matches on **both** the readable display name and the stable id; + the hover tooltip and selection panel show the readable name as the header + with the id underneath only when it differs. Node identity (SVG `` / + `data-name`) is unchanged, so selection, adjacency highlight and + double-click SLD resolution keep using the stable id. + +### Bug Fixes + +- **Do not leak the Graphviz `\N` placeholder**: label-less nodes carry the + Graphviz `\N` ("use node name") placeholder in their label attribute; + `nodeDisplayName` now ignores any backslash escape and falls back to the + id, so tooltips/search never surface a literal `\N`. + +### Tests + +- `test_interactive_html.py`: readable label surfaces as `data-attr-label` + without losing the id; search consults both id and resolved display name; + tooltip/selection skip the duplicated `label` attribute; label-less nodes + fall back to the id (no `\N` leak). + ## [0.3.2.post2] - 2026-05-07 ### New Features diff --git a/alphaDeesp/core/interactive_html.py b/alphaDeesp/core/interactive_html.py index 4562669..8ca0d1c 100644 --- a/alphaDeesp/core/interactive_html.py +++ b/alphaDeesp/core/interactive_html.py @@ -525,7 +525,7 @@ def _edge_repl(match: re.Match) -> str: <div class="hint">Click a node to highlight its neighbourhood. Wheel to zoom, drag to pan. Esc clears selection.</div> <h2>Search</h2> - <input type="text" id="search" placeholder="filter nodes by name…" autocomplete="off"> + <input type="text" id="search" placeholder="filter nodes by name or id…" autocomplete="off"> <h2>Layers</h2> <div class="layer-controls"> @@ -598,16 +598,41 @@ def _edge_repl(match: re.Match) -> str: // ---- Adjacency lookup, hover, click-highlight ---- const tooltip = document.getElementById('tooltip'); const info = document.getElementById('info'); - function fmtAttrs(prefix, el) { + function fmtAttrs(prefix, el, skip) { const out = []; + const skipSet = skip ? new Set(skip) : null; for (const a of el.attributes) { if (a.name.startsWith('data-' + prefix + '-')) { const k = a.name.slice(('data-' + prefix + '-').length); + if (skipSet && skipSet.has(k)) continue; out.push('<span class="key">' + k + '</span>: ' + a.value); } } return out.join('<br>'); } + // Display name for a node: the readable ``label`` (e.g. a voltage-level + // name such as "Saucats 400kV") when present, else the stable node id + // (``data-name``). The id is preserved as the node identity for + // selection / adjacency / double-click resolution; ``label`` only + // changes what the operator reads. + function nodeDisplayName(el) { + if (!el) return ''; + const label = el.getAttribute('data-attr-label'); + // Graphviz stores an *unset* label as the placeholder "\\N" (meaning + // "node name"); any backslash escape (\\N, \\G, …) is not a readable + // name, so fall back to the stable id (data-name) in that case. + if (label && label.indexOf('\\\\') === -1) return label; + return el.getAttribute('data-name') || ''; + } + // Tooltip / selection header for a node: readable name in bold, with the + // raw id shown underneath only when it differs from the readable name. + function nodeHeaderHtml(el) { + const disp = nodeDisplayName(el); + const id = el.getAttribute('data-name') || ''; + let head = '<b>' + escapeHtml(disp) + '</b>'; + if (id && id !== disp) head += '<br><span class="key">id</span>: ' + escapeHtml(id); + return head; + } function showTooltip(e, html) { tooltip.innerHTML = html; tooltip.style.display = 'block'; @@ -635,7 +660,7 @@ def _edge_repl(match: re.Match) -> str: const ed = document.getElementById(n.edge); if (ed) ed.classList.add('hl'); } - info.innerHTML = '<b>' + escapeHtml(name) + '</b><br>' + fmtAttrs('attr', node) + info.innerHTML = nodeHeaderHtml(node) + '<br>' + fmtAttrs('attr', node, ['label']) + '<br><br>degree: ' + neighbours.length; } function cssEscape(s) { return (window.CSS && CSS.escape) ? CSS.escape(s) : s.replace(/(["\\\\])/g, '\\\\$1'); } @@ -644,7 +669,7 @@ def _edge_repl(match: re.Match) -> str: root.addEventListener('mouseover', (e) => { const g = e.target.closest('.node, .edge'); if (!g) return; if (g.classList.contains('node')) { - showTooltip(e, '<b>' + escapeHtml(g.getAttribute('data-name') || '') + '</b><br>' + fmtAttrs('attr', g)); + showTooltip(e, nodeHeaderHtml(g) + '<br>' + fmtAttrs('attr', g, ['label'])); } else { const lbl = g.querySelector('text'); const name = g.getAttribute('data-attr-name') || ''; showTooltip(e, '<b>' + escapeHtml(g.getAttribute('data-source')) + ' → ' + escapeHtml(g.getAttribute('data-target')) + '</b>' @@ -676,8 +701,15 @@ def _edge_repl(match: re.Match) -> str: root.classList.add('has-search'); let count = 0; root.querySelectorAll('.node').forEach(n => { - const name = (n.getAttribute('data-name') || '').toLowerCase(); - if (name.indexOf(q) !== -1) { n.classList.add('match'); count++; } + // Match against both the stable id (data-name) and the resolved + // readable display name (e.g. a voltage-level name), so operators can + // find a node by either spelling. nodeDisplayName ignores the + // graphviz "\\N" placeholder, so label-less nodes match on their id. + const id = (n.getAttribute('data-name') || '').toLowerCase(); + const disp = nodeDisplayName(n).toLowerCase(); + if (id.indexOf(q) !== -1 || (disp && disp.indexOf(q) !== -1)) { + n.classList.add('match'); count++; + } }); } document.getElementById('search').addEventListener('input', applySearch); diff --git a/alphaDeesp/tests/test_interactive_html.py b/alphaDeesp/tests/test_interactive_html.py index 282b2a2..5c8c768 100644 --- a/alphaDeesp/tests/test_interactive_html.py +++ b/alphaDeesp/tests/test_interactive_html.py @@ -220,6 +220,70 @@ def test_layer_index_node_arg_is_optional_for_legacy_callers(): assert "nodes" in layer and "edges" in layer +def test_readable_node_label_surfaces_without_losing_id(): + """A node ``label`` (e.g. a readable voltage-level name) is rendered and + exposed as ``data-attr-label`` while the node identity (``<title>`` / + ``data-name``) stays the original id — so selection, adjacency and + double-click resolution keep using the stable id.""" + g = nx.MultiDiGraph() + g.add_node("VL_way_1", color="red", shape="oval", label="Saucats 400kV") + g.add_node("VL_way_2", color="blue", shape="oval") + g.add_edge("VL_way_1", "VL_way_2", color="coral", label="42", name="line_1") + pg = nx.drawing.nx_pydot.to_pydot(g) + html = build_interactive_html(pg, title="toy") + + # Readable name is exposed for the JS (search + tooltip header) … + assert 'data-attr-label="Saucats 400kV"' in html + # … and rendered as the visible node text. + assert "Saucats 400kV" in html + # Node identity is preserved as the id. + assert "<title>VL_way_1" in html + assert 'data-name="VL_way_1"' in html + + +def test_search_matches_readable_label_and_id(): + """The viewer's search filters on both the readable label and the id.""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + html = build_interactive_html(pg, title="toy") + # Search reads the readable label attribute in addition to data-name. + assert "data-attr-label" in html + assert "function nodeDisplayName" in html + assert "function nodeHeaderHtml" in html + # The search routine consults BOTH the stable id (data-name) and the + # resolved readable display name (via nodeDisplayName). + search_fn = html.split("function applySearch()", 1)[1].split( + "document.getElementById('search').addEventListener", 1 + )[0] + assert "data-name" in search_fn + assert "nodeDisplayName(n)" in search_fn + + +def test_label_less_nodes_fall_back_to_id_for_display(): + """Backward compatibility: when no readable ``label`` is set, Graphviz + stores the placeholder ``\\N`` in the label attribute. The viewer must + NOT surface that placeholder — ``nodeDisplayName`` ignores any + backslash escape and falls back to the stable id (data-name).""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) # _toy_graph sets no labels + html = build_interactive_html(pg, title="toy") + assert 'data-name="VALDI"' in html + # Graphviz emits the "\N" placeholder for label-less nodes … + assert 'data-attr-label="\\N"' in html + # … and the viewer guards against leaking any backslash placeholder. + assert "label.indexOf('\\\\') === -1" in html + + +def test_node_tooltip_skips_duplicated_label_attr(): + """Node tooltips/selection use the readable name as the header and pass + a skip-list so the ``label`` attribute is not also repeated in the + attribute dump.""" + pg = nx.drawing.nx_pydot.to_pydot(_toy_graph()) + html = build_interactive_html(pg, title="toy") + # fmtAttrs accepts a skip list and node renders pass ['label']. + assert "function fmtAttrs(prefix, el, skip)" in html + assert "fmtAttrs('attr', g, ['label'])" in html + assert "fmtAttrs('attr', node, ['label'])" in html + + def test_template_uses_dim_class_not_display_none(): """Unchecked layers must DIM elements (not hide them) so spatial context is preserved.""" diff --git a/setup.py b/setup.py index 7a87160..c621d7b 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ } setup(name='ExpertOp4Grid', - version='0.3.2.post3', + version='0.3.2.post4', description='Expert analysis algorithm for solving overloads in a powergrid', long_description_content_type="text/markdown", python_requires=">=3.9", @@ -50,7 +50,7 @@ author='Antoine Marot', author_email='antoine.marot@rte-france.com', url="https://github.com/marota/ExpertOp4Grid/", - download_url = 'https://github.com/marota/ExpertOp4Grid/archive/refs/tags/v0.3.2.post3.tar.gz', + download_url = 'https://github.com/marota/ExpertOp4Grid/archive/refs/tags/v0.3.2.post4.tar.gz', license='Mozilla Public License 2.0 (MPL 2.0)', packages=setuptools.find_packages(), extras_require=pkgs["extras"],