From a37f2b7349090baa0ec81558e1f5373115c64593 Mon Sep 17 00:00:00 2001 From: anujkatiyar Date: Thu, 30 Jul 2026 15:51:01 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(export):=20opt-in=20Focus=20Lens=20for?= =?UTF-8?q?=20graph.html=20=E2=80=94=20read=20dense=20regions=20as=20a=20l?= =?UTF-8?q?ayered=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A movable lens for the interactive graph viewer: a small dotted capture region follows the cursor and selects the nodes under it (live positions, legend filtering respected); a larger display panel re-lays-out that selection as a layered tree (longest-path levels, wrapped rows, capped at the most-connected 30) that updates as the mouse moves. Double-click or 'h' holds the lens — hover a box for the longer label + source tooltip, click one to inspect it in the existing sidebar; Esc releases, then exits. Scope kept deliberately tight for review: - Off by default; toggled in-page ('f' / Lens button). No CLI surface, so generated skill artifacts are untouched (skillgen --check: 134 OK). - Emitted only for graphs with >=15 nodes and >=2 communities (one with >=2 members — suppresses the aggregated community meta-graph). Gated-off output is byte-identical to pre-feature HTML (test-enforced). - No new dependencies: vanilla JS on the existing vis.js canvas via the same afterDrawing hook the hyperedge hull uses. - No new XSS surface (#1838 discipline): all lens text renders via canvas fillText from the already-sanitized labels; the sole DOM sink is the HUD count via textContent; inspect clicks delegate to the audited showInfo(). Tests pin a sink denylist (innerHTML/outerHTML/insertAdjacentHTML/ document.write/srcdoc) plus hostile node- and community-label fixtures. Tests: 8 new (gate boundary at 14/15 nodes, single-community and meta-graph suppression, unlabeled-build emission, static-block equality, sink denylist, determinism). Full suite green; ruff clean; inline scripts verified with node --check. --- README.md | 2 +- graphify/exporters/html.py | 456 ++++++++++++++++++++++++++++++++++++- tests/test_export.py | 159 +++++++++++++ 3 files changed, 615 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a4f188f46..fdec56486 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ That's it. You get **three files**: ``` graphify-out/ -├── graph.html open in any browser — click nodes, filter, search +├── graph.html open in any browser — click nodes, filter, search, focus-lens dense regions ├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions └── graph.json the full graph — query it anytime without re-reading your files ``` diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py index 59c0e52e3..b116bf9dd 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -322,6 +322,444 @@ def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str: }}); """ +def _lens_markup() -> str: + """Toggle button + HUD + scoped styles for the Focus Lens. + + Emitted only inside the conditional lens block (not in _html_styles()) so a + graph without the lens renders byte-identically to the pre-feature output. + The HUD legend explains the two rectangles: dotted = capture area feeding + the tree, solid = the display panel it is drawn in. + """ + return """ + +
+
Focus lens
+
move the lens over the graph
+
dotted box — capture area: the tree reads these nodes
solid box — tree view of that region
+
double-click / h — hold to inspect · [ ] capture size · arrow keys nudge · f / esc exit
+
""" + +def _lens_script() -> str: + """Client-side Focus Lens: opt-in, additive, dependency-free. + + A small dotted capture region centered on the cursor selects the nodes under + it (live positions, legend filtering respected); a larger display panel + re-lays-out that selection as a layered tree (longest-path levels, wrapped + rows, capped at the most-connected 30) that updates as the mouse moves. + Double-click or 'h' holds the lens: hover a box for the longer label + + source tooltip, click one to inspect it in the sidebar; Esc releases, then + exits. The layout is cached on the selection key, so gliding re-layouts + only when the captured set changes. + + All lens text is drawn with canvas fillText (an inert sink) from the same + sanitize_label'd labels vis renders; the HUD uses textContent and inspect + clicks delegate to the audited showInfo() — no new XSS surface (#1838). + """ + return """""" + def to_html( G: nx.Graph, communities: dict[int, list[str]], @@ -522,6 +960,22 @@ 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" + # Opt-in Focus Lens: a movable capture region whose contents are re-laid-out + # as a layered tree in a display panel. Emitted only for graphs large enough + # to benefit (>=15 nodes) with >=2 communities, at least one of which has >=2 + # members — the last clause suppresses it in the aggregated community + # meta-graph, where every "community" is a single super-node. Gates on + # `communities` (not community_labels) so unlabeled builds keep the lens. + # When the gate is false, lens_block is "" and the output is byte-identical + # to the pre-feature HTML. + lens_block = "" + if ( + G.number_of_nodes() >= 15 + and len(communities) >= 2 + and any(len(m) >= 2 for m in communities.values()) + ): + lens_block = "\n" + _lens_markup() + "\n" + _lens_script() + html = f""" @@ -553,7 +1007,7 @@ def _js_safe(obj) -> str:
{stats}
{_html_script(nodes_json, edges_json, legend_json)} -{_hyperedge_script(hyperedges_json)} +{_hyperedge_script(hyperedges_json)}{lens_block} """ diff --git a/tests/test_export.py b/tests/test_export.py index 7b55780ea..c3336d85c 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -831,3 +831,162 @@ def test_existing_graph_node_count(tmp_path): assert existing_graph_node_count(p) is MALFORMED_GRAPH # structurally wrong -> fail closed p.write_text('{"nodes": [{"id": "a"}, {"id": "b"}], "links": []}', encoding="utf-8") assert existing_graph_node_count(p) == 2 # valid + + +# --- Focus Lens (opt-in movable capture region rendered as a layered tree) ---- + +def _lens_region(content: str) -> str: + """The emitted lens markup+script region (from the toggle button onward), or ''.""" + i = content.find('