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..929249974 100644 --- a/graphify/exporters/html.py +++ b/graphify/exporters/html.py @@ -322,6 +322,452 @@ 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 +968,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 +1015,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('