diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index e4be7f1a3..14f781249 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -157,8 +157,9 @@ const nodesDS = new vis.DataSet(RAW_NODES.map((n, i) => ({{
// graphs. `i` is the map index — physics still settles small graphs identically.
x: 30 * Math.sqrt(i) * Math.cos(i * 2.4),
y: 30 * Math.sqrt(i) * Math.sin(i * 2.4),
- _community: n.community, _community_name: n.community_name,
- _source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
+ community: n.community, community_name: n.community_name,
+ source_file: n.source_file, file_type: n.file_type, degree: n.degree,
+ member_count: n.member_count,
}})));
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
@@ -210,14 +211,22 @@ function showInfo(nodeId) {{
const color = nb ? nb.color.background : '#555';
return `${{esc(nb ? nb.label : nid)}}`;
}}).join('');
- document.getElementById('info-content').innerHTML = `
-
${{esc(n.label)}}
- Type: ${{esc(n._file_type || 'unknown')}}
- Community: ${{esc(n._community_name)}}
- Source: ${{esc(n._source_file || '-')}}
- Degree: ${{n._degree}}
- ${{neighborIds.length ? `Neighbors (${{neighborIds.length}})
${{neighborItems}}
` : ''}}
- `;
+ const isMetaNode = !n.file_type && !n.source_file;
+ let html = `${{esc(n.label)}}
`;
+ if (!isMetaNode) {{
+ html += `Type: ${{esc(n.file_type || 'unknown')}}
`;
+ }}
+ html += `Community: ${{esc(n.community_name || '')}}
`;
+ if (!isMetaNode) {{
+ html += `Source: ${{esc(n.source_file || '-')}}
`;
+ }} else if (n.member_count !== undefined && n.member_count !== null) {{
+ html += `Members: ${{esc(n.member_count)}}
`;
+ }}
+ html += `Degree: ${{n.degree}}
`;
+ if (neighborIds.length) {{
+ html += `Neighbors (${{neighborIds.length}})
${{neighborItems}}
`;
+ }}
+ document.getElementById('info-content').innerHTML = html;
}}
function focusNode(nodeId) {{
@@ -541,6 +550,8 @@ def to_html(
"file_type": data.get("file_type", ""),
"degree": deg,
}
+ if member_counts:
+ node["member_count"] = member_counts.get(cid, len(communities.get(cid, [])))
# Conditional learning fields — only present for annotated nodes, so
# un-annotated output keeps the exact pre-feature node dict shape.
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
diff --git a/tests/test_export.py b/tests/test_export.py
index 725f45843..d05bdbb28 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -1197,3 +1197,179 @@ def test_to_html_spiral_seed_uses_a_real_map_index():
"spiral seed references `i` but the node map has no index param "
"-> ReferenceError: i is not defined (#3699)"
)
+
+
+def test_to_html_canonical_node_schema_mapping(tmp_path):
+ """#3914: nodesDS and showInfo must use canonical un-prefixed field names
+ matching RAW_NODES and Graphify's node schema (file_type, source_file,
+ community_name, degree), eliminating the underscore-prefixed mapping."""
+ import networkx as nx
+ G = nx.Graph()
+ G.add_node("n_sample", label="SampleNode", file_type="code", source_file="src/sample.py")
+ out = tmp_path / "graph.html"
+ to_html(G, {0: ["n_sample"]}, str(out), community_labels={0: "Core"})
+ content = out.read_text(encoding="utf-8")
+
+ # RAW_NODES carries canonical un-prefixed fields
+ nodes = {n["id"]: n for n in _vis_nodes_from_html(content)}
+ assert nodes["n_sample"]["file_type"] == "code"
+ assert nodes["n_sample"]["source_file"] == "src/sample.py"
+ assert nodes["n_sample"]["community_name"] == "Core"
+ assert "degree" in nodes["n_sample"]
+
+ # nodesDS dataset mapping must preserve canonical field names
+ assert "file_type: n.file_type" in content
+ assert "source_file: n.source_file" in content
+ assert "community_name: n.community_name" in content
+ assert "degree: n.degree" in content
+
+ # nodesDS must not contain old underscore-prefixed mappings
+ assert "_file_type: n.file_type" not in content
+ assert "_source_file: n.source_file" not in content
+ assert "_community_name: n.community_name" not in content
+ assert "_degree: n.degree" not in content
+ assert "_community: n.community" not in content
+
+ # showInfo must consume canonical fields directly
+ assert "n.file_type" in content
+ assert "n.source_file" in content
+ assert "n.community_name" in content
+ assert "n.degree" in content
+ assert "n._file_type" not in content
+ assert "n._source_file" not in content
+ assert "n._community_name" not in content
+ assert "n._degree" not in content
+
+
+def _run_node_info_harness(html_content: str, target_node_id: str) -> str:
+ """Helper to execute graph.html's client script in Node.js and return #info-content innerHTML."""
+ import re
+ import shutil
+ import subprocess
+ node = shutil.which("node")
+ if node is None:
+ import pytest
+ pytest.skip("node not available")
+
+ m = re.search(r"\s*