Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)

## Unreleased

- Feature: `--viz 3d` renders `graph.html` as a navigable WebGL view (three.js + d3-force-3d via a version-pinned, sha384-SRI `3d-force-graph` bundle, same supply-chain posture as the existing vis-network tag). Available on `/graphify`, `cluster-only`/`label` and `graphify export html`, or via `GRAPHIFY_VIZ_MODE`. The vis.js canvas renderer tops out around a few thousand nodes and offers pan/zoom as its only navigation; WebGL raises that ceiling by roughly an order of magnitude. The layout runs its simulation before the first frame and the camera is framed once at startup, so the graph opens already spread out and never moves on its own.
- Feature: 3D navigation — search, click-to-inspect with neighbour links, community filters, a single "Show" control that isolates 1-6 hops around the selection, an opt-in name overlay (hover names the node either way), and `/` `F` `L` `R` `Space` `Esc` shortcuts. Learning-overlay status is painted onto the node rather than ringing it, and hyperedges render as clickable hub nodes instead of shaded hulls.
- Refactor: `to_html()` now builds a renderer-agnostic view model (nodes, edges, legend, hyperedges) that both the 2D and 3D renderers consume, so the two views always describe the same graph. The default stays 2D and its output is byte-identical to before, guarded by a test.

## 0.9.28 (2026-07-27)

