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:
Click a node to highlight its neighbourhood. Wheel to zoom, drag to pan. Esc clears selection.
Search
-
+
Layers
@@ -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('' + k + ': ' + a.value);
}
}
return out.join(' ');
}
+ // 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 = '' + escapeHtml(disp) + '';
+ if (id && id !== disp) head += ' id: ' + 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 = '' + escapeHtml(name) + ' ' + fmtAttrs('attr', node)
+ info.innerHTML = nodeHeaderHtml(node) + ' ' + fmtAttrs('attr', node, ['label'])
+ '
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, '' + escapeHtml(g.getAttribute('data-name') || '') + ' ' + fmtAttrs('attr', g));
+ showTooltip(e, nodeHeaderHtml(g) + ' ' + fmtAttrs('attr', g, ['label']));
} else {
const lbl = g.querySelector('text'); const name = g.getAttribute('data-attr-name') || '';
showTooltip(e, '' + escapeHtml(g.getAttribute('data-source')) + ' → ' + escapeHtml(g.getAttribute('data-target')) + ''
@@ -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 (```` /
+ ``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 "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"],