From 26d96c52a93081b814cd81ba92ee4c3c47ff615f Mon Sep 17 00:00:00 2001 From: Sfahad7 Date: Mon, 27 Jul 2026 17:51:41 +0530 Subject: [PATCH 1/2] fix(export): colour and filter graph.html edges by relation type Every edge was drawn the same, so calls drowned in contains/imports. Add a Connection Types sidebar (off-by-default colouring, per-relation checkboxes, localStorage + GRAPHIFY_EDGE_TYPES / edge_types=). Also scope toggleAllCommunities to #legend so a second panel cannot steal its state. Fixes #2088 --- graphify/exporters/html.py | 199 +++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 7 deletions(-) diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 59c0e52e3..63a6d85e8 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -27,6 +27,19 @@ def _viz_node_limit() -> int: except ValueError: return MAX_NODES_FOR_VIZ + +def _edge_types_default() -> bool: + """Whether graph.html starts with relation coloring on (GRAPHIFY_EDGE_TYPES). + + Off by default so a generated report costs nothing until the reader opts in + (#2088). Mirrors GRAPHIFY_VIZ_NODE_LIMIT style: unset/empty => False. + """ + import os + raw = os.environ.get("GRAPHIFY_EDGE_TYPES") + if raw is None or not raw.strip(): + return False + return raw.strip().lower() not in ("0", "false", "no", "off") + def _html_styles() -> str: return """""" def _hyperedge_script(hyperedges_json: str) -> str: @@ -109,17 +137,47 @@ def _hyperedge_script(hyperedges_json: str) -> str: }}); """ -def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: +def _html_script( + nodes_json: str, + edges_json: str, + legend_json: str, + *, + show_edge_types: bool = False, + edge_types_color_default: bool = False, +) -> str: + show_edge_js = "true" if show_edge_types else "false" + edge_color_js = "true" if edge_types_color_default else "false" return f"""""" def to_html( @@ -330,15 +484,23 @@ def to_html( member_counts: dict[int, int] | None = None, node_limit: int | None = None, learning_overlay: dict | None = None, + edge_types: bool | None = None, ) -> None: """Generate an interactive vis.js HTML visualization of the graph. Features: node size by degree, click-to-inspect panel, search box, community filter, physics clustering by community, confidence-styled edges. + Optional Connection Types panel colours/filters edges by relation (#2088). Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ. If member_counts is provided (aggregated community view), node sizes are - based on community member counts rather than graph degree. + based on community member counts rather than graph degree. The Connection + Types panel is skipped there — meta-edges are weight labels, not relations. + + ``edge_types`` / ``GRAPHIFY_EDGE_TYPES`` set whether relation colouring + starts on; the panel itself is always present on non-aggregated graphs. + Readers can flip colouring in the sidebar; the choice persists in + ``localStorage``. If node_limit is set and the graph exceeds it, automatically builds an aggregated community-level meta-graph instead of raising ValueError. @@ -492,11 +654,12 @@ def to_html( relation = data.get("relation", "") true_src = data.get("_src", u) true_tgt = data.get("_tgt", v) + rel_label = sanitize_label(str(relation or "")) or "unknown" vis_edges.append({ "from": true_src, "to": true_tgt, - "label": relation, - "title": _html.escape(f"{relation} [{confidence}]"), + "label": rel_label, + "title": _html.escape(f"{rel_label} [{confidence}]"), "dashes": confidence != "EXTRACTED", "width": 2 if confidence == "EXTRACTED" else 1, "color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35}, @@ -522,6 +685,21 @@ def _js_safe(obj) -> str: title = _html.escape(sanitize_label(str(output_path))) stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities" + # Aggregated community meta-graphs use weight strings as "relation" — a + # Connection Types row per distinct weight is noise (#2088). + show_edge_types = member_counts is None + color_default = _edge_types_default() if edge_types is None else bool(edge_types) + edge_types_panel = "" + if show_edge_types: + edge_types_panel = """ +
+

Connection Types

+
+ +
+
+
""" + html = f""" @@ -549,10 +727,17 @@ def _js_safe(obj) -> str:
+ {edge_types_panel}
{stats}
-{_html_script(nodes_json, edges_json, legend_json)} +{_html_script( + nodes_json, + edges_json, + legend_json, + show_edge_types=show_edge_types, + edge_types_color_default=color_default, + )} {_hyperedge_script(hyperedges_json)} """ From 0b4c21cec38f668463cc9dea9d8e49f305e21791 Mon Sep 17 00:00:00 2001 From: Sfahad7 Date: Mon, 27 Jul 2026 17:57:28 +0530 Subject: [PATCH 2/2] test(export): cover Connection Types panel defaults for graph.html Assert the panel ships on normal graphs, stays off for aggregated views, and that edge_types= / GRAPHIFY_EDGE_TYPES set EDGE_TYPES_COLOR_DEFAULT. --- tests/test_export.py | 47 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_export.py b/tests/test_export.py index 28a4707a7..e580dd6ff 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -247,6 +247,53 @@ def test_to_html_member_counts_accepted(): assert out.exists() +def test_to_html_connection_types_panel_on_normal_graph(): + """#2088: non-aggregated graph.html ships the Connection Types panel, + with colouring off by default so existing reports look unchanged.""" + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out)) + html = out.read_text(encoding="utf-8") + assert 'id="edge-types-wrap"' in html + assert 'id="edge-types-enable"' in html + assert "SHOW_EDGE_TYPES = true" in html + assert "EDGE_TYPES_COLOR_DEFAULT = false" in html + assert "#legend .legend-item" in html # community select-all stays scoped + + +def test_to_html_connection_types_panel_skipped_when_aggregated(): + """#2088: aggregated community view has weight-label 'relations' — no panel.""" + G = make_graph() + communities = cluster(G) + member_counts = {cid: len(members) for cid, members in communities.items()} + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out), member_counts=member_counts) + html = out.read_text(encoding="utf-8") + assert 'id="edge-types-wrap"' not in html + assert "SHOW_EDGE_TYPES = false" in html + + +def test_to_html_edge_types_kwarg_and_env_set_color_default(monkeypatch): + """#2088: edge_types= / GRAPHIFY_EDGE_TYPES flip the initial colour flag.""" + G = make_graph() + communities = cluster(G) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "graph.html" + to_html(G, communities, str(out), edge_types=True) + assert "EDGE_TYPES_COLOR_DEFAULT = true" in out.read_text(encoding="utf-8") + + monkeypatch.setenv("GRAPHIFY_EDGE_TYPES", "1") + to_html(G, communities, str(out)) + assert "EDGE_TYPES_COLOR_DEFAULT = true" in out.read_text(encoding="utf-8") + + monkeypatch.delenv("GRAPHIFY_EDGE_TYPES", raising=False) + to_html(G, communities, str(out), edge_types=False) + assert "EDGE_TYPES_COLOR_DEFAULT = false" in out.read_text(encoding="utf-8") + + def _vis_nodes_from_html(content: str) -> list: """Extract the RAW_NODES JSON array embedded in the generated HTML.""" m = re.search(r"const RAW_NODES = (\[.*?\]);", content, re.DOTALL)