- Fix: incremental extraction no longer drops cross-file edges whose target file wasn't in the batch (#2211, #2213). Python relative imports and markdown reference links emitted absolute-path-derived target ids without the `target_file` stamp the incremental canonicalization needs, so a re-extracted file's imports/references dangled or vanished; both now stamp the resolved target and canonicalize to the root-relative node.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`--viz 3d` for the WebGL 3D view)
├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions
└── graph.json the full graph — query it anytime without re-reading your files
```
Expand Down Expand Up @@ -371,6 +371,7 @@ You can also set `GRAPHIFY_GOOGLE_WORKSPACE=1`. Graphify exports shortcuts into
/graphify . --cluster-only --resolution 1.5 # more granular communities
/graphify . --cluster-only --exclude-hubs 99 # suppress utility super-hubs from god-node rankings
/graphify . --no-viz # skip the HTML, just the report + JSON
/graphify . --viz 3d # render graph.html as a navigable WebGL 3D view
/graphify . --wiki # build a markdown wiki from the graph
graphify export callflow-html # Mermaid architecture/call-flow HTML (auto-regenerates on every git commit if hook is installed)

Expand Down
33 changes: 30 additions & 3 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
from pathlib import Path

# Accepted `--viz` values. Kept as a literal so `graphify --help` and the arg
# validation below don't drag the exporter (and networkx) into every startup;
# the tuple is asserted against exporters.html.VIZ_MODES in the test suite.
_VIZ_MODES = ("2d", "3d")


_SEARCH_NUDGE = json.dumps({
"hookSpecificOutput": {
Expand Down Expand Up @@ -1559,6 +1564,7 @@ def dispatch_command(cmd: str) -> None:
label_model = _model_arg.split("=", 1)[1] if _model_arg else None
_min_cs_arg = next((a for a in sys.argv if a.startswith("--min-community-size=")), None)
min_community_size = int(_min_cs_arg.split("=")[1]) if _min_cs_arg else 3
viz_mode: str | None = None
args = sys.argv[2:]
watch_path: Path | None = None
graph_override: Path | None = None
Expand Down Expand Up @@ -1595,6 +1601,13 @@ def dispatch_command(cmd: str) -> None:
label_batch_size = int(args[i_arg + 1]); i_arg += 2
elif a.startswith("--batch-size="):
label_batch_size = int(a.split("=", 1)[1]); i_arg += 1
# Must precede the `--`-prefixed catch-all: without an explicit arm
# the flag is skipped and its value falls through to the positional
# path slot, so `--viz 3d` would try to cluster a directory "3d".
elif a == "--viz" and i_arg + 1 < len(args):
viz_mode = args[i_arg + 1]; i_arg += 2
elif a.startswith("--viz="):
viz_mode = a.split("=", 1)[1]; i_arg += 1
elif a in ("--no-viz", "--missing-only") or a.startswith("--min-community-size="):
i_arg += 1
elif a.startswith("--"):
Expand All @@ -1605,6 +1618,10 @@ def dispatch_command(cmd: str) -> None:
i_arg += 1
if watch_path is None:
watch_path = Path(".")
if viz_mode is not None and viz_mode.strip().lower() not in _VIZ_MODES:
print(f"error: --viz must be one of {', '.join(_VIZ_MODES)} (got {viz_mode!r})",
file=sys.stderr)
sys.exit(1)
graph_json = graph_override if graph_override is not None else watch_path / _GRAPHIFY_OUT / "graph.json"
if not graph_json.exists():
print(
Expand Down Expand Up @@ -1857,7 +1874,7 @@ def dispatch_command(cmd: str) -> None:
# path so an oversized graph still renders a usable graph.html.
_node_limit = 5000 if _over_cap else None
to_html(G, communities, str(html_target), community_labels=labels or None,
node_limit=_node_limit)
node_limit=_node_limit, mode=viz_mode)
stages.mark("export"); stages.total()
print(f"Done - {len(communities)} communities. GRAPH_REPORT.md, graph.json and graph.html updated.")
except ValueError as viz_err:
Expand Down Expand Up @@ -2163,7 +2180,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"):
print("Usage: graphify export <format>", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr)
print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz] [--viz 2d|3d]", file=sys.stderr)
print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr)
print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr)
print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr)
Expand Down Expand Up @@ -2194,6 +2211,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json"
node_limit = 5000
no_viz = False
export_viz_mode: str | None = None
obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian"
# Shared push-connection settings for the graph-database sinks (neo4j,
# falkordb), parsed from the generic --push/--user/--password flags below.
Expand Down Expand Up @@ -2254,6 +2272,10 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
node_limit = int(args[i + 1]); i += 2
elif a == "--no-viz":
no_viz = True; i += 1
elif a == "--viz" and i + 1 < len(args):
export_viz_mode = args[i + 1]; i += 2
elif a.startswith("--viz="):
export_viz_mode = a.split("=", 1)[1]; i += 1
elif a == "--dir" and i + 1 < len(args):
obsidian_dir = Path(args[i + 1]); i += 2
elif a == "--push" and i + 1 < len(args):
Expand Down Expand Up @@ -2383,6 +2405,10 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":

if subcmd == "html":
from graphify.export import to_html as _to_html
if export_viz_mode is not None and export_viz_mode.strip().lower() not in _VIZ_MODES:
print(f"error: --viz must be one of {', '.join(_VIZ_MODES)} (got {export_viz_mode!r})",
file=sys.stderr)
sys.exit(1)
if no_viz:
html_target = out_dir / "graph.html"
if html_target.exists():
Expand All @@ -2393,7 +2419,8 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph":
# path so the oversized graph still renders a usable artifact.
_effective_node_limit = 5000 if _over_cap else node_limit
_to_html(G, communities, str(out_dir / "graph.html"),
community_labels=labels or None, node_limit=_effective_node_limit)
community_labels=labels or None, node_limit=_effective_node_limit,
mode=export_viz_mode)
if G.number_of_nodes() <= _effective_node_limit:
print(f"graph.html written - open in any browser, no server needed")
if _over_cap:
Expand Down
76 changes: 62 additions & 14 deletions graphify/exporters/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@

MAX_NODES_FOR_VIZ = 5_000

#: Renderers `to_html` can dispatch to. "2d" is the long-standing vis.js canvas
#: view; "3d" is the WebGL view in graphify.exporters.html3d.
VIZ_MODES = ("2d", "3d")

def _resolve_viz_mode(mode: str | None) -> str:
"""Pick the renderer: explicit argument > GRAPHIFY_VIZ_MODE > "2d".

Defaults to 2d so existing callers keep their byte-identical output; an
unknown value falls back to 2d rather than raising, matching how
_viz_node_limit() treats a malformed env var.
"""
import os
raw = mode if mode is not None else os.environ.get("GRAPHIFY_VIZ_MODE", "")
candidate = (raw or "").strip().lower()
return candidate if candidate in VIZ_MODES else "2d"

def _viz_node_limit() -> int:
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.

Expand Down Expand Up @@ -330,19 +346,25 @@ def to_html(
member_counts: dict[int, int] | None = None,
node_limit: int | None = None,
learning_overlay: dict | None = None,
mode: str | None = None,
) -> None:
"""Generate an interactive vis.js HTML visualization of the graph.
"""Generate an interactive 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.
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.

`mode` selects the renderer — "2d" (vis.js canvas, the default) or "3d"
(WebGL via 3d-force-graph). Both are fed the identical node/edge/legend
model built below, so the two views always describe the same graph.

If member_counts is provided (aggregated community view), node sizes are
based on community member counts rather than graph degree.

If node_limit is set and the graph exceeds it, automatically builds an
aggregated community-level meta-graph instead of raising ValueError.
"""
mode = _resolve_viz_mode(mode)
limit = node_limit if node_limit is not None else _viz_node_limit()
if G.number_of_nodes() > limit:
if node_limit is not None:
Expand Down Expand Up @@ -392,7 +414,7 @@ def to_html(
})
meta.graph["hyperedges"] = remapped
to_html(meta, meta_communities, output_path,
community_labels=community_labels, member_counts=mc)
community_labels=community_labels, member_counts=mc, mode=mode)
print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
print("Tip: run with --obsidian for full node-level detail.")
return
Expand Down Expand Up @@ -511,18 +533,46 @@ def to_html(
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})

# Escape </script> sequences so embedded JSON cannot break out of the script tag
def _js_safe(obj) -> str:
return json.dumps(obj).replace("</", "<\\/")
# The renderer-agnostic view model. Both the 2d and the 3d renderer consume
# exactly this dict, so a node styled one way in vis.js is the same node,
# with the same fields, in the WebGL view.
model = {
"nodes": vis_nodes,
"edges": vis_edges,
"legend": legend_data,
"hyperedges": getattr(G, "graph", {}).get("hyperedges", []),
"title": _html.escape(sanitize_label(str(output_path))),
"stats": f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities",
}

if mode == "3d":
from graphify.exporters.html3d import render as _render_force3d
html = _render_force3d(model)
else:
html = _render_visjs(model)

Path(output_path).write_text(html, encoding="utf-8") # nosec

nodes_json = _js_safe(vis_nodes)
edges_json = _js_safe(vis_edges)
legend_json = _js_safe(legend_data)
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
title = _html.escape(sanitize_label(str(output_path)))
stats = f"{G.number_of_nodes()} nodes &middot; {G.number_of_edges()} edges &middot; {len(communities)} communities"

html = f"""<!DOCTYPE html>
def js_safe(obj) -> str:
"""Serialize `obj` for embedding in a <script> block.

Escaping `</` keeps a node label containing a literal `</script>` from
closing the tag early and turning graph data into markup.
"""
return json.dumps(obj).replace("</", "<\\/")


def _render_visjs(model: dict) -> str:
"""Render the view model as the vis.js 2D canvas viewer."""
nodes_json = js_safe(model["nodes"])
edges_json = js_safe(model["edges"])
legend_json = js_safe(model["legend"])
hyperedges_json = js_safe(model["hyperedges"])
title = model["title"]
stats = model["stats"]

return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
Expand Down Expand Up @@ -556,5 +606,3 @@ def _js_safe(obj) -> str:
{_hyperedge_script(hyperedges_json)}
</body>
</html>"""

Path(output_path).write_text(html, encoding="utf-8") # nosec
Loading