1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/src/docforge/visualization.py

1599 lines
64 KiB
Python
Raw Normal View History

2026-07-24 16:01:03 -04:00
"""Project-bound, read-only graph visualization over a validated DocForge index."""
from __future__ import annotations
import atexit
import json
import secrets
import sqlite3
import threading
import urllib.parse
from collections.abc import Generator
from contextlib import contextmanager
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
2026-07-24 21:43:11 -04:00
from time import monotonic
2026-07-24 16:01:03 -04:00
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
2026-07-24 21:43:11 -04:00
VISUALIZATION_TEMPLATE = "graph-browser@5"
2026-07-24 16:01:03 -04:00
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
2026-07-24 21:43:11 -04:00
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
DEFAULT_LEASE_SECONDS = 180.0
LEASE_MONITOR_INTERVAL_SECONDS = 1.0
2026-07-24 16:01:03 -04:00
class _VisualizationHttpServer(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = False
class VisualizationIndexSnapshot:
"""Fast read model pinned to one index file validated by ``ProjectIndex.check``."""
_IDENTITY_KEYS = (
"project_id",
"project_root_fingerprint",
"revision",
"source_hash",
"adapter",
"node_count",
"edge_count",
)
def __init__(self, index: ProjectIndex, checked: dict[str, object]) -> None:
self.path = index.path
self.title = index.project.descriptor.title
self.max_query_chars = index.project.descriptor.limits.max_query_chars
self.max_results = index.project.descriptor.limits.max_results
self.max_depth = index.project.descriptor.limits.max_traversal_depth
self.identity = {key: checked[key] for key in self._IDENTITY_KEYS}
self._stat = self._safe_stat()
def overview(self) -> dict[str, object]:
with self._connection() as connection:
return self._result(
title=self.title,
node_count=connection.execute("SELECT COUNT(*) FROM nodes").fetchone()[0],
edge_count=connection.execute("SELECT COUNT(*) FROM edges").fetchone()[0],
families=_facet_rows(connection, "nodes", "family"),
authorities=_facet_rows(connection, "nodes", "authority"),
statuses=_facet_rows(connection, "nodes", "status"),
relations=_facet_rows(connection, "edges", "relation"),
max_results=self.max_results,
snapshot=True,
)
def search(
self,
*,
query: str,
family: str | None,
limit: int,
) -> dict[str, object]:
bounded = self._bounded_limit(limit)
with self._connection() as connection:
if query:
if len(query) > self.max_query_chars:
raise DocForgeError(
"invalid_query", "Search query exceeds the configured limit"
)
terms = re_tokenize(query)
if not terms:
raise DocForgeError("invalid_query", "Search query contains no searchable text")
expression = " AND ".join(
f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms
)
family_clause = "AND nodes.family = ?" if family else ""
values: tuple[object, ...] = (
(expression, family, bounded) if family else (expression, bounded)
)
rows = connection.execute(
"""
SELECT nodes.*, bm25(node_fts) AS rank,
snippet(node_fts, 3, '[', ']', '', 18) AS snippet
FROM node_fts JOIN nodes USING(node_id)
WHERE node_fts MATCH ?
"""
+ family_clause
+ " ORDER BY rank, nodes.node_id LIMIT ?",
values,
).fetchall()
results = []
for row in rows:
item = _node_dict(row, include_content=False)
item.update({"rank": row["rank"], "snippet": row["snippet"]})
results.append(item)
else:
family_clause = "WHERE family = ?" if family else ""
values = (family, bounded) if family else (bounded,)
rows = connection.execute(
f"SELECT * FROM nodes {family_clause} ORDER BY node_id LIMIT ?",
values,
).fetchall()
results = [_node_dict(row, include_content=False) for row in rows]
return self._result(
query=query,
family=family,
count=len(results),
results=results,
snapshot=True,
)
def node(self, node_id: str, *, depth: int, limit: int) -> dict[str, object]:
if type(depth) is not int or depth < 1 or depth > self.max_depth:
raise DocForgeError("invalid_depth", "Traversal depth is outside the configured limit")
if type(limit) is not int or limit < 1 or limit > MAX_EDGE_LIMIT:
raise DocForgeError(
"invalid_limit",
"Visualization edge limit exceeds the fixed safety boundary",
maximum=MAX_EDGE_LIMIT,
)
with self._connection() as connection:
root_row = connection.execute(
"SELECT * FROM nodes WHERE node_id = ?", (node_id,)
).fetchone()
if root_row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
visited = {node_id}
frontier = {node_id}
selected: list[dict[str, str]] = []
selected_keys: set[tuple[str, str, str]] = set()
truncated = False
for _ in range(depth):
if not frontier or len(selected) >= limit:
break
placeholders = ",".join("?" for _ in frontier)
values = tuple(sorted(frontier))
rows = connection.execute(
"SELECT source_id, relation, target_id FROM edges "
f"WHERE source_id IN ({placeholders}) OR target_id IN ({placeholders}) "
"ORDER BY source_id, relation, target_id LIMIT ?",
(*values, *values, limit + 1),
).fetchall()
next_frontier: set[str] = set()
for row in rows:
key = (row["source_id"], row["relation"], row["target_id"])
if key in selected_keys:
continue
if len(selected) >= limit:
truncated = True
break
selected_keys.add(key)
edge = {
"source_id": row["source_id"],
"relation": row["relation"],
"target_id": row["target_id"],
}
selected.append(edge)
for candidate in (edge["source_id"], edge["target_id"]):
if candidate not in visited:
visited.add(candidate)
next_frontier.add(candidate)
frontier = next_frontier
placeholders = ",".join("?" for _ in visited)
node_rows = connection.execute(
f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id",
tuple(sorted(visited)),
).fetchall()
return self._result(
root=node_id,
depth=depth,
edge_limit=limit,
truncated=truncated,
node=_node_dict(root_row),
nodes=[_node_dict(row, include_content=False) for row in node_rows],
edges=selected,
snapshot=True,
)
def require_node(self, node_id: str) -> None:
with self._connection() as connection:
row = connection.execute("SELECT 1 FROM nodes WHERE node_id = ?", (node_id,)).fetchone()
if row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
def _bounded_limit(self, value: int) -> int:
if type(value) is not int or value < 1 or value > self.max_results:
raise DocForgeError("invalid_limit", "Result limit is outside the configured range")
return value
def _safe_stat(self) -> tuple[int, int, int, int]:
if (
self.path.is_symlink()
or not self.path.is_file()
or self.path.resolve(strict=True) != self.path
):
raise DocForgeError("missing_index", "Validated visualization index is unavailable")
stat = self.path.stat()
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
@contextmanager
def _connection(self) -> Generator[sqlite3.Connection, None, None]:
if self._safe_stat() != self._stat:
raise DocForgeError(
"visualization_stale",
"The validated index changed; invoke docforge_visualize again",
)
connection: sqlite3.Connection | None = None
try:
connection = sqlite3.connect(f"file:{self.path}?mode=ro", uri=True)
connection.row_factory = sqlite3.Row
application_id = connection.execute("PRAGMA application_id").fetchone()[0]
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if application_id != APPLICATION_ID or schema_version != INDEX_SCHEMA_VERSION:
raise DocForgeError(
"invalid_index", "Visualization index has an unsupported schema"
)
metadata = dict(connection.execute("SELECT key, value FROM metadata"))
if any(metadata.get(key) != str(value) for key, value in self.identity.items()):
raise DocForgeError(
"visualization_stale",
"The validated index identity changed; invoke docforge_visualize again",
)
yield connection
if self._safe_stat() != self._stat:
raise DocForgeError(
"visualization_stale",
"The validated index changed during the request",
)
except DocForgeError:
raise
except (json.JSONDecodeError, KeyError, sqlite3.Error, TypeError) as error:
raise DocForgeError(
"invalid_index", "Visualization index is corrupt or unreadable"
) from error
finally:
if connection is not None:
connection.close()
def _result(self, **payload: object) -> dict[str, object]:
return {
"status": "ok",
**self.identity,
**payload,
}
class VisualizationRunner:
"""Start one token-protected loopback reader for one immutable project binding."""
2026-07-24 21:43:11 -04:00
def __init__(
self,
index: ProjectIndex,
*,
initial_grace_seconds: float = DEFAULT_INITIAL_GRACE_SECONDS,
lease_seconds: float = DEFAULT_LEASE_SECONDS,
monitor_interval_seconds: float = LEASE_MONITOR_INTERVAL_SECONDS,
) -> None:
if initial_grace_seconds <= 0 or lease_seconds <= 0 or monitor_interval_seconds <= 0:
raise ValueError("Visualization lease durations must be positive")
2026-07-24 16:01:03 -04:00
self.index = index
2026-07-24 21:43:11 -04:00
self.initial_grace_seconds = initial_grace_seconds
self.lease_seconds = lease_seconds
self.monitor_interval_seconds = monitor_interval_seconds
2026-07-24 16:01:03 -04:00
self._lock = threading.Lock()
self._token = secrets.token_urlsafe(24)
self._server: _VisualizationHttpServer | None = None
self._thread: threading.Thread | None = None
2026-07-24 21:43:11 -04:00
self._lease_thread: threading.Thread | None = None
self._lease_stop = threading.Event()
self._lease_last_activity = monotonic()
self._lease_connected = False
2026-07-24 16:01:03 -04:00
self._atexit_registered = False
self._reader: VisualizationIndexSnapshot | None = None
def start(
self,
*,
node_id: str | None = None,
query: str | None = None,
depth: int = 1,
) -> dict[str, object]:
if node_id is not None and query is not None:
raise DocForgeError(
"invalid_visualization_target",
"Choose either one exact node ID or one search query",
)
maximum_depth = self.index.project.descriptor.limits.max_traversal_depth
if type(depth) is not int or depth < 1 or depth > maximum_depth:
raise DocForgeError(
"invalid_depth",
"Visualization depth is outside the configured traversal limit",
)
reader = VisualizationIndexSnapshot(self.index, self.index.check())
if node_id is not None:
reader.require_node(node_id)
elif query is not None:
reader.search(query=query, family=None, limit=1)
with self._lock:
self._reader = reader
if self._server is None:
runner = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
runner._handle_get(self)
def do_HEAD(self) -> None: # noqa: N802
runner._handle_get(self, include_body=False)
def do_POST(self) -> None: # noqa: N802
runner._respond_error(
self,
HTTPStatus.METHOD_NOT_ALLOWED,
"method_not_allowed",
"The visualization service is read-only",
)
def do_PUT(self) -> None: # noqa: N802
self.do_POST()
def do_PATCH(self) -> None: # noqa: N802
self.do_POST()
def do_DELETE(self) -> None: # noqa: N802
self.do_POST()
def log_message(self, _format: str, *_args: object) -> None:
return
self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler)
2026-07-24 21:43:11 -04:00
self._lease_last_activity = monotonic()
self._lease_connected = False
self._lease_stop.clear()
2026-07-24 16:01:03 -04:00
self._thread = threading.Thread(
target=self._server.serve_forever,
name="docforge-visualization",
2026-07-24 21:43:11 -04:00
daemon=False,
2026-07-24 16:01:03 -04:00
)
self._thread.start()
2026-07-24 21:43:11 -04:00
self._lease_thread = threading.Thread(
target=self._monitor_lease,
name="docforge-visualization-lease",
daemon=True,
)
self._lease_thread.start()
2026-07-24 16:01:03 -04:00
if not self._atexit_registered:
atexit.register(self.stop)
self._atexit_registered = True
server = self._server
assert server is not None
port = int(server.server_address[1])
parameters: dict[str, str] = {"depth": str(depth)}
if node_id is not None:
parameters["node"] = node_id
if query is not None:
parameters["q"] = query
query_string = urllib.parse.urlencode(parameters)
url = f"http://127.0.0.1:{port}/{self._token}/"
if query_string:
url = f"{url}?{query_string}"
return {
"state": "running",
"url": url,
"bind": "127.0.0.1",
"port": port,
"template": VISUALIZATION_TEMPLATE,
"read_only": True,
"project_bound": True,
2026-07-24 21:43:11 -04:00
"lifetime": {
"policy": "browser_lease",
"initial_grace_seconds": self.initial_grace_seconds,
"lease_seconds": self.lease_seconds,
},
2026-07-24 16:01:03 -04:00
"target": {
"node_id": node_id,
"query": query,
"depth": depth,
},
"snapshot": dict(reader.identity),
}
def stop(self) -> None:
with self._lock:
server = self._server
thread = self._thread
self._server = None
self._thread = None
2026-07-24 21:43:11 -04:00
self._lease_thread = None
2026-07-24 16:01:03 -04:00
self._reader = None
2026-07-24 21:43:11 -04:00
self._lease_stop.set()
2026-07-24 16:01:03 -04:00
if server is None:
return
server.shutdown()
server.server_close()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2)
2026-07-24 21:43:11 -04:00
def _touch_lease(self) -> None:
with self._lock:
if self._server is None:
return
self._lease_last_activity = monotonic()
self._lease_connected = True
def _monitor_lease(self) -> None:
while not self._lease_stop.wait(self.monitor_interval_seconds):
with self._lock:
if self._server is None:
return
timeout = (
self.lease_seconds if self._lease_connected else self.initial_grace_seconds
)
expired = monotonic() - self._lease_last_activity >= timeout
if expired:
self.stop()
return
2026-07-24 16:01:03 -04:00
def _handle_get(self, handler: BaseHTTPRequestHandler, *, include_body: bool = True) -> None:
parsed = urllib.parse.urlparse(handler.path)
prefix = f"/{self._token}"
if parsed.path not in {prefix, f"{prefix}/"} and not parsed.path.startswith(
f"{prefix}/api/"
):
self._respond_error(
handler,
HTTPStatus.NOT_FOUND,
"not_found",
"Not found",
include_body=include_body,
)
return
2026-07-24 21:43:11 -04:00
self._touch_lease()
2026-07-24 16:01:03 -04:00
if parsed.path in {prefix, f"{prefix}/"}:
self._respond(
handler,
_GRAPH_BROWSER_HTML.encode("utf-8"),
"text/html; charset=utf-8",
include_body=include_body,
)
return
try:
params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
reader = self._current_reader()
if parsed.path == f"{prefix}/api/overview":
payload = reader.overview()
2026-07-24 21:43:11 -04:00
elif parsed.path == f"{prefix}/api/heartbeat":
payload = reader._result(
viewer="alive",
lease_seconds=self.lease_seconds,
)
2026-07-24 16:01:03 -04:00
elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/node":
payload = self._node(reader, params)
else:
self._respond_error(
handler,
HTTPStatus.NOT_FOUND,
"not_found",
"Not found",
include_body=include_body,
)
return
except DocForgeError as error:
status = {
"missing_node": HTTPStatus.NOT_FOUND,
"stale_index": HTTPStatus.CONFLICT,
"source_changed": HTTPStatus.CONFLICT,
"stale_adapter_source": HTTPStatus.CONFLICT,
"visualization_stale": HTTPStatus.CONFLICT,
"invalid_query": HTTPStatus.BAD_REQUEST,
"invalid_depth": HTTPStatus.BAD_REQUEST,
"invalid_limit": HTTPStatus.BAD_REQUEST,
}.get(error.code, HTTPStatus.SERVICE_UNAVAILABLE)
self._respond_json(
handler,
{"status": "error", "error": error.as_dict()},
status=status,
include_body=include_body,
)
return
except (TypeError, ValueError):
self._respond_error(
handler,
HTTPStatus.BAD_REQUEST,
"invalid_request",
"Request parameters are invalid",
include_body=include_body,
)
return
self._respond_json(handler, payload, include_body=include_body)
def _search(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
query = _one(params, "q").strip()
family = _one(params, "family").strip() or None
limit = _integer(_one(params, "limit") or "50")
return reader.search(query=query, family=family, limit=limit)
def _node(
self,
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
node_id = _one(params, "id").strip()
if not node_id:
raise DocForgeError("missing_node", "One exact node ID is required")
depth = _integer(_one(params, "depth") or "1")
limit = _integer(_one(params, "limit") or str(DEFAULT_EDGE_LIMIT))
if limit > MAX_EDGE_LIMIT:
raise DocForgeError(
"invalid_limit",
"Visualization edge limit exceeds the fixed safety boundary",
maximum=MAX_EDGE_LIMIT,
)
return reader.node(node_id, depth=depth, limit=limit)
def _current_reader(self) -> VisualizationIndexSnapshot:
with self._lock:
reader = self._reader
if reader is None:
raise DocForgeError(
"visualization_unavailable",
"The visualization snapshot is unavailable",
)
return reader
@staticmethod
def _respond(
handler: BaseHTTPRequestHandler,
payload: bytes,
content_type: str,
*,
status: HTTPStatus = HTTPStatus.OK,
include_body: bool = True,
) -> None:
handler.send_response(status)
handler.send_header("Content-Type", content_type)
handler.send_header("Content-Length", str(len(payload)))
handler.send_header("Cache-Control", "no-store")
handler.send_header(
"Content-Security-Policy",
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; "
"frame-ancestors 'none'",
)
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("X-Frame-Options", "DENY")
handler.send_header("Referrer-Policy", "no-referrer")
handler.end_headers()
if include_body:
handler.wfile.write(payload)
@classmethod
def _respond_json(
cls,
handler: BaseHTTPRequestHandler,
body: dict[str, object],
*,
status: HTTPStatus = HTTPStatus.OK,
include_body: bool = True,
) -> None:
cls._respond(
handler,
json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8"),
"application/json; charset=utf-8",
status=status,
include_body=include_body,
)
@classmethod
def _respond_error(
cls,
handler: BaseHTTPRequestHandler,
status: HTTPStatus,
code: str,
message: str,
*,
include_body: bool = True,
) -> None:
cls._respond_json(
handler,
{
"status": "error",
"error": {"code": code, "message": message, "details": {}},
},
status=status,
include_body=include_body,
)
def _one(params: dict[str, list[str]], name: str) -> str:
values = params.get(name) or [""]
return values[0]
def _integer(value: str) -> int:
return int(value)
def _node_dict(row: sqlite3.Row, *, include_content: bool = True) -> dict[str, object]:
result = {
"node_id": row["node_id"],
"title": row["title"],
"family": row["family"],
"authority": row["authority"],
"status": row["status"],
"tags": json.loads(row["tags_json"]),
"summary": row["summary"],
"source_path": row["source_path"],
"source_anchor": row["source_anchor"],
"content_hash": row["content_hash"],
}
if include_content:
result["content"] = row["content"]
return result
def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list[dict[str, object]]:
allowed = {
("nodes", "family"),
("nodes", "authority"),
("nodes", "status"),
("edges", "relation"),
}
if (table, column) not in allowed:
raise DocForgeError("invalid_index", "Unsupported visualization facet")
rows = connection.execute(
f"SELECT {column}, COUNT(*) AS count FROM {table} "
f"GROUP BY {column} ORDER BY count DESC, {column}"
).fetchall()
return [{"value": row[0], "count": row[1]} for row in rows]
_GRAPH_BROWSER_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DocForge graph</title>
<style>
:root {
color-scheme: dark;
--bg: #07101a;
--panel: #0c1825;
--panel-2: #111f2f;
--line: #263b51;
--text: #ecf5ff;
--muted: #93abc3;
--accent: #51d7ff;
--accent-2: #8fffc5;
--warn: #ffd27a;
2026-07-24 21:43:11 -04:00
--left-width: 310px;
--right-width: 350px;
--primary-fill: #176b7d;
--primary-stroke: #83e8ff;
--child-fill: #216c51;
--child-stroke: #91f2bd;
--edge-fill: #634580;
--edge-stroke: #d0a7ff;
2026-07-24 16:01:03 -04:00
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.app { display: grid; grid-template-rows: auto 1fr; min-height: 100vh; }
header {
display: flex; gap: 18px; align-items: center; padding: 14px 18px;
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
}
header h1 { margin: 0; font-size: 17px; }
.stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); }
2026-07-24 21:43:11 -04:00
.layout {
min-height: 0; display: grid;
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
}
2026-07-24 16:01:03 -04:00
aside { min-height: 0; overflow: auto; padding: 16px; background: var(--panel); }
2026-07-24 21:43:11 -04:00
.panel-resizer {
position: relative; z-index: 4; min-height: 0; background: #0a1521;
cursor: col-resize; touch-action: none;
}
.panel-resizer::after {
content: ""; position: absolute; inset: 0 2px; background: var(--line);
transition: background .15s;
}
.panel-resizer:hover::after, .panel-resizer:focus-visible::after,
.panel-resizer.resizing::after { background: var(--accent); }
.panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
2026-07-24 16:01:03 -04:00
form { display: grid; gap: 8px; }
input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px;
padding: 9px 10px; background: var(--panel-2); color: var(--text);
}
.search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
.button {
border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px;
background: #12384a; color: var(--text);
}
.results { display: grid; gap: 7px; margin-top: 14px; }
2026-07-24 21:43:11 -04:00
.section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
}
.section-label span {
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0;
}
.neighborhood { margin-top: 18px; padding-top: 2px; border-top: 1px solid var(--line); }
.node-list { display: grid; gap: 6px; }
.node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
gap: 9px; align-items: center; text-align: left; border: 1px solid var(--line);
border-radius: 9px; padding: 8px; background: rgba(17, 31, 47, .72); color: var(--text);
}
.node-list-item:hover, .node-list-item:focus-visible {
border-color: var(--item-color, var(--accent)); outline: none;
background: rgba(24, 43, 62, .9);
}
.node-swatch {
width: 8px; height: 28px; border-radius: 999px;
background: var(--item-color, var(--accent));
box-shadow: 0 0 12px color-mix(in srgb, var(--item-color, var(--accent)) 35%, transparent);
}
.node-list-copy { min-width: 0; }
.node-list-copy strong, .node-list-copy span {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node-list-copy span { color: var(--muted); font-size: 11px; }
.legend {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px;
}
.legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
}
.legend i { width: 8px; height: 8px; border-radius: 50%; }
2026-07-24 16:01:03 -04:00
.result {
width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px;
padding: 9px; background: var(--panel-2); color: var(--text);
}
.result:hover, .result:focus { border-color: var(--accent); }
.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; }
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
.canvas { position: relative; min-height: 0; overflow: hidden; }
svg { width: 100%; height: 100%; min-height: 620px; background:
2026-07-24 16:09:56 -04:00
radial-gradient(circle at center, #10243a 0, #07101a 64%);
cursor: grab; touch-action: none; user-select: none;
}
.canvas.dragging svg { cursor: grabbing; }
.viewport-controls {
position: absolute; z-index: 2; top: 12px; right: 12px;
display: grid; grid-template-columns: repeat(3, 36px) auto;
align-items: center; gap: 6px; padding: 6px;
border: 1px solid var(--line); border-radius: 10px;
background: rgba(7, 16, 26, .9); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
}
.viewport-control {
width: 36px; height: 34px; border: 1px solid #31526d; border-radius: 7px;
background: #102b3d; color: var(--text); font-weight: 700;
}
.viewport-control:hover, .viewport-control:focus-visible {
border-color: var(--accent); outline: 2px solid transparent;
}
.zoom-level {
min-width: 48px; padding: 0 5px; color: var(--muted);
font-variant-numeric: tabular-nums; text-align: right;
}
.viewport-hint {
position: absolute; z-index: 1; left: 12px; bottom: 12px;
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
background: rgba(7, 16, 26, .78); color: var(--muted); font-size: 11px;
pointer-events: none;
}
2026-07-24 21:43:11 -04:00
.edge { stroke-opacity: .68; stroke-width: 1.4; }
.edge.child-edge { stroke: #4cbe8a; }
.edge.context-edge { stroke: #a77bd6; }
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
2026-07-24 16:01:03 -04:00
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
2026-07-24 16:09:56 -04:00
.node { cursor: pointer; }
2026-07-24 21:43:11 -04:00
.node circle { stroke-width: 1.8; transition: stroke-width .15s, filter .15s; }
.node.root circle { stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24)); }
2026-07-24 16:01:03 -04:00
.node:hover circle { stroke: #fff; stroke-width: 3; }
.node text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; }
.empty {
position: absolute; inset: 0; display: grid; place-items: center;
color: var(--muted); pointer-events: none;
}
2026-07-24 21:14:30 -04:00
.empty[hidden] { display: none; }
2026-07-24 21:43:11 -04:00
.connection-state {
position: absolute; z-index: 6; inset: 50% auto auto 50%; transform: translate(-50%, -50%);
width: min(440px, calc(100% - 40px)); border: 1px solid #9a4b59; border-radius: 12px;
padding: 18px; background: rgba(32, 13, 20, .96); color: #ffd4dc;
box-shadow: 0 18px 60px rgba(0, 0, 0, .45); text-align: center;
}
.connection-state[hidden] { display: none; }
.connection-state strong { display: block; margin-bottom: 5px; }
2026-07-24 16:01:03 -04:00
.detail-head { display: flex; align-items: start; gap: 10px; }
.detail-head h2 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.badge {
display: inline-block; margin: 4px 5px 0 0; border: 1px solid var(--line);
border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 11px;
}
.meta { display: grid; gap: 8px; margin: 14px 0; }
.meta div { display: grid; grid-template-columns: 82px 1fr; gap: 8px; }
.meta dt { color: var(--muted); }
.meta dd { margin: 0; overflow-wrap: anywhere; }
.summary { color: #c9d9e8; }
pre {
white-space: pre-wrap; overflow-wrap: anywhere; max-height: 44vh; overflow: auto;
padding: 12px; border: 1px solid var(--line); border-radius: 9px;
background: #07111c; color: #d6e6f5;
}
2026-07-24 21:01:53 -04:00
dialog {
2026-07-24 21:43:11 -04:00
width: min(760px, calc(100vw - 32px)); height: min(680px, calc(100vh - 32px));
min-width: min(360px, calc(100vw - 20px)); min-height: 280px;
max-width: calc(100vw - 16px); max-height: calc(100vh - 16px);
2026-07-24 21:01:53 -04:00
padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px;
background: var(--panel); color: var(--text);
2026-07-24 21:43:11 -04:00
box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both;
2026-07-24 21:01:53 -04:00
}
2026-07-24 21:43:11 -04:00
dialog::backdrop { background: rgba(2, 8, 14, .48); }
2026-07-24 21:01:53 -04:00
.dialog-shell {
2026-07-24 21:43:11 -04:00
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%;
2026-07-24 21:01:53 -04:00
}
.dialog-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel-2);
2026-07-24 21:43:11 -04:00
cursor: move; touch-action: none; user-select: none;
2026-07-24 21:01:53 -04:00
}
.dialog-head strong { font-size: 15px; }
.dialog-close {
width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 8px;
background: #102b3d; color: var(--text); font-size: 21px; line-height: 1;
}
.dialog-close:hover, .dialog-close:focus-visible {
border-color: var(--accent); outline: 2px solid transparent;
}
.dialog-body { min-height: 0; overflow: auto; padding: 18px; }
.dialog-body pre { max-height: none; }
.dialog-actions {
display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px;
border-top: 1px solid var(--line); background: var(--panel-2);
}
2026-07-24 16:01:03 -04:00
.error { color: #ff9aac; }
@media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; }
2026-07-24 21:43:11 -04:00
.panel-resizer { display: none; }
2026-07-24 16:01:03 -04:00
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
}
@media (max-width: 700px) {
.layout { display: block; }
aside { max-height: none; }
svg { min-height: 520px; }
header { flex-wrap: wrap; }
.status { width: 100%; margin-left: 0; }
}
</style>
</head>
<body>
<div class="app">
<header>
<h1 id="project-title">DocForge graph</h1>
<div class="stats">
<span><strong id="node-count"></strong> nodes</span>
<span><strong id="edge-count"></strong> edges</span>
</div>
<div class="status" id="status">Loading validated index snapshot</div>
</header>
<div class="layout">
<aside class="left">
<form id="search-form">
<label for="search">Find nodes</label>
<div class="search-row">
<input id="search" name="q" autocomplete="off" placeholder="title, symbol, path…">
<button class="button" type="submit">Find</button>
</div>
<label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select>
</form>
<div class="results" id="results"></div>
2026-07-24 21:43:11 -04:00
<div class="neighborhood" id="neighborhood" hidden>
<div class="legend" aria-label="Node role colors">
<span><i style="background: var(--primary-stroke)"></i>Primary</span>
<span><i style="background: var(--child-stroke)"></i>Children</span>
<span><i style="background: var(--edge-stroke)"></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
2026-07-24 16:01:03 -04:00
</aside>
2026-07-24 21:43:11 -04:00
<div class="panel-resizer" id="left-resizer" role="separator" tabindex="0"
aria-label="Resize navigation panel" aria-orientation="vertical"
aria-valuemin="220" aria-valuemax="900" aria-valuenow="310"></div>
2026-07-24 16:01:03 -04:00
<main class="canvas">
2026-07-24 16:09:56 -04:00
<div class="viewport-controls" aria-label="Graph viewport controls">
<button class="viewport-control" id="zoom-in" type="button"
title="Zoom in" aria-label="Zoom in">+</button>
<button class="viewport-control" id="zoom-out" type="button"
title="Zoom out" aria-label="Zoom out"></button>
<button class="viewport-control" id="reset-view" type="button"
title="Reset view" aria-label="Reset graph view"></button>
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
</div>
2026-07-24 16:01:03 -04:00
<svg id="graph" viewBox="-600 -410 1200 820"
role="img" aria-label="Node neighborhood"></svg>
<div class="empty" id="empty">Search for a node to inspect its neighborhood.</div>
2026-07-24 21:43:11 -04:00
<div class="connection-state" id="connection-state" role="alert" hidden>
<strong>Visualization disconnected</strong>
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div>
2026-07-24 21:01:53 -04:00
<div class="viewport-hint">
Click node to inspect · mouse wheel to zoom · left-drag to pan
</div>
2026-07-24 16:01:03 -04:00
</main>
2026-07-24 21:43:11 -04:00
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
2026-07-24 16:01:03 -04:00
<aside class="right">
2026-07-24 21:01:53 -04:00
<div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p>
</div>
2026-07-24 16:01:03 -04:00
</aside>
</div>
</div>
2026-07-24 21:01:53 -04:00
<dialog id="node-dialog" aria-labelledby="node-dialog-label">
<div class="dialog-shell">
<div class="dialog-head">
<strong id="node-dialog-label">Inspect node</strong>
<button class="dialog-close" id="close-node-dialog" type="button"
aria-label="Close node inspection">×</button>
</div>
<div class="dialog-body" id="node-dialog-details"></div>
<div class="dialog-actions">
<button class="button" id="explore-node" type="button">Explore neighborhood</button>
<button class="button" id="dismiss-node-dialog" type="button">Close</button>
</div>
</div>
</dialog>
2026-07-24 16:01:03 -04:00
<script>
const base = location.pathname.replace(/\/?$/, "/");
2026-07-24 16:09:56 -04:00
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
const state = {
overview: null,
graph: null,
root: null,
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
pointer: null,
suppressClick: false,
2026-07-24 21:01:53 -04:00
inspectedNode: null,
2026-07-24 21:43:11 -04:00
dialogDrag: null,
leaseTimer: null,
2026-07-24 16:09:56 -04:00
};
2026-07-24 16:01:03 -04:00
const $ = (id) => document.getElementById(id);
const api = async (path) => {
2026-07-24 21:43:11 -04:00
let response;
try {
response = await fetch(`${base}api/${path}`, {cache: "no-store"});
} catch (_error) {
$("connection-state").hidden = false;
throw new Error("Visualization listener disconnected; invoke docforge_visualize again");
}
2026-07-24 16:01:03 -04:00
const body = await response.json();
if (!response.ok || body.status === "error") {
throw new Error(body.error?.message || "DocForge request failed");
}
2026-07-24 21:43:11 -04:00
$("connection-state").hidden = true;
2026-07-24 16:01:03 -04:00
return body;
};
const escapeText = (value) => String(value ?? "");
const short = (value, length = 34) => {
const text = escapeText(value);
return text.length > length ? `${text.slice(0, length - 1)}` : text;
};
2026-07-24 16:09:56 -04:00
function applyViewport() {
const view = state.viewport;
$("graph").setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
const zoom = Math.round((defaultViewport.width / view.width) * 100);
$("zoom-level").textContent = `${zoom}%`;
}
function resetViewport() {
state.viewport = {...defaultViewport};
applyViewport();
}
function zoomAt(factor, clientX = null, clientY = null) {
const svg = $("graph");
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const current = state.viewport;
const nextWidth = Math.min(
defaultViewport.width * 4,
Math.max(defaultViewport.width * .2, current.width * factor),
);
const nextHeight = nextWidth * (defaultViewport.height / defaultViewport.width);
const ratioX = clientX === null ? .5 : (clientX - rect.left) / rect.width;
const ratioY = clientY === null ? .5 : (clientY - rect.top) / rect.height;
const anchorX = current.x + ratioX * current.width;
const anchorY = current.y + ratioY * current.height;
state.viewport = {
x: anchorX - ratioX * nextWidth,
y: anchorY - ratioY * nextHeight,
width: nextWidth,
height: nextHeight,
};
applyViewport();
}
2026-07-24 16:01:03 -04:00
function setStatus(message, error = false) {
$("status").textContent = message;
$("status").classList.toggle("error", error);
}
2026-07-24 21:43:11 -04:00
async function renewViewerLease() {
try {
await api("heartbeat");
} catch (error) {
setStatus(error.message, true);
if (state.leaseTimer !== null) clearInterval(state.leaseTimer);
state.leaseTimer = null;
}
}
function startViewerLease() {
if (state.leaseTimer !== null) clearInterval(state.leaseTimer);
state.leaseTimer = setInterval(renewViewerLease, 15000);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) renewViewerLease();
});
}
2026-07-24 16:01:03 -04:00
function renderOverview(data) {
state.overview = data;
state.searchLimit = Math.max(1, Number(data.max_results) || 1);
$("project-title").textContent = data.title || data.project_id;
$("node-count").textContent = Number(data.node_count).toLocaleString();
$("edge-count").textContent = Number(data.edge_count).toLocaleString();
const family = $("family");
for (const item of data.families) {
const option = document.createElement("option");
option.value = item.value;
option.textContent = `${item.value} (${item.count})`;
family.append(option);
}
}
function renderResults(items) {
const results = $("results");
results.replaceChildren();
if (!items.length) {
const note = document.createElement("p");
note.className = "summary";
note.textContent = "No matching nodes.";
results.append(note);
return;
}
for (const item of items) {
const button = document.createElement("button");
button.type = "button";
button.className = "result";
const title = document.createElement("strong");
title.textContent = item.title;
const id = document.createElement("span");
id.textContent = item.node_id;
const family = document.createElement("span");
family.textContent = `${item.family} · ${item.source_path}`;
button.append(title, id, family);
button.addEventListener("click", () => loadNode(item.node_id));
results.append(button);
}
}
2026-07-24 21:43:11 -04:00
function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
const outgoing = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
for (const edge of data.edges) {
if (!nodeIds.has(edge.source_id) || !nodeIds.has(edge.target_id)) continue;
adjacency.get(edge.source_id).add(edge.target_id);
adjacency.get(edge.target_id).add(edge.source_id);
outgoing.get(edge.source_id).add(edge.target_id);
}
const hops = new Map([[data.root, 0]]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const neighbor of adjacency.get(nodeId) || []) {
if (hops.has(neighbor)) continue;
hops.set(neighbor, hops.get(nodeId) + 1);
next.push(neighbor);
}
}
frontier = next;
}
const children = new Set();
frontier = [...(outgoing.get(data.root) || [])];
for (const nodeId of frontier) children.add(nodeId);
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const candidate of outgoing.get(nodeId) || []) {
if (candidate === data.root || children.has(candidate)) continue;
children.add(candidate);
next.push(candidate);
}
}
frontier = next;
}
return new Map(data.nodes.map((node) => [
node.node_id,
{
hop: hops.get(node.node_id) ?? data.depth + 1,
role: node.node_id === data.root
? "primary"
: children.has(node.node_id) ? "child" : "edge",
},
]));
}
function layoutNodes(nodes, rootId, topology) {
const ordered = [...nodes].sort((a, b) => {
const first = topology.get(a.node_id);
const second = topology.get(b.node_id);
return first.hop - second.hop
|| first.role.localeCompare(second.role)
|| a.node_id.localeCompare(b.node_id);
});
2026-07-24 16:01:03 -04:00
const root = ordered.find((node) => node.node_id === rootId);
const positions = new Map();
if (root) positions.set(root.node_id, {x: 0, y: 0});
2026-07-24 21:43:11 -04:00
const rings = new Map();
for (const node of ordered) {
if (node.node_id === rootId) continue;
const hop = Math.max(1, topology.get(node.node_id).hop);
if (!rings.has(hop)) rings.set(hop, []);
rings.get(hop).push(node);
}
for (const [hop, ringNodes] of rings) {
ringNodes.forEach((node, index) => {
const angle = (index / Math.max(1, ringNodes.length)) * Math.PI * 2 - Math.PI / 2;
const radius = 165 + (hop - 1) * 145;
positions.set(node.node_id, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
});
}
2026-07-24 16:01:03 -04:00
return positions;
}
2026-07-24 21:43:11 -04:00
function darken(hex, amount) {
const value = Number.parseInt(hex.slice(1), 16);
const factor = 1 - Math.min(.5, Math.max(0, amount));
const channels = [
(value >> 16) & 255,
(value >> 8) & 255,
value & 255,
].map((channel) => Math.round(channel * factor).toString(16).padStart(2, "0"));
return `#${channels.join("")}`;
}
function nodePalette(role, hop) {
const colors = {
primary: {fill: "#176b7d", stroke: "#83e8ff"},
child: {fill: "#216c51", stroke: "#91f2bd"},
edge: {fill: "#634580", stroke: "#d0a7ff"},
}[role];
const distanceShade = Math.min(.5, Math.max(0, hop - 1) * .17);
return {
fill: darken(colors.fill, distanceShade),
stroke: darken(colors.stroke, distanceShade),
};
}
function renderNeighborhood(data, topology) {
const sections = [
{role: "primary", label: "Primary focus"},
{role: "child", label: "Children"},
{role: "edge", label: "Edge & context"},
];
const container = $("neighborhood-sections");
container.replaceChildren();
for (const section of sections) {
const nodes = data.nodes
.filter((node) => topology.get(node.node_id).role === section.role)
.sort((a, b) => topology.get(a.node_id).hop - topology.get(b.node_id).hop
|| a.node_id.localeCompare(b.node_id));
if (!nodes.length) continue;
const heading = document.createElement("div");
heading.className = "section-label";
heading.append(document.createTextNode(section.label));
const count = document.createElement("span");
count.textContent = String(nodes.length);
heading.append(count);
const list = document.createElement("div");
list.className = "node-list";
for (const node of nodes) {
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const button = document.createElement("button");
button.type = "button";
button.className = "node-list-item";
button.style.setProperty("--item-color", palette.stroke);
button.title = `Focus ${node.title}`;
const swatch = document.createElement("i");
swatch.className = "node-swatch";
const copy = document.createElement("span");
copy.className = "node-list-copy";
const title = document.createElement("strong");
title.textContent = node.title;
const meta = document.createElement("span");
const hopLabel = `${topologyNode.hop} hop${topologyNode.hop === 1 ? "" : "s"}`;
meta.textContent = `${node.family} · ${hopLabel}`;
copy.append(title, meta);
button.append(swatch, copy);
button.addEventListener("click", () => loadNode(node.node_id));
list.append(button);
}
container.append(heading, list);
}
$("neighborhood").hidden = false;
}
2026-07-24 16:01:03 -04:00
function svgElement(name, attributes = {}) {
const element = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
return element;
}
function renderGraph(data) {
state.graph = data;
state.root = data.root;
2026-07-24 16:09:56 -04:00
resetViewport();
2026-07-24 16:01:03 -04:00
const svg = $("graph");
svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0;
2026-07-24 21:43:11 -04:00
const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology);
renderNeighborhood(data, topology);
2026-07-24 16:01:03 -04:00
const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g");
for (const edge of data.edges) {
const source = positions.get(edge.source_id);
const target = positions.get(edge.target_id);
if (!source || !target) continue;
2026-07-24 21:43:11 -04:00
const targetRole = topology.get(edge.target_id)?.role || "edge";
const edgeRole = edge.source_id === data.root && targetRole === "child"
? "child-edge"
: edge.target_id === data.root || targetRole === "edge"
? "context-edge"
: "boundary-edge";
2026-07-24 16:01:03 -04:00
edgeLayer.append(svgElement("line", {
2026-07-24 21:43:11 -04:00
x1: source.x, y1: source.y, x2: target.x, y2: target.y,
class: `edge ${edgeRole}`
2026-07-24 16:01:03 -04:00
}));
const label = svgElement("text", {
x: (source.x + target.x) / 2,
y: (source.y + target.y) / 2,
class: "edge-label",
"text-anchor": "middle"
});
label.textContent = edge.relation;
edgeLayer.append(label);
}
for (const node of data.nodes) {
const point = positions.get(node.node_id);
if (!point) continue;
2026-07-24 21:43:11 -04:00
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
2026-07-24 16:01:03 -04:00
const group = svgElement("g", {
2026-07-24 21:43:11 -04:00
class: `node ${topologyNode.role}${node.node_id === data.root ? " root" : ""}`,
2026-07-24 16:01:03 -04:00
transform: `translate(${point.x} ${point.y})`,
2026-07-24 21:43:11 -04:00
"data-hop": String(topologyNode.hop),
2026-07-24 16:01:03 -04:00
tabindex: "0",
role: "button",
2026-07-24 21:43:11 -04:00
"aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ")
2026-07-24 16:01:03 -04:00
});
2026-07-24 21:43:11 -04:00
group.append(svgElement("circle", {
r: node.node_id === data.root ? 25 : 18,
fill: palette.fill,
stroke: palette.stroke,
}));
2026-07-24 16:01:03 -04:00
const title = svgElement("text", {y: 35, "text-anchor": "middle"});
title.textContent = short(node.title, 26);
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
family.textContent = short(node.family, 22);
group.append(title, family);
2026-07-24 16:09:56 -04:00
group.addEventListener("click", () => {
2026-07-24 21:01:53 -04:00
if (!state.suppressClick) inspectNode(node.node_id);
2026-07-24 16:09:56 -04:00
});
2026-07-24 16:01:03 -04:00
group.addEventListener("keydown", (event) => {
2026-07-24 21:01:53 -04:00
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inspectNode(node.node_id);
}
2026-07-24 16:01:03 -04:00
});
nodeLayer.append(group);
}
svg.append(edgeLayer, nodeLayer);
}
2026-07-24 21:01:53 -04:00
function renderDetails(details, node, data) {
2026-07-24 16:01:03 -04:00
details.replaceChildren();
const heading = document.createElement("div");
heading.className = "detail-head";
const title = document.createElement("h2");
title.textContent = node.title;
heading.append(title);
const badges = document.createElement("div");
for (const value of [node.family, node.authority, node.status, ...node.tags]) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = value;
badges.append(badge);
}
const summary = document.createElement("p");
summary.className = "summary";
summary.textContent = node.summary;
const dl = document.createElement("dl");
dl.className = "meta";
for (const [label, value] of [
["ID", node.node_id],
["Source", node.source_path],
["Anchor", node.source_anchor || ""],
["Neighbors", `${data.nodes.length - 1} nodes · ${data.edges.length} edges`],
]) {
const row = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = label;
dd.textContent = escapeText(value);
row.append(dt, dd);
dl.append(row);
}
const content = document.createElement("pre");
content.textContent = node.content;
details.append(heading, badges, summary, dl, content);
}
2026-07-24 21:01:53 -04:00
function closeNodeDialog() {
const dialog = $("node-dialog");
if (dialog.open) dialog.close();
else state.inspectedNode = null;
}
async function inspectNode(nodeId) {
try {
setStatus(`Inspecting ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
state.inspectedNode = nodeId;
renderDetails($("node-dialog-details"), data.node, data);
2026-07-24 21:43:11 -04:00
$("node-dialog-label").textContent = short(data.node.title, 72);
2026-07-24 21:01:53 -04:00
const dialog = $("node-dialog");
if (!dialog.open) dialog.showModal();
$("close-node-dialog").focus();
setStatus(`Inspecting ${nodeId}`);
} catch (error) {
setStatus(error.message, true);
}
}
2026-07-24 16:01:03 -04:00
async function search() {
const params = new URLSearchParams({
q: $("search").value.trim(),
family: $("family").value,
limit: String(state.searchLimit),
});
try {
setStatus("Searching validated index…");
const data = await api(`search?${params}`);
renderResults(data.results || []);
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
} catch (error) {
setStatus(error.message, true);
}
}
async function loadNode(nodeId) {
try {
setStatus(`Loading ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
renderGraph(data);
2026-07-24 21:01:53 -04:00
renderDetails($("details"), data.node, data);
2026-07-24 16:01:03 -04:00
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
} catch (error) {
setStatus(error.message, true);
}
}
2026-07-24 21:43:11 -04:00
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
}
function setPanelWidth(side, width) {
const maximum = Math.max(260, Math.floor(window.innerWidth * .46));
const bounded = clamp(width, side === "left" ? 220 : 240, maximum);
document.documentElement.style.setProperty(`--${side}-width`, `${bounded}px`);
$(`${side}-resizer`).setAttribute("aria-valuenow", String(Math.round(bounded)));
}
function setupPanelResizer(side) {
const handle = $(`${side}-resizer`);
handle.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const property = getComputedStyle(document.documentElement)
.getPropertyValue(`--${side}-width`);
const startWidth = Number.parseFloat(property) || (side === "left" ? 310 : 350);
const startX = event.clientX;
handle.classList.add("resizing");
handle.setPointerCapture(event.pointerId);
const move = (moveEvent) => {
const delta = moveEvent.clientX - startX;
setPanelWidth(side, startWidth + (side === "left" ? delta : -delta));
};
const end = (endEvent) => {
if (handle.hasPointerCapture(endEvent.pointerId)) {
handle.releasePointerCapture(endEvent.pointerId);
}
handle.classList.remove("resizing");
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", end);
handle.removeEventListener("pointercancel", end);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", end);
handle.addEventListener("pointercancel", end);
});
handle.addEventListener("keydown", (event) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const property = getComputedStyle(document.documentElement)
.getPropertyValue(`--${side}-width`);
const width = Number.parseFloat(property) || (side === "left" ? 310 : 350);
const direction = event.key === "ArrowRight" ? 16 : -16;
setPanelWidth(side, width + (side === "left" ? direction : -direction));
});
handle.addEventListener("dblclick", () => setPanelWidth(side, side === "left" ? 310 : 350));
}
function beginDialogDrag(event) {
if (event.button !== 0 || event.target.closest("button")) return;
const dialog = $("node-dialog");
const header = event.currentTarget;
const bounds = dialog.getBoundingClientRect();
dialog.style.margin = "0";
dialog.style.right = "auto";
dialog.style.bottom = "auto";
dialog.style.left = `${bounds.left}px`;
dialog.style.top = `${bounds.top}px`;
dialog.style.width = `${bounds.width}px`;
dialog.style.height = `${bounds.height}px`;
state.dialogDrag = {
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
left: bounds.left,
top: bounds.top,
};
header.setPointerCapture(event.pointerId);
}
function moveDialog(event) {
const drag = state.dialogDrag;
if (!drag || drag.id !== event.pointerId) return;
const dialog = $("node-dialog");
const maximumLeft = Math.max(8, window.innerWidth - dialog.offsetWidth - 8);
const maximumTop = Math.max(8, window.innerHeight - dialog.offsetHeight - 8);
dialog.style.left = `${clamp(drag.left + event.clientX - drag.startX, 8, maximumLeft)}px`;
dialog.style.top = `${clamp(drag.top + event.clientY - drag.startY, 8, maximumTop)}px`;
}
function endDialogDrag(event) {
const drag = state.dialogDrag;
if (!drag || drag.id !== event.pointerId) return;
const header = $("node-dialog").querySelector(".dialog-head");
if (header.hasPointerCapture(event.pointerId)) header.releasePointerCapture(event.pointerId);
state.dialogDrag = null;
}
2026-07-24 16:01:03 -04:00
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", search);
2026-07-24 16:09:56 -04:00
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
2026-07-24 21:01:53 -04:00
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
2026-07-24 21:43:11 -04:00
setupPanelResizer("left");
setupPanelResizer("right");
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerdown", beginDialogDrag);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointermove", moveDialog);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointerup", endDialogDrag);
$("node-dialog").querySelector(".dialog-head").addEventListener("pointercancel", endDialogDrag);
2026-07-24 21:01:53 -04:00
$("explore-node").addEventListener("click", async () => {
const nodeId = state.inspectedNode;
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
});
$("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return;
const bounds = $("node-dialog").getBoundingClientRect();
const inside = event.clientX >= bounds.left && event.clientX <= bounds.right
&& event.clientY >= bounds.top && event.clientY <= bounds.bottom;
if (!inside) closeNodeDialog();
});
$("node-dialog").addEventListener("close", () => {
state.inspectedNode = null;
if (state.graph) {
const nodeCount = state.graph.nodes.length;
const edgeCount = state.graph.edges.length;
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
2026-07-24 16:09:56 -04:00
$("graph").addEventListener("wheel", (event) => {
event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);
}, {passive: false});
$("graph").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const svg = $("graph");
state.suppressClick = false;
state.pointer = {
id: event.pointerId,
startX: event.clientX,
startY: event.clientY,
viewport: {...state.viewport},
moved: false,
};
});
$("graph").addEventListener("pointermove", (event) => {
const pointer = state.pointer;
if (!pointer || pointer.id !== event.pointerId) return;
const svg = $("graph");
const rect = svg.getBoundingClientRect();
if (!rect.width || !rect.height) return;
const deltaX = event.clientX - pointer.startX;
const deltaY = event.clientY - pointer.startY;
if (!pointer.moved && Math.hypot(deltaX, deltaY) < 4) return;
pointer.moved = true;
state.suppressClick = true;
2026-07-24 21:14:30 -04:00
if (!svg.hasPointerCapture(event.pointerId)) svg.setPointerCapture(event.pointerId);
2026-07-24 16:09:56 -04:00
svg.closest(".canvas").classList.add("dragging");
state.viewport = {
...pointer.viewport,
x: pointer.viewport.x - deltaX * (pointer.viewport.width / rect.width),
y: pointer.viewport.y - deltaY * (pointer.viewport.height / rect.height),
};
applyViewport();
});
function endPan(event) {
const pointer = state.pointer;
if (!pointer || pointer.id !== event.pointerId) return;
const svg = $("graph");
if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId);
svg.closest(".canvas").classList.remove("dragging");
state.pointer = null;
if (pointer.moved) {
setTimeout(() => { state.suppressClick = false; }, 0);
}
}
$("graph").addEventListener("pointerup", endPan);
$("graph").addEventListener("pointercancel", endPan);
applyViewport();
2026-07-24 16:01:03 -04:00
(async () => {
try {
const params = new URLSearchParams(location.search);
state.depth = Math.max(1, Number(params.get("depth")) || 1);
const overview = await api("overview");
renderOverview(overview);
2026-07-24 21:43:11 -04:00
startViewerLease();
2026-07-24 16:01:03 -04:00
const nodeId = params.get("node");
const query = params.get("q");
if (nodeId) {
await loadNode(nodeId);
} else {
if (query) $("search").value = query;
await search();
}
} catch (error) {
setStatus(error.message, true);
}
})();
</script>
</body>
</html>
"""