Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<title>` /
`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
Expand Down
44 changes: 38 additions & 6 deletions alphaDeesp/core/interactive_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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'); }
Expand All @@ -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>'
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 64 additions & 0 deletions alphaDeesp/tests/test_interactive_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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</title>" 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."""
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"],
Expand Down
Loading