mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 00:19:33 +00:00
feat(export): show truncated sidebar names and legend node counts on hover in graph.html
The 280px sidebar truncates community names in the legend, search results and Node Info neighbor links with a CSS ellipsis and gives no way to read the full text, and the legend count has no label. On graphify's own graph 126 of 283 legend labels (45%) are truncated. Add one shared tooltip that reuses vis-network's .vis-tooltip style and 100ms tooltipDelay: - Names show it only when the text is truncated (scrollWidth > clientWidth). - The legend count always shows it, as "N nodes" / "1 node". - Text is set with textContent only, keeping the #1838 / #3686 escaping invariant. - One delegated listener covers rows rebuilt later. - No title attributes are added, so the native tooltip does not stack. Node-driven regression tests cover truncated and non-truncated rows, the count wording, literal rendering of & " ' < >, and single escaping of the legend label. Fixes #4286 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1d1e03b4c8
commit
def404e576
2 files changed
+146
No files matched your search
@@ -248,6 +248,46 @@ document.addEventListener('click', e => {{
|
||||
if (el && el.dataset.nid !== undefined) focusNode(el.dataset.nid);
|
||||
}});
|
||||
|
||||
// Sidebar tooltips reuse vis-network's .vis-tooltip style and 100ms tooltipDelay.
|
||||
// Names get one only when CSS ellipsis truncates them (the full text is still in
|
||||
// the DOM); counts always get one, to say what the number is. Text is set with
|
||||
// textContent only (labels are untrusted). Delegated on document so rows rebuilt
|
||||
// later (search results, showInfo()) are covered.
|
||||
const sidebarTip = document.createElement('div');
|
||||
sidebarTip.className = 'vis-tooltip';
|
||||
sidebarTip.style.cssText = 'white-space: normal; max-width: 360px; overflow-wrap: break-word;';
|
||||
document.body.appendChild(sidebarTip);
|
||||
let sidebarTipTimer = null;
|
||||
let sidebarTipEl = null;
|
||||
function hideSidebarTip() {{
|
||||
clearTimeout(sidebarTipTimer);
|
||||
sidebarTip.style.visibility = 'hidden';
|
||||
sidebarTipEl = null;
|
||||
}}
|
||||
document.addEventListener('mouseover', e => {{
|
||||
const el = e.target.closest && e.target.closest('.legend-label, .legend-count, .search-item, .neighbor-link');
|
||||
if (!el || el === sidebarTipEl) return;
|
||||
hideSidebarTip();
|
||||
const isCount = el.classList.contains('legend-count');
|
||||
if (!isCount && el.scrollWidth <= el.clientWidth) return;
|
||||
const text = isCount ? `${{el.textContent}} ${{el.textContent === '1' ? 'node' : 'nodes'}}` : el.textContent;
|
||||
sidebarTipEl = el;
|
||||
const x = e.clientX, y = e.clientY;
|
||||
sidebarTipTimer = setTimeout(() => {{
|
||||
sidebarTip.textContent = text;
|
||||
sidebarTip.style.left = sidebarTip.style.top = '0px'; // measure at full max-width
|
||||
sidebarTip.style.visibility = 'visible';
|
||||
const w = sidebarTip.offsetWidth, h = sidebarTip.offsetHeight;
|
||||
sidebarTip.style.left = Math.max(4, Math.min(x + 12, window.innerWidth - w - 4)) + 'px';
|
||||
sidebarTip.style.top = (y + 16 + h > window.innerHeight ? Math.max(4, y - h - 8) : y + 16) + 'px';
|
||||
}}, 100);
|
||||
}});
|
||||
document.addEventListener('mouseout', e => {{
|
||||
if (sidebarTipEl && !sidebarTipEl.contains(e.relatedTarget)) hideSidebarTip();
|
||||
}});
|
||||
// A click rebuilds or hides the row (showInfo(), search pick) without a mouseout.
|
||||
document.addEventListener('mousedown', hideSidebarTip);
|
||||
|
||||
// Track hovered node — hover detection is more reliable than click params
|
||||
let hoveredNodeId = null;
|
||||
network.on('hoverNode', params => {{
|
||||
|
||||
@@ -1278,6 +1278,7 @@ const mockElem = () => ({{
|
||||
}});
|
||||
|
||||
const doc = {{
|
||||
body: mockElem(),
|
||||
elements: {{
|
||||
'info-content': mockElem(),
|
||||
'graph': mockElem(),
|
||||
@@ -1385,3 +1386,108 @@ def test_to_html_aggregated_community_nodes_runtime(tmp_path):
|
||||
assert '<div class="field">Degree: 1</div>' in info_html
|
||||
assert "Type: unknown" not in info_html
|
||||
assert "Source: -" not in info_html
|
||||
|
||||
|
||||
def _run_sidebar_harness(html_content: str, epilogue: str):
|
||||
"""Run graph.html's client script in Node.js with DOM mocks that record
|
||||
children and listeners, then run `epilogue` and return what it prints as JSON."""
|
||||
import shutil
|
||||
import subprocess
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
import pytest
|
||||
pytest.skip("node not available")
|
||||
m = re.search(r"<script>(.*?)</script>\s*<script>", html_content, re.DOTALL)
|
||||
assert m, "vis script block not found in html"
|
||||
harness = """
|
||||
const mockElem = () => ({ innerHTML: '', style: {}, classList: { add() {}, remove() {} }, children: [], listeners: {},
|
||||
appendChild(c) { this.children.push(c); }, addEventListener(t, f) { this.listeners[t] = f; }, prepend() {} });
|
||||
const els = {};
|
||||
const document = { body: mockElem(), listeners: {}, getElementById: id => els[id] || (els[id] = mockElem()),
|
||||
createElement: mockElem, addEventListener(t, f) { this.listeners[t] = f; }, querySelectorAll: () => [] };
|
||||
const window = { innerWidth: 1280, innerHeight: 800 };
|
||||
class MockDataSet { constructor(items) { this.map = new Map(items.map(it => [it.id, it])); }
|
||||
get(id) { return this.map.get(id); } update() {} }
|
||||
const vis = { DataSet: MockDataSet, Network: class { once() {} on() {} focus() {} selectNodes() {}
|
||||
getConnectedNodes(id) { return RAW_NODES.map(n => n.id).filter(x => x !== id); } } };
|
||||
""" + m.group(1) + "\n" + epilogue
|
||||
proc = subprocess.run([node, "-e", harness], capture_output=True, text=True, encoding="utf-8", timeout=30)
|
||||
assert proc.returncode == 0, f"Node script failed ({proc.returncode}): {proc.stderr}"
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _parse_html_fragment(fragment: str) -> list:
|
||||
"""Return (tag, attrs dict, text) for each start tag; HTMLParser decodes entities."""
|
||||
from html.parser import HTMLParser
|
||||
tags: list = []
|
||||
open_: list = []
|
||||
|
||||
class _P(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tags.append([tag, dict(attrs), ""])
|
||||
open_.append(tags[-1])
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if open_:
|
||||
open_.pop()
|
||||
|
||||
def handle_data(self, data):
|
||||
if open_:
|
||||
open_[-1][2] += data
|
||||
|
||||
_P(convert_charrefs=True).feed(fragment)
|
||||
return tags
|
||||
|
||||
|
||||
def test_to_html_legend_label_escaped_once_without_title(tmp_path):
|
||||
"""The legend label is HTML-escaped once for the innerHTML sink, so its DOM
|
||||
text (what the sidebar tooltip shows) is the raw label; no title attributes."""
|
||||
import networkx as nx
|
||||
G = nx.Graph()
|
||||
for nid in ("n1", "n2", "n3", "n4"):
|
||||
G.add_node(nid, label=nid, file_type="code", source_file=f"{nid}.py")
|
||||
label = "Team \"A\" & <B> 'c'"
|
||||
out = tmp_path / "graph.html"
|
||||
to_html(G, {0: ["n1", "n2", "n3"], 1: ["n4"]}, str(out), community_labels={0: label, 1: "Solo"})
|
||||
items = _run_sidebar_harness(out.read_text(encoding="utf-8"),
|
||||
"console.log(JSON.stringify(legendEl.children.map(c => c.innerHTML)));")
|
||||
assert "&amp;" not in items[0]
|
||||
tags = [t for frag in items for t in _parse_html_fragment(frag)]
|
||||
assert {t[0] for t in tags} == {"div", "span"}
|
||||
assert not any("title" in t[1] for t in tags)
|
||||
assert [t[2] for t in tags if t[1].get("class") == "legend-label"] == [label, "Solo"]
|
||||
assert [t[2] for t in tags if t[1].get("class") == "legend-count"] == ["3", "1"]
|
||||
|
||||
|
||||
def test_to_html_sidebar_tooltip_shows_truncated_text_and_node_counts(tmp_path):
|
||||
"""One shared .vis-tooltip: names only when truncated, counts always as
|
||||
'N node(s)'; text set via textContent so & " ' < > show literally."""
|
||||
import networkx as nx
|
||||
G = nx.Graph()
|
||||
G.add_node("a", label="a", file_type="code", source_file="a.py")
|
||||
label = "Team \"A\" & <b>B</b> 'c' <img src=x onerror=alert(1)>"
|
||||
out = tmp_path / "graph.html"
|
||||
to_html(G, {0: ["a"]}, str(out), community_labels={0: "Core"})
|
||||
content = out.read_text(encoding="utf-8")
|
||||
assert "sidebarTip.innerHTML" not in content
|
||||
assert "max-width: 360px; overflow-wrap: break-word" in content
|
||||
result = _run_sidebar_harness(content, """
|
||||
globalThis.setTimeout = f => f();
|
||||
const tips = document.body.children.filter(c => c.className === 'vis-tooltip');
|
||||
const hover = (cls, text, truncated) => {
|
||||
const row = { textContent: text, scrollWidth: truncated ? 300 : 100, clientWidth: 100,
|
||||
classList: { contains: c => c === cls }, contains: o => o === row,
|
||||
closest: sel => (sel.split(', ').includes('.' + cls) ? row : null) };
|
||||
document.listeners.mouseover({ target: row, clientX: 100, clientY: 100 });
|
||||
const shown = tips[0].style.visibility === 'visible' ? tips[0].textContent : null;
|
||||
document.listeners.mouseout({ target: row, relatedTarget: null });
|
||||
return [shown, tips[0].style.visibility, tips[0].innerHTML];
|
||||
};
|
||||
const L = %s;
|
||||
console.log(JSON.stringify({ count: tips.length, hovers: [
|
||||
hover('legend-label', L, true), hover('search-item', L, true), hover('neighbor-link', L, true),
|
||||
hover('legend-label', 'short', false), hover('search-item', 'short', false), hover('neighbor-link', 'short', false),
|
||||
hover('legend-count', '3', false), hover('legend-count', '1', false)] }));""" % json.dumps(label))
|
||||
assert result["count"] == 1
|
||||
assert result["hovers"] == [[label, "hidden", ""]] * 3 + [[None, "hidden", ""]] * 3 + [
|
||||
["3 nodes", "hidden", ""], ["1 node", "hidden", ""]]
|
||||
Reference in new issue
Block a user