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
|
2026-07-24 22:07:33 -04:00
|
|
|
|
import os
|
2026-07-24 16:01:03 -04:00
|
|
|
|
import secrets
|
2026-07-24 23:55:55 -04:00
|
|
|
|
import signal
|
2026-07-24 22:07:33 -04:00
|
|
|
|
import socket
|
2026-07-24 16:01:03 -04:00
|
|
|
|
import sqlite3
|
2026-07-24 22:07:33 -04:00
|
|
|
|
import sys
|
2026-07-24 23:55:55 -04:00
|
|
|
|
import tempfile
|
2026-07-24 16:01:03 -04:00
|
|
|
|
import threading
|
2026-07-24 23:55:55 -04:00
|
|
|
|
import time
|
|
|
|
|
|
import urllib.error
|
2026-07-24 16:01:03 -04:00
|
|
|
|
import urllib.parse
|
2026-07-24 23:55:55 -04:00
|
|
|
|
import urllib.request
|
2026-07-24 16:01:03 -04:00
|
|
|
|
from collections.abc import Generator
|
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
|
from http import HTTPStatus
|
|
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
2026-07-24 22:07:33 -04:00
|
|
|
|
from pathlib import Path
|
2026-07-24 21:43:11 -04:00
|
|
|
|
from time import monotonic
|
2026-07-24 22:26:01 -04:00
|
|
|
|
from typing import cast
|
2026-07-24 16:01:03 -04:00
|
|
|
|
|
2026-07-25 00:17:21 -04:00
|
|
|
|
try:
|
|
|
|
|
|
import fcntl
|
|
|
|
|
|
except ImportError: # pragma: no cover - retained only for the deprecated direct runner.
|
|
|
|
|
|
fcntl = None
|
|
|
|
|
|
|
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 23:55:55 -04:00
|
|
|
|
from .project import project_root_fingerprint
|
2026-07-24 16:01:03 -04:00
|
|
|
|
|
2026-07-25 01:05:45 -04:00
|
|
|
|
VISUALIZATION_TEMPLATE = "graph-browser@11"
|
2026-07-24 16:01:03 -04:00
|
|
|
|
DEFAULT_EDGE_LIMIT = 100
|
|
|
|
|
|
MAX_EDGE_LIMIT = 400
|
2026-07-25 01:05:45 -04:00
|
|
|
|
MAX_LINEAGE_EDGE_LIMIT = 1_000
|
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 23:55:55 -04:00
|
|
|
|
VISUALIZATION_REGISTRY_NAME = ".visualization.json"
|
|
|
|
|
|
VISUALIZATION_LOCK_NAME = ".visualization.lock"
|
|
|
|
|
|
VISUALIZATION_RUNTIME = "persistent-worker@1"
|
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
|
2026-07-24 22:26:01 -04:00
|
|
|
|
self.identity: dict[str, object] = {key: checked[key] for key in self._IDENTITY_KEYS}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
self._stat = self._safe_stat()
|
|
|
|
|
|
|
2026-07-24 22:07:33 -04:00
|
|
|
|
@classmethod
|
|
|
|
|
|
def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot:
|
|
|
|
|
|
snapshot = cls.__new__(cls)
|
2026-07-24 22:26:01 -04:00
|
|
|
|
path = spec["path"]
|
|
|
|
|
|
title = spec["title"]
|
|
|
|
|
|
max_query_chars = spec["max_query_chars"]
|
|
|
|
|
|
max_results = spec["max_results"]
|
|
|
|
|
|
max_depth = spec["max_depth"]
|
|
|
|
|
|
if (
|
|
|
|
|
|
not isinstance(path, str)
|
|
|
|
|
|
or not isinstance(title, str)
|
|
|
|
|
|
or type(max_query_chars) is not int
|
|
|
|
|
|
or type(max_results) is not int
|
|
|
|
|
|
or type(max_depth) is not int
|
|
|
|
|
|
):
|
|
|
|
|
|
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
|
|
|
|
|
|
snapshot.path = Path(path)
|
|
|
|
|
|
snapshot.title = title
|
|
|
|
|
|
snapshot.max_query_chars = max_query_chars
|
|
|
|
|
|
snapshot.max_results = max_results
|
|
|
|
|
|
snapshot.max_depth = max_depth
|
2026-07-24 22:07:33 -04:00
|
|
|
|
identity = spec["identity"]
|
|
|
|
|
|
if not isinstance(identity, dict):
|
|
|
|
|
|
raise DocForgeError("invalid_index", "Visualization identity is invalid")
|
2026-07-24 22:26:01 -04:00
|
|
|
|
typed_identity = cast(dict[str, object], identity)
|
|
|
|
|
|
snapshot.identity = {key: typed_identity[key] for key in cls._IDENTITY_KEYS}
|
2026-07-24 22:07:33 -04:00
|
|
|
|
snapshot._stat = snapshot._safe_stat()
|
|
|
|
|
|
return snapshot
|
|
|
|
|
|
|
|
|
|
|
|
def spec(self) -> dict[str, object]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"path": str(self.path),
|
|
|
|
|
|
"title": self.title,
|
|
|
|
|
|
"max_query_chars": self.max_query_chars,
|
|
|
|
|
|
"max_results": self.max_results,
|
|
|
|
|
|
"max_depth": self.max_depth,
|
|
|
|
|
|
"identity": dict(self.identity),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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()
|
2026-07-24 22:26:01 -04:00
|
|
|
|
results: list[dict[str, object]] = []
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-24 23:15:57 -04:00
|
|
|
|
def filter_nodes(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
category: str,
|
|
|
|
|
|
value: str,
|
|
|
|
|
|
limit: int,
|
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
|
bounded = self._bounded_limit(limit)
|
|
|
|
|
|
if category not in {"family", "authority", "status", "tag"}:
|
|
|
|
|
|
raise DocForgeError("invalid_filter", "Descriptor filter category is unsupported")
|
|
|
|
|
|
if not value or len(value) > self.max_query_chars:
|
|
|
|
|
|
raise DocForgeError("invalid_filter", "Descriptor filter value is invalid")
|
|
|
|
|
|
if category == "tag":
|
|
|
|
|
|
clause = "EXISTS (SELECT 1 FROM json_each(tags_json) WHERE value = ?)"
|
|
|
|
|
|
else:
|
|
|
|
|
|
clause = f"{category} = ?"
|
|
|
|
|
|
with self._connection() as connection:
|
|
|
|
|
|
total = connection.execute(
|
|
|
|
|
|
f"SELECT COUNT(*) FROM nodes WHERE {clause}",
|
|
|
|
|
|
(value,),
|
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
rows = connection.execute(
|
|
|
|
|
|
f"SELECT * FROM nodes WHERE {clause} ORDER BY node_id LIMIT ?",
|
|
|
|
|
|
(value, bounded),
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
results = [_node_dict(row, include_content=False) for row in rows]
|
|
|
|
|
|
return self._result(
|
|
|
|
|
|
category=category,
|
|
|
|
|
|
value=value,
|
|
|
|
|
|
count=len(results),
|
|
|
|
|
|
total=total,
|
|
|
|
|
|
truncated=total > len(results),
|
|
|
|
|
|
results=results,
|
|
|
|
|
|
snapshot=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-25 01:05:45 -04:00
|
|
|
|
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
|
|
|
|
|
|
"""Return every bounded, directed ancestry path terminating at ``node_id``.
|
|
|
|
|
|
|
|
|
|
|
|
A lineage follows stored edge direction only: ``source -> target``. This keeps
|
|
|
|
|
|
Flow literal and auditable. It does not reinterpret relationship meanings or
|
|
|
|
|
|
reverse dependency/data edges as the old client-side Flow view did.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
|
"Visualization lineage limit exceeds the fixed safety boundary",
|
|
|
|
|
|
maximum=MAX_LINEAGE_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]] = []
|
|
|
|
|
|
truncated = False
|
|
|
|
|
|
while frontier and len(selected) < limit:
|
|
|
|
|
|
placeholders = ",".join("?" for _ in frontier)
|
|
|
|
|
|
remaining = limit - len(selected)
|
|
|
|
|
|
rows = connection.execute(
|
|
|
|
|
|
"SELECT source_id, relation, target_id FROM edges "
|
|
|
|
|
|
f"WHERE target_id IN ({placeholders}) "
|
|
|
|
|
|
"ORDER BY source_id, relation, target_id LIMIT ?",
|
|
|
|
|
|
(*sorted(frontier), remaining + 1),
|
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
if len(rows) > remaining:
|
|
|
|
|
|
rows = rows[:remaining]
|
|
|
|
|
|
truncated = True
|
|
|
|
|
|
next_frontier: set[str] = set()
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
edge = {
|
|
|
|
|
|
"source_id": row["source_id"],
|
|
|
|
|
|
"relation": row["relation"],
|
|
|
|
|
|
"target_id": row["target_id"],
|
|
|
|
|
|
}
|
|
|
|
|
|
selected.append(edge)
|
|
|
|
|
|
source_id = edge["source_id"]
|
|
|
|
|
|
if source_id not in visited:
|
|
|
|
|
|
visited.add(source_id)
|
|
|
|
|
|
next_frontier.add(source_id)
|
|
|
|
|
|
frontier = next_frontier
|
|
|
|
|
|
if frontier and len(selected) >= limit:
|
|
|
|
|
|
truncated = True
|
|
|
|
|
|
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,
|
|
|
|
|
|
lineage=True,
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 22:26:01 -04:00
|
|
|
|
def result(self, **payload: object) -> dict[str, object]:
|
|
|
|
|
|
return self._result(**payload)
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
|
|
|
|
|
|
class VisualizationRunner:
|
|
|
|
|
|
"""Start one token-protected loopback reader for one immutable project binding."""
|
|
|
|
|
|
|
2026-07-24 21:43:11 -04:00
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
2026-07-24 22:07:33 -04:00
|
|
|
|
index: ProjectIndex | None,
|
2026-07-24 21:43:11 -04:00
|
|
|
|
*,
|
2026-07-24 22:07:33 -04:00
|
|
|
|
snapshot_spec: dict[str, object] | None = None,
|
|
|
|
|
|
token: str | None = None,
|
|
|
|
|
|
register_atexit: bool = True,
|
2026-07-24 23:55:55 -04:00
|
|
|
|
persistent: bool = False,
|
2026-07-24 21:43:11 -04:00
|
|
|
|
initial_grace_seconds: float = DEFAULT_INITIAL_GRACE_SECONDS,
|
|
|
|
|
|
lease_seconds: float = DEFAULT_LEASE_SECONDS,
|
|
|
|
|
|
monitor_interval_seconds: float = LEASE_MONITOR_INTERVAL_SECONDS,
|
|
|
|
|
|
) -> None:
|
2026-07-24 22:07:33 -04:00
|
|
|
|
if (index is None) == (snapshot_spec is None):
|
|
|
|
|
|
raise ValueError("Provide exactly one visualization index or snapshot")
|
2026-07-24 23:55:55 -04:00
|
|
|
|
if (
|
|
|
|
|
|
(not persistent and initial_grace_seconds <= 0)
|
|
|
|
|
|
or lease_seconds <= 0
|
|
|
|
|
|
or monitor_interval_seconds <= 0
|
|
|
|
|
|
):
|
2026-07-24 21:43:11 -04:00
|
|
|
|
raise ValueError("Visualization lease durations must be positive")
|
2026-07-24 16:01:03 -04:00
|
|
|
|
self.index = index
|
2026-07-24 22:07:33 -04:00
|
|
|
|
self._snapshot_spec = snapshot_spec
|
|
|
|
|
|
self._register_atexit = register_atexit
|
2026-07-24 23:55:55 -04:00
|
|
|
|
self.persistent = persistent
|
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()
|
2026-07-24 22:07:33 -04:00
|
|
|
|
self._token = token or secrets.token_urlsafe(24)
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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()
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
2026-07-24 22:07:33 -04:00
|
|
|
|
reader = (
|
|
|
|
|
|
VisualizationIndexSnapshot(self.index, self.index.check())
|
|
|
|
|
|
if self.index is not None
|
|
|
|
|
|
else VisualizationIndexSnapshot.from_spec(self._snapshot_spec or {})
|
|
|
|
|
|
)
|
|
|
|
|
|
maximum_depth = reader.max_depth
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-24 22:26:01 -04:00
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
|
|
|
|
del format, args
|
2026-07-24 16:01:03 -04:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler)
|
2026-07-24 21:43:11 -04:00
|
|
|
|
self._lease_last_activity = monotonic()
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
|
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 23:55:55 -04:00
|
|
|
|
if not self.persistent:
|
|
|
|
|
|
self._lease_thread = threading.Thread(
|
|
|
|
|
|
target=self._monitor_lease,
|
|
|
|
|
|
name="docforge-visualization-lease",
|
|
|
|
|
|
daemon=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._lease_thread.start()
|
2026-07-24 22:07:33 -04:00
|
|
|
|
if self._register_atexit and not self._atexit_registered:
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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 23:55:55 -04:00
|
|
|
|
"lifetime": (
|
|
|
|
|
|
{"policy": "explicit_stop"}
|
|
|
|
|
|
if self.persistent
|
|
|
|
|
|
else {
|
|
|
|
|
|
"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 22:07:33 -04:00
|
|
|
|
def is_running(self) -> bool:
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
return self._server is not None
|
|
|
|
|
|
|
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()
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._activity_last_seen = time.time()
|
2026-07-24 21:43:11 -04:00
|
|
|
|
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
|
|
|
|
|
|
if parsed.path in {prefix, f"{prefix}/"}:
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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()
|
2026-07-25 00:17:21 -04:00
|
|
|
|
if parsed.path == f"{prefix}/api/health":
|
|
|
|
|
|
with self._lock:
|
|
|
|
|
|
last_activity = self._activity_last_seen
|
|
|
|
|
|
payload = reader.result(viewer="alive", last_activity_at=last_activity)
|
|
|
|
|
|
elif parsed.path == f"{prefix}/api/overview":
|
|
|
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
|
payload = reader.overview()
|
2026-07-24 21:43:11 -04:00
|
|
|
|
elif parsed.path == f"{prefix}/api/heartbeat":
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._touch_lease()
|
2026-07-24 22:26:01 -04:00
|
|
|
|
payload = reader.result(
|
2026-07-24 21:43:11 -04:00
|
|
|
|
viewer="alive",
|
|
|
|
|
|
lease_seconds=self.lease_seconds,
|
|
|
|
|
|
)
|
2026-07-24 16:01:03 -04:00
|
|
|
|
elif parsed.path == f"{prefix}/api/search":
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
|
payload = self._search(reader, params)
|
2026-07-24 23:15:57 -04:00
|
|
|
|
elif parsed.path == f"{prefix}/api/filter":
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._touch_lease()
|
2026-07-24 23:15:57 -04:00
|
|
|
|
payload = self._filter(reader, params)
|
2026-07-24 16:01:03 -04:00
|
|
|
|
elif parsed.path == f"{prefix}/api/node":
|
2026-07-25 00:17:21 -04:00
|
|
|
|
self._touch_lease()
|
2026-07-24 16:01:03 -04:00
|
|
|
|
payload = self._node(reader, params)
|
2026-07-25 01:05:45 -04:00
|
|
|
|
elif parsed.path == f"{prefix}/api/lineage":
|
|
|
|
|
|
self._touch_lease()
|
|
|
|
|
|
payload = self._lineage(reader, params)
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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,
|
2026-07-24 23:15:57 -04:00
|
|
|
|
"invalid_filter": HTTPStatus.BAD_REQUEST,
|
2026-07-24 16:01:03 -04:00
|
|
|
|
"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)
|
|
|
|
|
|
|
2026-07-25 01:05:45 -04:00
|
|
|
|
def _lineage(
|
|
|
|
|
|
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")
|
|
|
|
|
|
limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT))
|
|
|
|
|
|
if limit > MAX_LINEAGE_EDGE_LIMIT:
|
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"invalid_limit",
|
|
|
|
|
|
"Visualization lineage limit exceeds the fixed safety boundary",
|
|
|
|
|
|
maximum=MAX_LINEAGE_EDGE_LIMIT,
|
|
|
|
|
|
)
|
|
|
|
|
|
return reader.lineage(node_id, limit=limit)
|
|
|
|
|
|
|
2026-07-24 23:15:57 -04:00
|
|
|
|
def _filter(
|
|
|
|
|
|
self,
|
|
|
|
|
|
reader: VisualizationIndexSnapshot,
|
|
|
|
|
|
params: dict[str, list[str]],
|
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
|
category = _one(params, "category").strip()
|
|
|
|
|
|
value = _one(params, "value").strip()
|
|
|
|
|
|
limit = _integer(_one(params, "limit") or "50")
|
|
|
|
|
|
return reader.filter_nodes(category=category, value=value, limit=limit)
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
|
class PersistentVisualizationRunner:
|
|
|
|
|
|
"""Run one project-bound browser until an explicit DocForge stop request."""
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
|
def __init__(self, index: ProjectIndex) -> None:
|
|
|
|
|
|
self.index = index
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def _cache_root(self) -> Path:
|
|
|
|
|
|
return self.index.project.descriptor.cache_root
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def _registry_path(self) -> Path:
|
|
|
|
|
|
return self._cache_root / VISUALIZATION_REGISTRY_NAME
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def _lock_path(self) -> Path:
|
|
|
|
|
|
return self._cache_root / VISUALIZATION_LOCK_NAME
|
|
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
|
def _locked_registry(self) -> Generator[None, None, None]:
|
2026-07-25 00:17:21 -04:00
|
|
|
|
if fcntl is None:
|
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"visualization_unavailable",
|
|
|
|
|
|
"The deprecated direct visualization runner is unavailable on this platform",
|
|
|
|
|
|
)
|
2026-07-24 23:55:55 -04:00
|
|
|
|
self._cache_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
with self._lock_path.open("a+", encoding="utf-8") as handle:
|
|
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
|
|
|
|
try:
|
|
|
|
|
|
yield
|
|
|
|
|
|
finally:
|
|
|
|
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
|
|
|
|
|
|
|
|
|
|
def _read_registry(self) -> dict[str, object] | None:
|
|
|
|
|
|
path = self._registry_path
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return None
|
|
|
|
|
|
if path.is_symlink() or not path.is_file():
|
|
|
|
|
|
raise DocForgeError("visualization_unavailable", "Visualization registry is unsafe")
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_document: object = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
except (OSError, json.JSONDecodeError):
|
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
|
return None
|
|
|
|
|
|
if not isinstance(raw_document, dict):
|
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
|
return None
|
|
|
|
|
|
document = cast(dict[str, object], raw_document)
|
|
|
|
|
|
pid = document.get("pid")
|
|
|
|
|
|
port = document.get("port")
|
|
|
|
|
|
token = document.get("token")
|
|
|
|
|
|
snapshot = document.get("snapshot")
|
|
|
|
|
|
if (
|
|
|
|
|
|
document.get("runtime") != VISUALIZATION_RUNTIME
|
|
|
|
|
|
or document.get("template") != VISUALIZATION_TEMPLATE
|
|
|
|
|
|
or type(pid) is not int
|
|
|
|
|
|
or pid <= 1
|
|
|
|
|
|
or type(port) is not int
|
|
|
|
|
|
or not 1 <= port <= 65535
|
|
|
|
|
|
or not isinstance(token, str)
|
|
|
|
|
|
or len(token) < 20
|
|
|
|
|
|
or not isinstance(snapshot, dict)
|
|
|
|
|
|
):
|
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
|
return None
|
|
|
|
|
|
return document
|
|
|
|
|
|
|
|
|
|
|
|
def _remove_registry(self) -> None:
|
|
|
|
|
|
path = self._registry_path
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
return
|
|
|
|
|
|
if path.is_symlink() or not path.is_file():
|
|
|
|
|
|
raise DocForgeError("visualization_unavailable", "Visualization registry is unsafe")
|
|
|
|
|
|
path.unlink()
|
|
|
|
|
|
|
|
|
|
|
|
def _write_registry(self, document: dict[str, object]) -> None:
|
|
|
|
|
|
descriptor, temporary_name = tempfile.mkstemp(
|
|
|
|
|
|
prefix=".visualization-", dir=self._cache_root
|
|
|
|
|
|
)
|
|
|
|
|
|
temporary = Path(temporary_name)
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.fchmod(descriptor, 0o600)
|
|
|
|
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
|
|
|
|
json.dump(document, handle, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
handle.write("\n")
|
|
|
|
|
|
handle.flush()
|
|
|
|
|
|
os.fsync(handle.fileno())
|
|
|
|
|
|
os.replace(temporary, self._registry_path)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _worker_process(pid: int) -> bool:
|
|
|
|
|
|
if pid <= 1:
|
|
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
command = Path(f"/proc/{pid}/cmdline").read_bytes()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return b"docforge.visualization_worker" in command
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _target_url(
|
|
|
|
|
|
*,
|
|
|
|
|
|
port: int,
|
|
|
|
|
|
token: str,
|
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
|
query: str | None,
|
|
|
|
|
|
depth: int,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
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
|
|
|
|
|
|
return f"http://127.0.0.1:{port}/{token}/?{urllib.parse.urlencode(parameters)}"
|
|
|
|
|
|
|
|
|
|
|
|
def _matches_snapshot(
|
|
|
|
|
|
self,
|
|
|
|
|
|
document: dict[str, object],
|
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
record = document.get("snapshot")
|
|
|
|
|
|
return isinstance(record, dict) and record == snapshot.identity
|
|
|
|
|
|
|
|
|
|
|
|
def _is_live(
|
2026-07-24 22:07:33 -04:00
|
|
|
|
self,
|
2026-07-24 23:55:55 -04:00
|
|
|
|
document: dict[str, object],
|
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
if not self._matches_snapshot(document, snapshot):
|
|
|
|
|
|
return False
|
|
|
|
|
|
pid = cast(int, document["pid"])
|
|
|
|
|
|
port = cast(int, document["port"])
|
|
|
|
|
|
token = cast(str, document["token"])
|
|
|
|
|
|
if not self._worker_process(pid):
|
|
|
|
|
|
return False
|
|
|
|
|
|
request = urllib.request.Request(
|
|
|
|
|
|
f"http://127.0.0.1:{port}/{token}/api/overview",
|
|
|
|
|
|
headers={"Accept": "application/json"},
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with urllib.request.urlopen(request, timeout=1) as response:
|
|
|
|
|
|
raw_payload: object = json.load(response)
|
|
|
|
|
|
except (OSError, ValueError, urllib.error.URLError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
if not isinstance(raw_payload, dict):
|
|
|
|
|
|
return False
|
|
|
|
|
|
payload = cast(dict[str, object], raw_payload)
|
|
|
|
|
|
return all(payload.get(key) == value for key, value in snapshot.identity.items())
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _terminate_worker(pid: int) -> None:
|
|
|
|
|
|
if not PersistentVisualizationRunner._worker_process(pid):
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.kill(pid, signal.SIGTERM)
|
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
|
return
|
|
|
|
|
|
except PermissionError as error:
|
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"visualization_unavailable", "Visualization worker cannot be stopped"
|
|
|
|
|
|
) from error
|
|
|
|
|
|
deadline = monotonic() + 2
|
|
|
|
|
|
while PersistentVisualizationRunner._worker_process(pid) and monotonic() < deadline:
|
|
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
if PersistentVisualizationRunner._worker_process(pid):
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.kill(pid, signal.SIGKILL)
|
|
|
|
|
|
except ProcessLookupError:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
def _result(
|
|
|
|
|
|
self,
|
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
2026-07-24 22:07:33 -04:00
|
|
|
|
*,
|
2026-07-24 23:55:55 -04:00
|
|
|
|
port: int,
|
|
|
|
|
|
token: str,
|
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
|
query: str | None,
|
|
|
|
|
|
depth: int,
|
|
|
|
|
|
reused: bool,
|
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"state": "running",
|
|
|
|
|
|
"reused": reused,
|
|
|
|
|
|
"url": self._target_url(
|
|
|
|
|
|
port=port,
|
|
|
|
|
|
token=token,
|
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
depth=depth,
|
|
|
|
|
|
),
|
|
|
|
|
|
"bind": "127.0.0.1",
|
|
|
|
|
|
"port": port,
|
|
|
|
|
|
"template": VISUALIZATION_TEMPLATE,
|
|
|
|
|
|
"read_only": True,
|
|
|
|
|
|
"project_bound": True,
|
|
|
|
|
|
"lifetime": {
|
|
|
|
|
|
"policy": "explicit_stop",
|
|
|
|
|
|
"stop_tool": "docforge_stop_visualization",
|
|
|
|
|
|
},
|
|
|
|
|
|
"target": {"node_id": node_id, "query": query, "depth": depth},
|
|
|
|
|
|
"snapshot": dict(snapshot.identity),
|
|
|
|
|
|
}
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
|
)
|
|
|
|
|
|
snapshot = VisualizationIndexSnapshot(self.index, self.index.check())
|
|
|
|
|
|
if type(depth) is not int or depth < 1 or depth > snapshot.max_depth:
|
|
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"invalid_depth",
|
|
|
|
|
|
"Visualization depth is outside the configured traversal limit",
|
|
|
|
|
|
)
|
|
|
|
|
|
if node_id is not None:
|
|
|
|
|
|
snapshot.require_node(node_id)
|
|
|
|
|
|
elif query is not None:
|
|
|
|
|
|
snapshot.search(query=query, family=None, limit=1)
|
|
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
|
with self._locked_registry():
|
|
|
|
|
|
existing = self._read_registry()
|
|
|
|
|
|
if existing is not None and self._is_live(existing, snapshot):
|
|
|
|
|
|
return self._result(
|
|
|
|
|
|
snapshot,
|
|
|
|
|
|
port=cast(int, existing["port"]),
|
|
|
|
|
|
token=cast(str, existing["token"]),
|
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
depth=depth,
|
|
|
|
|
|
reused=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
if existing is not None:
|
|
|
|
|
|
self._terminate_worker(cast(int, existing["pid"]))
|
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
|
return self._launch(snapshot, node_id=node_id, query=query, depth=depth)
|
|
|
|
|
|
|
|
|
|
|
|
def _launch(
|
|
|
|
|
|
self,
|
|
|
|
|
|
snapshot: VisualizationIndexSnapshot,
|
|
|
|
|
|
*,
|
|
|
|
|
|
node_id: str | None,
|
|
|
|
|
|
query: str | None,
|
|
|
|
|
|
depth: int,
|
|
|
|
|
|
) -> dict[str, object]:
|
2026-07-24 22:07:33 -04:00
|
|
|
|
parent_socket, child_socket = socket.socketpair()
|
2026-07-24 23:55:55 -04:00
|
|
|
|
process_id: int | None = None
|
|
|
|
|
|
token = secrets.token_urlsafe(24)
|
2026-07-24 22:07:33 -04:00
|
|
|
|
try:
|
|
|
|
|
|
command = (
|
|
|
|
|
|
sys.executable,
|
|
|
|
|
|
"-m",
|
|
|
|
|
|
"docforge.visualization_worker",
|
|
|
|
|
|
"--control-fd",
|
|
|
|
|
|
str(child_socket.fileno()),
|
|
|
|
|
|
)
|
2026-07-24 23:55:55 -04:00
|
|
|
|
child_socket.set_inheritable(True)
|
|
|
|
|
|
process_id = os.posix_spawn(
|
|
|
|
|
|
sys.executable,
|
2026-07-24 22:07:33 -04:00
|
|
|
|
command,
|
2026-07-24 23:55:55 -04:00
|
|
|
|
os.environ,
|
|
|
|
|
|
setsid=True,
|
2026-07-24 22:07:33 -04:00
|
|
|
|
)
|
|
|
|
|
|
child_socket.close()
|
|
|
|
|
|
request = {
|
|
|
|
|
|
"snapshot": snapshot.spec(),
|
2026-07-24 23:55:55 -04:00
|
|
|
|
"token": token,
|
|
|
|
|
|
"target": {"node_id": node_id, "query": query, "depth": depth},
|
2026-07-24 22:07:33 -04:00
|
|
|
|
}
|
|
|
|
|
|
parent_socket.settimeout(10)
|
|
|
|
|
|
parent_socket.sendall(
|
|
|
|
|
|
json.dumps(request, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
response = _receive_worker_response(parent_socket)
|
2026-07-24 23:55:55 -04:00
|
|
|
|
if response.get("status") != "ok" or not isinstance(
|
|
|
|
|
|
response.get("visualization"), dict
|
|
|
|
|
|
):
|
|
|
|
|
|
raise ValueError("Visualization worker rejected the snapshot")
|
|
|
|
|
|
visualization = cast(dict[str, object], response["visualization"])
|
|
|
|
|
|
port = visualization.get("port")
|
|
|
|
|
|
if type(port) is not int or not 1 <= port <= 65535:
|
|
|
|
|
|
raise ValueError("Visualization worker returned an invalid port")
|
|
|
|
|
|
self._write_registry(
|
|
|
|
|
|
{
|
|
|
|
|
|
"runtime": VISUALIZATION_RUNTIME,
|
|
|
|
|
|
"template": VISUALIZATION_TEMPLATE,
|
|
|
|
|
|
"pid": process_id,
|
|
|
|
|
|
"port": port,
|
|
|
|
|
|
"token": token,
|
|
|
|
|
|
"snapshot": dict(snapshot.identity),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._result(
|
|
|
|
|
|
snapshot,
|
|
|
|
|
|
port=port,
|
|
|
|
|
|
token=token,
|
|
|
|
|
|
node_id=node_id,
|
|
|
|
|
|
query=query,
|
|
|
|
|
|
depth=depth,
|
|
|
|
|
|
reused=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
except (OSError, ValueError) as error:
|
|
|
|
|
|
if process_id is not None:
|
|
|
|
|
|
self._terminate_worker(process_id)
|
2026-07-24 22:07:33 -04:00
|
|
|
|
raise DocForgeError(
|
|
|
|
|
|
"visualization_unavailable",
|
2026-07-24 23:55:55 -04:00
|
|
|
|
"The persistent visualization worker failed to start",
|
2026-07-24 22:07:33 -04:00
|
|
|
|
) from error
|
|
|
|
|
|
finally:
|
|
|
|
|
|
child_socket.close()
|
|
|
|
|
|
parent_socket.close()
|
|
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
|
def stop(self) -> dict[str, object]:
|
|
|
|
|
|
with self._locked_registry():
|
|
|
|
|
|
existing = self._read_registry()
|
|
|
|
|
|
if existing is None:
|
|
|
|
|
|
return self._stop_result("not_running")
|
|
|
|
|
|
self._terminate_worker(cast(int, existing["pid"]))
|
|
|
|
|
|
self._remove_registry()
|
|
|
|
|
|
return self._stop_result("stopped")
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
2026-07-24 23:55:55 -04:00
|
|
|
|
def _stop_result(self, state: str) -> dict[str, object]:
|
|
|
|
|
|
descriptor = self.index.project.descriptor
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "ok",
|
|
|
|
|
|
"state": state,
|
|
|
|
|
|
"project_id": descriptor.project_id,
|
|
|
|
|
|
"project_root_fingerprint": project_root_fingerprint(descriptor.root),
|
|
|
|
|
|
"adapter": descriptor.adapter,
|
|
|
|
|
|
}
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _receive_worker_response(control: socket.socket) -> dict[str, object]:
|
|
|
|
|
|
chunks: list[bytes] = []
|
|
|
|
|
|
size = 0
|
|
|
|
|
|
while True:
|
|
|
|
|
|
chunk = control.recv(65536)
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
chunks.append(chunk)
|
|
|
|
|
|
size += len(chunk)
|
|
|
|
|
|
if size > 1_000_000:
|
|
|
|
|
|
raise ValueError("Visualization worker response exceeded its fixed boundary")
|
|
|
|
|
|
if b"\n" in chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
payload = b"".join(chunks).split(b"\n", 1)[0]
|
|
|
|
|
|
result = json.loads(payload)
|
|
|
|
|
|
if not isinstance(result, dict):
|
|
|
|
|
|
raise ValueError("Visualization worker returned an invalid response")
|
2026-07-24 22:26:01 -04:00
|
|
|
|
return cast(dict[str, object], result)
|
2026-07-24 22:07:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 22:36:44 -04:00
|
|
|
|
_GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
2026-07-24 16:01:03 -04:00
|
|
|
|
<html lang="en">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
|
<title>DocForge graph</title>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22/%3E">
|
2026-07-24 16:01:03 -04:00
|
|
|
|
<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; }
|
2026-07-24 23:15:57 -04:00
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
|
|
|
|
body { margin: 0; background: var(--bg); color: var(--text); }
|
2026-07-24 16:01:03 -04:00
|
|
|
|
button, input, select { font: inherit; }
|
|
|
|
|
|
button { cursor: pointer; }
|
|
|
|
|
|
code, pre { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.app { display: grid; grid-template-rows: auto minmax(0, 1fr); height: 100dvh; }
|
2026-07-24 16:01:03 -04:00
|
|
|
|
header {
|
2026-07-24 23:15:57 -04:00
|
|
|
|
display: flex; gap: 14px; align-items: center; padding: 10px 14px;
|
2026-07-24 16:01:03 -04:00
|
|
|
|
border-bottom: 1px solid var(--line); background: rgba(8, 18, 30, .96);
|
|
|
|
|
|
}
|
|
|
|
|
|
header h1 { margin: 0; font-size: 17px; }
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.view-switch {
|
|
|
|
|
|
position: relative; display: grid; grid-template-columns: repeat(2, 58px);
|
|
|
|
|
|
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
|
|
|
|
|
|
background: #08131f; isolation: isolate;
|
|
|
|
|
|
}
|
|
|
|
|
|
.view-switch::before {
|
|
|
|
|
|
content: ""; position: absolute; z-index: -1; top: 3px; left: 3px;
|
|
|
|
|
|
width: 58px; height: calc(100% - 6px); border-radius: 6px;
|
|
|
|
|
|
background: #1b536b; box-shadow: 0 0 14px rgba(81, 215, 255, .18);
|
|
|
|
|
|
transition: transform .18s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.view-switch[data-mode="flow"]::before { transform: translateX(58px); }
|
|
|
|
|
|
.view-switch button {
|
|
|
|
|
|
min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px;
|
|
|
|
|
|
background: transparent; color: var(--muted); font-size: 12px; font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.view-switch button[aria-pressed="true"] { color: var(--text); }
|
|
|
|
|
|
.view-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
2026-07-24 16:01:03 -04:00
|
|
|
|
.stats { display: flex; gap: 14px; color: var(--muted); }
|
|
|
|
|
|
.status { margin-left: auto; color: var(--muted); }
|
2026-07-24 21:43:11 -04:00
|
|
|
|
.layout {
|
2026-07-24 23:15:57 -04:00
|
|
|
|
min-height: 0; overflow: hidden; display: grid;
|
2026-07-24 21:43:11 -04:00
|
|
|
|
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
aside { min-height: 0; overflow: hidden; padding: 16px; background: var(--panel); }
|
|
|
|
|
|
.left, .right { display: flex; flex-direction: column; }
|
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 23:15:57 -04:00
|
|
|
|
form { display: grid; flex: 0 0 auto; gap: 8px; }
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.results-context {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
|
|
|
|
min-height: 30px; margin-top: 12px; color: var(--muted); font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.results-context strong {
|
|
|
|
|
|
overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
#clear-result-filter {
|
|
|
|
|
|
flex: 0 0 auto; border: 1px solid var(--line); border-radius: 6px; padding: 3px 7px;
|
|
|
|
|
|
background: var(--panel-2); color: var(--muted); font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
#clear-result-filter[hidden] { display: none; }
|
|
|
|
|
|
.results {
|
|
|
|
|
|
display: grid; align-content: start; gap: 7px; min-height: 0;
|
|
|
|
|
|
margin-top: 8px; overflow: auto; padding-right: 2px;
|
|
|
|
|
|
}
|
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;
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.neighborhood {
|
|
|
|
|
|
display: flex; flex: 1; flex-direction: column; min-height: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.neighborhood[hidden] { display: none; }
|
|
|
|
|
|
.neighborhood-title { margin: 0; font-size: 15px; }
|
|
|
|
|
|
#neighborhood-sections { min-height: 0; overflow: auto; padding-right: 2px; }
|
2026-07-24 21:43:11 -04:00
|
|
|
|
.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; }
|
2026-07-24 23:40:47 -04:00
|
|
|
|
.node-legend {
|
2026-07-24 23:15:57 -04:00
|
|
|
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 12px;
|
|
|
|
|
|
padding-bottom: 4px;
|
2026-07-24 21:43:11 -04:00
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
.node-legend span {
|
2026-07-24 21:43:11 -04:00
|
|
|
|
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
|
|
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
.node-legend span[hidden] { display: none; }
|
|
|
|
|
|
.node-legend i { width: 8px; height: 8px; border-radius: 50%; }
|
|
|
|
|
|
.node-legend-primary i { background: var(--primary-stroke); }
|
|
|
|
|
|
.node-legend-child i { background: var(--child-stroke); }
|
|
|
|
|
|
.node-legend-edge i { background: var(--edge-stroke); }
|
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; }
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
|
|
|
|
|
|
svg { width: 100%; height: 100%; 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 23:40:47 -04:00
|
|
|
|
.relationship-key {
|
|
|
|
|
|
position: absolute; z-index: 2; top: 12px; left: 12px;
|
|
|
|
|
|
width: min(310px, calc(100% - 100px)); max-height: calc(100% - 64px);
|
|
|
|
|
|
overflow: hidden; border: 1px solid var(--line); border-radius: 10px;
|
|
|
|
|
|
background: rgba(7, 16, 26, .92); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
|
|
|
|
|
|
}
|
|
|
|
|
|
.relationship-key summary {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
|
|
|
|
|
padding: 8px 10px; color: var(--text); cursor: pointer; font-size: 11px;
|
|
|
|
|
|
font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
|
|
|
|
|
}
|
|
|
|
|
|
.relationship-key summary::marker { color: var(--accent); }
|
|
|
|
|
|
.relationship-key-count {
|
|
|
|
|
|
color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: 0;
|
|
|
|
|
|
text-transform: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.relationship-key-list {
|
|
|
|
|
|
display: grid; gap: 5px; max-height: min(360px, calc(100vh - 190px));
|
|
|
|
|
|
overflow: auto; margin: 0; padding: 2px 10px 10px; list-style: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.relationship-key-item {
|
|
|
|
|
|
display: grid; grid-template-columns: 58px minmax(0, 1fr) auto;
|
|
|
|
|
|
gap: 8px; align-items: center; color: var(--text); font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.relationship-symbol { display: block; width: 58px; height: 14px; overflow: visible; }
|
|
|
|
|
|
.relationship-key-item small { color: var(--muted); }
|
|
|
|
|
|
.relationship-key-empty { margin: 2px 0; color: var(--muted); font-size: 11px; }
|
|
|
|
|
|
.relationship-edge {
|
|
|
|
|
|
fill: none; stroke-opacity: .8; stroke-width: 1.7;
|
|
|
|
|
|
vector-effect: non-scaling-stroke;
|
|
|
|
|
|
}
|
|
|
|
|
|
.edge-label {
|
|
|
|
|
|
font-size: 9px; font-weight: 650; pointer-events: none;
|
|
|
|
|
|
paint-order: stroke; stroke: #07101a; stroke-width: 3px; stroke-linejoin: round;
|
|
|
|
|
|
}
|
2026-07-24 16:09:56 -04:00
|
|
|
|
.node { cursor: pointer; }
|
2026-07-24 22:54:19 -04:00
|
|
|
|
.node:focus { outline: none; }
|
|
|
|
|
|
.node > circle:not(.selection-ring) {
|
|
|
|
|
|
stroke-width: 1.8; transition: stroke-width .15s, filter .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.node.root > circle:not(.selection-ring) {
|
|
|
|
|
|
stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24));
|
|
|
|
|
|
}
|
|
|
|
|
|
.node:hover > circle:not(.selection-ring) { stroke: #fff; stroke-width: 3; }
|
|
|
|
|
|
.node .selection-ring {
|
|
|
|
|
|
fill: none; stroke: #ffd166; stroke-width: 0; opacity: 0;
|
|
|
|
|
|
pointer-events: none; vector-effect: non-scaling-stroke;
|
|
|
|
|
|
transition: opacity .15s, stroke-width .15s;
|
|
|
|
|
|
}
|
|
|
|
|
|
.node.selected .selection-ring, .node:focus-visible .selection-ring {
|
|
|
|
|
|
stroke-width: 2.5; opacity: 1; filter: drop-shadow(0 0 5px rgba(255, 209, 102, .7));
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
.badge-button { background: #0d2031; }
|
|
|
|
|
|
.badge-button:hover, .badge-button:focus-visible {
|
|
|
|
|
|
border-color: var(--accent); color: var(--text); outline: none;
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
.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-25 00:49:17 -04:00
|
|
|
|
width: fit-content; height: fit-content;
|
|
|
|
|
|
min-width: min(360px, calc(100vw - 20px)); min-height: 0;
|
|
|
|
|
|
max-width: min(760px, calc(100vw - 32px)); max-height: calc(100vh - 32px);
|
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-25 00:49:17 -04:00
|
|
|
|
display: grid; grid-template-rows: auto auto auto; width: fit-content;
|
|
|
|
|
|
min-width: min(360px, calc(100vw - 20px)); max-width: min(760px, calc(100vw - 32px));
|
|
|
|
|
|
max-height: calc(100vh - 32px);
|
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
|
|
|
|
}
|
2026-07-25 00:49:17 -04:00
|
|
|
|
.dialog-head strong { min-width: 0; font-size: 15px; overflow-wrap: anywhere; }
|
2026-07-24 21:01:53 -04:00
|
|
|
|
.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; }
|
2026-07-24 23:15:57 -04:00
|
|
|
|
dialog.compact-dialog {
|
2026-07-25 00:49:17 -04:00
|
|
|
|
position: fixed; inset: auto; margin: 0; width: min(400px, calc(100vw - 24px));
|
|
|
|
|
|
height: auto; min-width: 0; min-height: 0; max-height: min(560px, calc(100vh - 24px));
|
|
|
|
|
|
resize: none; border-color: rgba(91, 137, 169, .78); background: rgba(7, 18, 29, .82);
|
|
|
|
|
|
box-shadow: 0 14px 42px rgba(0, 0, 0, .42);
|
|
|
|
|
|
}
|
|
|
|
|
|
.compact-dialog .dialog-shell { grid-template-rows: auto auto; }
|
|
|
|
|
|
.compact-dialog .dialog-body { padding: 14px 16px; }
|
|
|
|
|
|
.compact-dialog .dialog-body pre { display: none; }
|
|
|
|
|
|
.compact-dialog .detail-head h2 { font-size: 16px; }
|
|
|
|
|
|
.compact-dialog .meta { margin: 12px 0 0; gap: 6px; }
|
|
|
|
|
|
.compact-dialog .meta div { grid-template-columns: 66px 1fr; }
|
2026-07-24 21:01:53 -04:00
|
|
|
|
.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-25 00:49:17 -04:00
|
|
|
|
.compact-dialog .dialog-actions { padding: 9px 12px; background: rgba(12, 35, 51, .72); }
|
2026-07-24 16:01:03 -04:00
|
|
|
|
.error { color: #ff9aac; }
|
|
|
|
|
|
@media (max-width: 980px) {
|
2026-07-24 23:15:57 -04:00
|
|
|
|
:root { --left-width: 240px; --right-width: 260px; }
|
|
|
|
|
|
.layout {
|
|
|
|
|
|
grid-template-columns: var(--left-width) 7px minmax(280px, 1fr) 7px var(--right-width);
|
|
|
|
|
|
}
|
|
|
|
|
|
.stats { display: none; }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
|
:root { --left-width: 220px; --right-width: 240px; }
|
|
|
|
|
|
header h1 { font-size: 14px; }
|
|
|
|
|
|
.status { display: none; }
|
|
|
|
|
|
.layout {
|
|
|
|
|
|
grid-template-columns: var(--left-width) 5px minmax(240px, 1fr) 5px var(--right-width);
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="app">
|
|
|
|
|
|
<header>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<div class="view-switch" id="view-switch" data-mode="nodes"
|
|
|
|
|
|
role="group" aria-label="Graph view">
|
|
|
|
|
|
<button id="view-nodes" type="button" aria-pressed="true">Nodes</button>
|
|
|
|
|
|
<button id="view-flow" type="button" aria-pressed="false">Flow</button>
|
|
|
|
|
|
</div>
|
2026-07-24 16:01:03 -04:00
|
|
|
|
<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">
|
2026-07-24 22:36:44 -04:00
|
|
|
|
<aside class="left" aria-label="Graph navigation">
|
2026-07-24 16:01:03 -04:00
|
|
|
|
<form id="search-form">
|
|
|
|
|
|
<label for="search">Find nodes</label>
|
|
|
|
|
|
<div class="search-row">
|
2026-07-24 22:36:44 -04:00
|
|
|
|
<input id="search" name="q" type="search" autocomplete="off"
|
|
|
|
|
|
placeholder="title, symbol, path…">
|
2026-07-24 16:01:03 -04:00
|
|
|
|
<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>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<div class="results-context">
|
|
|
|
|
|
<strong id="results-label">All nodes</strong>
|
|
|
|
|
|
<button id="clear-result-filter" type="button" hidden>Clear filter</button>
|
2026-07-24 21:43:11 -04:00
|
|
|
|
</div>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<div class="results" id="results"></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 22:36:44 -04:00
|
|
|
|
<div class="viewport-controls" role="group" aria-label="Graph viewport controls">
|
2026-07-24 16:09:56 -04:00
|
|
|
|
<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 23:40:47 -04:00
|
|
|
|
<details class="relationship-key" id="relationship-key" open>
|
|
|
|
|
|
<summary>
|
|
|
|
|
|
<span>Relationships</span>
|
|
|
|
|
|
<span class="relationship-key-count" id="relationship-key-count">0 visible</span>
|
|
|
|
|
|
</summary>
|
|
|
|
|
|
<ul class="relationship-key-list" id="relationship-key-list"
|
|
|
|
|
|
aria-label="Visible relationship color and symbol key"></ul>
|
|
|
|
|
|
</details>
|
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">
|
2026-07-24 23:15:57 -04:00
|
|
|
|
Left-click descriptor · right-click full inspector · Space centers selection
|
2026-07-24 21:01:53 -04:00
|
|
|
|
</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"
|
2026-07-24 23:15:57 -04:00
|
|
|
|
aria-label="Resize neighborhood panel" aria-orientation="vertical"
|
2026-07-24 21:43:11 -04:00
|
|
|
|
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<aside class="right" aria-label="Neighborhood navigation">
|
|
|
|
|
|
<div class="neighborhood" id="neighborhood" hidden>
|
|
|
|
|
|
<h2 class="neighborhood-title">Neighborhood</h2>
|
2026-07-24 23:40:47 -04:00
|
|
|
|
<div class="node-legend" role="group" aria-label="Node role colors">
|
|
|
|
|
|
<span class="node-legend-primary"><i></i><b id="primary-role-label">Focus</b></span>
|
|
|
|
|
|
<span class="node-legend-child"><i></i><b id="child-role-label">Outgoing</b></span>
|
|
|
|
|
|
<span class="node-legend-edge"><i></i><b id="edge-role-label">Incoming</b></span>
|
2026-07-24 23:15:57 -04:00
|
|
|
|
</div>
|
|
|
|
|
|
<div id="neighborhood-sections"></div>
|
2026-07-24 21:01:53 -04:00
|
|
|
|
</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-25 00:49:17 -04:00
|
|
|
|
<dialog class="compact-dialog" id="node-card" aria-label="Selected node descriptor">
|
2026-07-24 23:15:57 -04:00
|
|
|
|
<div class="dialog-shell">
|
|
|
|
|
|
<div class="dialog-body" id="node-card-details"></div>
|
|
|
|
|
|
<div class="dialog-actions">
|
|
|
|
|
|
<button class="button" id="explore-card-node" type="button">Explore neighborhood</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,
|
2026-07-24 23:15:57 -04:00
|
|
|
|
mode: "nodes",
|
2026-07-24 16:09:56 -04:00
|
|
|
|
depth: 1,
|
|
|
|
|
|
searchLimit: 1,
|
|
|
|
|
|
viewport: {...defaultViewport},
|
2026-07-24 22:54:19 -04:00
|
|
|
|
homeViewport: {...defaultViewport},
|
|
|
|
|
|
positions: new Map(),
|
|
|
|
|
|
selectedNode: null,
|
2026-07-24 16:09:56 -04:00
|
|
|
|
pointer: null,
|
|
|
|
|
|
suppressClick: false,
|
2026-07-24 21:01:53 -04:00
|
|
|
|
inspectedNode: null,
|
2026-07-24 23:15:57 -04:00
|
|
|
|
cardNode: null,
|
2026-07-24 21:43:11 -04:00
|
|
|
|
dialogDrag: null,
|
|
|
|
|
|
leaseTimer: null,
|
2026-07-24 16:09:56 -04:00
|
|
|
|
};
|
2026-07-24 23:40:47 -04:00
|
|
|
|
const relationStyles = Object.freeze({
|
|
|
|
|
|
contains: {
|
|
|
|
|
|
family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow",
|
2026-07-25 00:49:17 -04:00
|
|
|
|
flow: "forward",
|
2026-07-24 23:40:47 -04:00
|
|
|
|
},
|
|
|
|
|
|
defines: {
|
|
|
|
|
|
family: "Structure", color: "#38bdf8", dash: "7 3", marker: "diamond-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
defined_in: {
|
|
|
|
|
|
family: "Structure", color: "#7dd3fc", dash: "3 3", marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
implemented_by: {
|
|
|
|
|
|
family: "Structure", color: "#818cf8", dash: "8 3", marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
calls: {
|
|
|
|
|
|
family: "Execution", color: "#34d399", dash: "", marker: "arrow",
|
|
|
|
|
|
flow: "forward",
|
|
|
|
|
|
},
|
|
|
|
|
|
dispatches_to: {
|
|
|
|
|
|
family: "Execution", color: "#2dd4bf", dash: "9 3", marker: "double-arrow",
|
|
|
|
|
|
flow: "forward",
|
|
|
|
|
|
},
|
|
|
|
|
|
launches: {
|
|
|
|
|
|
family: "Execution", color: "#a3e635", dash: "11 4", marker: "arrow",
|
|
|
|
|
|
flow: "forward",
|
|
|
|
|
|
},
|
|
|
|
|
|
activates: {
|
|
|
|
|
|
family: "Execution", color: "#facc15", dash: "4 3", marker: "double-arrow",
|
|
|
|
|
|
flow: "forward",
|
|
|
|
|
|
},
|
|
|
|
|
|
reads: {
|
|
|
|
|
|
family: "Data", color: "#22d3ee", dash: "3 4", marker: "circle-arrow",
|
|
|
|
|
|
flow: "reverse",
|
|
|
|
|
|
},
|
|
|
|
|
|
writes: {
|
|
|
|
|
|
family: "Data", color: "#fb7185", dash: "", marker: "square-arrow",
|
|
|
|
|
|
flow: "forward",
|
|
|
|
|
|
},
|
|
|
|
|
|
imports: {
|
|
|
|
|
|
family: "Dependency", color: "#fbbf24", dash: "3 3", marker: "open-arrow",
|
|
|
|
|
|
flow: "reverse",
|
|
|
|
|
|
},
|
|
|
|
|
|
depends_on: {
|
|
|
|
|
|
family: "Dependency", color: "#f59e0b", dash: "9 4", marker: "open-arrow",
|
|
|
|
|
|
flow: "reverse",
|
|
|
|
|
|
},
|
|
|
|
|
|
tested_by: {
|
|
|
|
|
|
family: "Evidence", color: "#c084fc", dash: "2 4", marker: "circle-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
verifies: {
|
|
|
|
|
|
family: "Evidence", color: "#a78bfa", dash: "2 4", marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
documents: {
|
|
|
|
|
|
family: "Evidence", color: "#e879f9", dash: "2 5", marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
governs: {
|
|
|
|
|
|
family: "Evidence", color: "#f472b6", dash: "8 3 2 3", marker: "diamond-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
relates_to: {
|
|
|
|
|
|
family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const fallbackRelationColors = Object.freeze([
|
|
|
|
|
|
"#67e8f9", "#86efac", "#fde047", "#fdba74", "#f0abfc", "#a5b4fc",
|
|
|
|
|
|
]);
|
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 23:40:47 -04:00
|
|
|
|
function relationHash(relation) {
|
|
|
|
|
|
let value = 2166136261;
|
|
|
|
|
|
for (const character of relation) {
|
|
|
|
|
|
value ^= character.codePointAt(0);
|
|
|
|
|
|
value = Math.imul(value, 16777619);
|
|
|
|
|
|
}
|
|
|
|
|
|
return value >>> 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
function relationStyle(relation) {
|
|
|
|
|
|
if (relationStyles[relation]) return relationStyles[relation];
|
|
|
|
|
|
return {
|
|
|
|
|
|
family: "Other",
|
|
|
|
|
|
color: fallbackRelationColors[relationHash(relation) % fallbackRelationColors.length],
|
|
|
|
|
|
dash: "6 4",
|
|
|
|
|
|
marker: "open-arrow",
|
|
|
|
|
|
flow: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
function relationLabel(relation) {
|
|
|
|
|
|
return relation.replaceAll("_", " ");
|
|
|
|
|
|
}
|
|
|
|
|
|
function relationMarkerId(relation) {
|
|
|
|
|
|
return `relation-marker-${relationHash(relation).toString(36)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
function markerArtwork(marker, color) {
|
|
|
|
|
|
const group = svgElement("g", {"aria-hidden": "true"});
|
|
|
|
|
|
const path = (data, attributes = {}) => group.append(svgElement("path", {
|
|
|
|
|
|
d: data,
|
|
|
|
|
|
...attributes,
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (marker === "open-arrow") {
|
|
|
|
|
|
path("M1 1 L9 5 L1 9", {
|
|
|
|
|
|
fill: "none", stroke: color, "stroke-width": "1.8",
|
|
|
|
|
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (marker === "double-arrow") {
|
|
|
|
|
|
path("M1 1 L5 5 L1 9 M5 1 L9 5 L5 9", {
|
|
|
|
|
|
fill: "none", stroke: color, "stroke-width": "1.6",
|
|
|
|
|
|
"stroke-linecap": "round", "stroke-linejoin": "round",
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (marker === "diamond-arrow") {
|
|
|
|
|
|
path("M0 5 L3 2 L6 5 L3 8 Z", {fill: color});
|
|
|
|
|
|
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
|
|
|
|
|
} else if (marker === "circle-arrow") {
|
|
|
|
|
|
group.append(svgElement("circle", {
|
|
|
|
|
|
cx: "3.5", cy: "5", r: "2.3", fill: "none", stroke: color,
|
|
|
|
|
|
"stroke-width": "1.4",
|
|
|
|
|
|
}));
|
|
|
|
|
|
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
|
|
|
|
|
} else if (marker === "square-arrow") {
|
|
|
|
|
|
group.append(svgElement("rect", {
|
|
|
|
|
|
x: "1", y: "2.5", width: "5", height: "5", rx: ".7", fill: color,
|
|
|
|
|
|
}));
|
|
|
|
|
|
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
path("M1 1 L10 5 L1 9 Z", {fill: color});
|
|
|
|
|
|
}
|
|
|
|
|
|
return group;
|
|
|
|
|
|
}
|
|
|
|
|
|
function appendRelationMarker(defs, relation) {
|
|
|
|
|
|
const style = relationStyle(relation);
|
|
|
|
|
|
const marker = svgElement("marker", {
|
|
|
|
|
|
id: relationMarkerId(relation),
|
|
|
|
|
|
viewBox: "0 0 11 10",
|
|
|
|
|
|
refX: "10",
|
|
|
|
|
|
refY: "5",
|
|
|
|
|
|
markerWidth: "11",
|
|
|
|
|
|
markerHeight: "10",
|
|
|
|
|
|
orient: "auto",
|
|
|
|
|
|
markerUnits: "userSpaceOnUse",
|
|
|
|
|
|
});
|
|
|
|
|
|
marker.append(markerArtwork(style.marker, style.color));
|
|
|
|
|
|
defs.append(marker);
|
|
|
|
|
|
}
|
|
|
|
|
|
function relationSymbol(relation) {
|
|
|
|
|
|
const style = relationStyle(relation);
|
|
|
|
|
|
const svg = svgElement("svg", {
|
|
|
|
|
|
viewBox: "0 0 58 14",
|
|
|
|
|
|
class: "relationship-symbol",
|
|
|
|
|
|
role: "img",
|
|
|
|
|
|
"aria-label": `${relationLabel(relation)} relationship symbol`,
|
|
|
|
|
|
});
|
|
|
|
|
|
const line = svgElement("line", {
|
|
|
|
|
|
x1: "2", y1: "7", x2: "43", y2: "7",
|
|
|
|
|
|
stroke: style.color, "stroke-width": "2",
|
|
|
|
|
|
});
|
|
|
|
|
|
if (style.dash) line.setAttribute("stroke-dasharray", style.dash);
|
|
|
|
|
|
const marker = svgElement("g", {transform: "translate(45 2) scale(.9)"});
|
|
|
|
|
|
marker.append(markerArtwork(style.marker, style.color));
|
|
|
|
|
|
svg.append(line, marker);
|
|
|
|
|
|
return svg;
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderRelationshipKey(edges) {
|
|
|
|
|
|
const counts = new Map();
|
|
|
|
|
|
for (const edge of edges) {
|
|
|
|
|
|
counts.set(edge.relation, (counts.get(edge.relation) || 0) + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
const entries = [...counts.entries()].sort((first, second) => {
|
|
|
|
|
|
const firstStyle = relationStyle(first[0]);
|
|
|
|
|
|
const secondStyle = relationStyle(second[0]);
|
|
|
|
|
|
return firstStyle.family.localeCompare(secondStyle.family)
|
|
|
|
|
|
|| first[0].localeCompare(second[0]);
|
|
|
|
|
|
});
|
|
|
|
|
|
const container = $("relationship-key-list");
|
|
|
|
|
|
container.replaceChildren();
|
|
|
|
|
|
$("relationship-key-count").textContent = `${entries.length} visible`;
|
|
|
|
|
|
if (!entries.length) {
|
|
|
|
|
|
const empty = document.createElement("p");
|
|
|
|
|
|
empty.className = "relationship-key-empty";
|
|
|
|
|
|
empty.textContent = state.mode === "flow"
|
|
|
|
|
|
? "No flow-capable relationships reach this focus."
|
|
|
|
|
|
: "No relationships in this neighborhood.";
|
|
|
|
|
|
container.append(empty);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [relation, count] of entries) {
|
|
|
|
|
|
const style = relationStyle(relation);
|
|
|
|
|
|
const item = document.createElement("li");
|
|
|
|
|
|
item.className = "relationship-key-item";
|
|
|
|
|
|
const label = document.createElement("span");
|
|
|
|
|
|
label.textContent = relationLabel(relation);
|
|
|
|
|
|
label.title = `${style.family} relationship`;
|
|
|
|
|
|
const total = document.createElement("small");
|
|
|
|
|
|
total.textContent = String(count);
|
|
|
|
|
|
item.append(relationSymbol(relation), label, total);
|
|
|
|
|
|
container.append(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
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() {
|
2026-07-24 22:54:19 -04:00
|
|
|
|
state.viewport = {...state.homeViewport};
|
2026-07-24 16:09:56 -04:00
|
|
|
|
applyViewport();
|
|
|
|
|
|
}
|
2026-07-24 22:54:19 -04:00
|
|
|
|
function viewportForPositions(positions) {
|
|
|
|
|
|
const points = [...positions.values()];
|
|
|
|
|
|
if (!points.length) return {...defaultViewport};
|
|
|
|
|
|
const xs = points.map((point) => point.x);
|
|
|
|
|
|
const ys = points.map((point) => point.y);
|
|
|
|
|
|
const minimumX = Math.min(...xs);
|
|
|
|
|
|
const maximumX = Math.max(...xs);
|
|
|
|
|
|
const minimumY = Math.min(...ys);
|
|
|
|
|
|
const maximumY = Math.max(...ys);
|
|
|
|
|
|
const aspect = defaultViewport.height / defaultViewport.width;
|
|
|
|
|
|
const contentWidth = maximumX - minimumX + 260;
|
|
|
|
|
|
const contentHeight = maximumY - minimumY + 220;
|
|
|
|
|
|
const width = Math.max(440, contentWidth, contentHeight / aspect);
|
|
|
|
|
|
const height = width * aspect;
|
|
|
|
|
|
const centerX = (minimumX + maximumX) / 2;
|
|
|
|
|
|
const centerY = (minimumY + maximumY) / 2;
|
|
|
|
|
|
return {
|
|
|
|
|
|
x: centerX - width / 2,
|
|
|
|
|
|
y: centerY - height / 2,
|
|
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
function viewportCenteredOn(point, viewport) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
...viewport,
|
|
|
|
|
|
x: point.x - viewport.width / 2,
|
|
|
|
|
|
y: point.y - viewport.height / 2,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
function selectNode(nodeId) {
|
|
|
|
|
|
if (!state.positions.has(nodeId)) return false;
|
|
|
|
|
|
state.selectedNode = nodeId;
|
|
|
|
|
|
for (const group of $("graph").querySelectorAll(".node")) {
|
|
|
|
|
|
const selected = group.dataset.nodeId === nodeId;
|
|
|
|
|
|
group.classList.toggle("selected", selected);
|
|
|
|
|
|
group.setAttribute("aria-pressed", String(selected));
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
function centerSelectedNode() {
|
|
|
|
|
|
const point = state.positions.get(state.selectedNode);
|
|
|
|
|
|
if (!point) return false;
|
|
|
|
|
|
state.viewport = viewportCenteredOn(point, state.viewport);
|
|
|
|
|
|
applyViewport();
|
|
|
|
|
|
setStatus(`Centered ${state.selectedNode}`);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-07-24 16:09:56 -04:00
|
|
|
|
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 23:15:57 -04:00
|
|
|
|
function setResultsContext(label, filtered = false) {
|
|
|
|
|
|
$("results-label").textContent = label;
|
|
|
|
|
|
$("clear-result-filter").hidden = !filtered;
|
|
|
|
|
|
}
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
]));
|
|
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
function buildFlowGraph(data) {
|
2026-07-25 01:05:45 -04:00
|
|
|
|
// The server has already supplied the complete directed ancestry for this
|
|
|
|
|
|
// focus. Keep every stored edge as-is: source -> target. Flow must show
|
|
|
|
|
|
// what literally leads to the selected terminal, not infer an alternate
|
|
|
|
|
|
// direction from a relationship label.
|
|
|
|
|
|
const lineageEdges = data.edges;
|
2026-07-24 23:40:47 -04:00
|
|
|
|
const upstreamHops = new Map([[data.root, 0]]);
|
|
|
|
|
|
let frontier = [data.root];
|
|
|
|
|
|
while (frontier.length) {
|
|
|
|
|
|
const next = [];
|
|
|
|
|
|
for (const targetId of frontier) {
|
2026-07-25 01:05:45 -04:00
|
|
|
|
for (const edge of lineageEdges) {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
if (edge.target_id !== targetId || upstreamHops.has(edge.source_id)) continue;
|
|
|
|
|
|
upstreamHops.set(edge.source_id, upstreamHops.get(targetId) + 1);
|
|
|
|
|
|
next.push(edge.source_id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
frontier = next;
|
|
|
|
|
|
}
|
|
|
|
|
|
const nodes = data.nodes.filter((node) => upstreamHops.has(node.node_id));
|
|
|
|
|
|
const nodeIds = new Set(nodes.map((node) => node.node_id));
|
2026-07-25 01:05:45 -04:00
|
|
|
|
const edges = lineageEdges.filter(
|
2026-07-24 23:40:47 -04:00
|
|
|
|
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
|
|
|
|
|
|
);
|
|
|
|
|
|
const topology = new Map(nodes.map((node) => [
|
|
|
|
|
|
node.node_id,
|
|
|
|
|
|
{
|
|
|
|
|
|
hop: upstreamHops.get(node.node_id),
|
|
|
|
|
|
role: node.node_id === data.root ? "primary" : "child",
|
|
|
|
|
|
},
|
|
|
|
|
|
]));
|
|
|
|
|
|
return {
|
|
|
|
|
|
...data,
|
|
|
|
|
|
nodes,
|
|
|
|
|
|
edges,
|
|
|
|
|
|
topology,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-07-24 21:43:11 -04:00
|
|
|
|
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 23:40:47 -04:00
|
|
|
|
function layoutFlow(nodes, rootId, topology) {
|
|
|
|
|
|
const layers = new Map();
|
|
|
|
|
|
for (const node of nodes) {
|
|
|
|
|
|
const hop = topology.get(node.node_id).hop;
|
|
|
|
|
|
if (!layers.has(hop)) layers.set(hop, []);
|
|
|
|
|
|
layers.get(hop).push(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
const positions = new Map();
|
|
|
|
|
|
for (const [hop, layer] of [...layers.entries()].sort((a, b) => a[0] - b[0])) {
|
|
|
|
|
|
layer.sort((first, second) => first.node_id.localeCompare(second.node_id));
|
|
|
|
|
|
const spacing = 118;
|
|
|
|
|
|
const top = -((layer.length - 1) * spacing) / 2;
|
|
|
|
|
|
layer.forEach((node, index) => {
|
|
|
|
|
|
positions.set(node.node_id, {
|
|
|
|
|
|
x: node.node_id === rootId ? 0 : -hop * 230,
|
|
|
|
|
|
y: top + index * spacing,
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
const sections = state.mode === "flow"
|
|
|
|
|
|
? [
|
|
|
|
|
|
{role: "primary", label: "Flow destination"},
|
|
|
|
|
|
{role: "child", label: "Upstream lineage"},
|
|
|
|
|
|
]
|
|
|
|
|
|
: [
|
|
|
|
|
|
{role: "primary", label: "Focus node"},
|
|
|
|
|
|
{role: "child", label: "Outgoing paths"},
|
|
|
|
|
|
{role: "edge", label: "Incoming & lateral"},
|
|
|
|
|
|
];
|
|
|
|
|
|
$("neighborhood").querySelector(".neighborhood-title").textContent = state.mode === "flow"
|
|
|
|
|
|
? "Upstream flow"
|
|
|
|
|
|
: "Neighborhood";
|
|
|
|
|
|
$("primary-role-label").textContent = state.mode === "flow" ? "Destination" : "Focus";
|
|
|
|
|
|
$("child-role-label").textContent = state.mode === "flow" ? "Upstream" : "Outgoing";
|
|
|
|
|
|
$("edge-role-label").textContent = "Incoming";
|
|
|
|
|
|
$("edge-role-label").closest("span").hidden = state.mode === "flow";
|
2026-07-24 21:43:11 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
function edgeEndpoints(source, target, sourceRadius, targetRadius) {
|
|
|
|
|
|
const deltaX = target.x - source.x;
|
|
|
|
|
|
const deltaY = target.y - source.y;
|
|
|
|
|
|
const distance = Math.hypot(deltaX, deltaY) || 1;
|
|
|
|
|
|
const unitX = deltaX / distance;
|
|
|
|
|
|
const unitY = deltaY / distance;
|
|
|
|
|
|
return {
|
|
|
|
|
|
x1: source.x + unitX * (sourceRadius + 4),
|
|
|
|
|
|
y1: source.y + unitY * (sourceRadius + 4),
|
|
|
|
|
|
x2: target.x - unitX * (targetRadius + 13),
|
|
|
|
|
|
y2: target.y - unitY * (targetRadius + 13),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
function topologyRoleLabel(role) {
|
|
|
|
|
|
if (role === "primary") return state.mode === "flow" ? "flow destination" : "focus node";
|
|
|
|
|
|
if (role === "child") return state.mode === "flow" ? "upstream node" : "outgoing node";
|
|
|
|
|
|
return "incoming or lateral node";
|
|
|
|
|
|
}
|
|
|
|
|
|
function renderGraph(data, preserveSelection = false) {
|
2026-07-24 16:01:03 -04:00
|
|
|
|
state.graph = data;
|
|
|
|
|
|
state.root = data.root;
|
2026-07-24 23:40:47 -04:00
|
|
|
|
const selectedCandidate = preserveSelection
|
|
|
|
|
|
&& data.nodes.some((node) => node.node_id === state.selectedNode)
|
|
|
|
|
|
? state.selectedNode
|
|
|
|
|
|
: data.root;
|
|
|
|
|
|
const view = state.mode === "flow" ? buildFlowGraph(data) : data;
|
|
|
|
|
|
state.selectedNode = view.nodes.some((node) => node.node_id === selectedCandidate)
|
|
|
|
|
|
? selectedCandidate
|
|
|
|
|
|
: view.root;
|
2026-07-24 16:01:03 -04:00
|
|
|
|
const svg = $("graph");
|
|
|
|
|
|
svg.replaceChildren();
|
2026-07-24 23:40:47 -04:00
|
|
|
|
$("empty").hidden = view.nodes.length > 0;
|
|
|
|
|
|
const topology = view.topology || analyzeTopology(view);
|
|
|
|
|
|
const positions = state.mode === "flow"
|
|
|
|
|
|
? layoutFlow(view.nodes, view.root, topology)
|
|
|
|
|
|
: layoutNodes(view.nodes, view.root, topology);
|
2026-07-24 22:54:19 -04:00
|
|
|
|
state.positions = positions;
|
|
|
|
|
|
state.homeViewport = viewportForPositions(positions);
|
|
|
|
|
|
resetViewport();
|
2026-07-24 23:40:47 -04:00
|
|
|
|
renderNeighborhood(view, topology);
|
|
|
|
|
|
renderRelationshipKey(view.edges);
|
|
|
|
|
|
const definitions = svgElement("defs");
|
|
|
|
|
|
for (const relation of new Set(view.edges.map((edge) => edge.relation))) {
|
|
|
|
|
|
appendRelationMarker(definitions, relation);
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
const edgeLayer = svgElement("g");
|
|
|
|
|
|
const nodeLayer = svgElement("g");
|
2026-07-24 23:40:47 -04:00
|
|
|
|
for (const edge of view.edges) {
|
2026-07-24 16:01:03 -04:00
|
|
|
|
const source = positions.get(edge.source_id);
|
|
|
|
|
|
const target = positions.get(edge.target_id);
|
|
|
|
|
|
if (!source || !target) continue;
|
2026-07-24 23:40:47 -04:00
|
|
|
|
const style = relationStyle(edge.relation);
|
|
|
|
|
|
const sourceRadius = edge.source_id === view.root ? 25 : 18;
|
|
|
|
|
|
const targetRadius = edge.target_id === view.root ? 25 : 18;
|
|
|
|
|
|
const points = edgeEndpoints(source, target, sourceRadius, targetRadius);
|
|
|
|
|
|
const line = svgElement("line", {
|
|
|
|
|
|
...points,
|
|
|
|
|
|
class: "relationship-edge",
|
|
|
|
|
|
stroke: style.color,
|
|
|
|
|
|
"marker-end": `url(#${relationMarkerId(edge.relation)})`,
|
|
|
|
|
|
"data-relation": edge.relation,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (style.dash) line.setAttribute("stroke-dasharray", style.dash);
|
|
|
|
|
|
edgeLayer.append(line);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
const label = svgElement("text", {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
x: (points.x1 + points.x2) / 2,
|
|
|
|
|
|
y: (points.y1 + points.y2) / 2 - 5,
|
2026-07-24 16:01:03 -04:00
|
|
|
|
class: "edge-label",
|
2026-07-24 23:40:47 -04:00
|
|
|
|
fill: style.color,
|
|
|
|
|
|
"text-anchor": "middle",
|
2026-07-24 16:01:03 -04:00
|
|
|
|
});
|
2026-07-24 23:40:47 -04:00
|
|
|
|
label.textContent = relationLabel(edge.relation);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
edgeLayer.append(label);
|
|
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
for (const node of view.nodes) {
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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 22:54:19 -04:00
|
|
|
|
class: [
|
|
|
|
|
|
"node",
|
|
|
|
|
|
topologyNode.role,
|
2026-07-24 23:40:47 -04:00
|
|
|
|
node.node_id === view.root ? "root" : "",
|
2026-07-24 22:54:19 -04:00
|
|
|
|
node.node_id === state.selectedNode ? "selected" : "",
|
|
|
|
|
|
].filter(Boolean).join(" "),
|
2026-07-24 16:01:03 -04:00
|
|
|
|
transform: `translate(${point.x} ${point.y})`,
|
2026-07-24 22:54:19 -04:00
|
|
|
|
"data-node-id": node.node_id,
|
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 22:54:19 -04:00
|
|
|
|
"aria-pressed": String(node.node_id === state.selectedNode),
|
2026-07-24 21:43:11 -04:00
|
|
|
|
"aria-label": [
|
2026-07-24 23:40:47 -04:00
|
|
|
|
node.title, node.family, topologyRoleLabel(topologyNode.role),
|
|
|
|
|
|
`${topologyNode.hop} hops`,
|
2026-07-24 21:43:11 -04:00
|
|
|
|
].join(", ")
|
2026-07-24 16:01:03 -04:00
|
|
|
|
});
|
2026-07-24 21:43:11 -04:00
|
|
|
|
group.append(svgElement("circle", {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
r: node.node_id === view.root ? 25 : 18,
|
2026-07-24 21:43:11 -04:00
|
|
|
|
fill: palette.fill,
|
|
|
|
|
|
stroke: palette.stroke,
|
|
|
|
|
|
}));
|
2026-07-24 22:54:19 -04:00
|
|
|
|
group.append(svgElement("circle", {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
r: node.node_id === view.root ? 32 : 25,
|
2026-07-24 22:54:19 -04:00
|
|
|
|
class: "selection-ring",
|
|
|
|
|
|
}));
|
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-25 00:49:17 -04:00
|
|
|
|
group.addEventListener("click", (event) => {
|
2026-07-24 22:54:19 -04:00
|
|
|
|
if (!state.suppressClick) {
|
|
|
|
|
|
selectNode(node.node_id);
|
2026-07-25 00:49:17 -04:00
|
|
|
|
showNodeCard(node.node_id, event);
|
2026-07-24 22:54:19 -04:00
|
|
|
|
}
|
2026-07-24 16:09:56 -04:00
|
|
|
|
});
|
2026-07-24 23:15:57 -04:00
|
|
|
|
group.addEventListener("contextmenu", (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
selectNode(node.node_id);
|
|
|
|
|
|
inspectNode(node.node_id);
|
|
|
|
|
|
});
|
2026-07-24 16:01:03 -04:00
|
|
|
|
group.addEventListener("keydown", (event) => {
|
2026-07-24 22:54:19 -04:00
|
|
|
|
if (event.key === "Enter") {
|
2026-07-24 21:01:53 -04:00
|
|
|
|
event.preventDefault();
|
2026-07-24 22:54:19 -04:00
|
|
|
|
selectNode(node.node_id);
|
2026-07-24 23:15:57 -04:00
|
|
|
|
if (event.shiftKey) inspectNode(node.node_id);
|
|
|
|
|
|
else showNodeCard(node.node_id);
|
2026-07-24 21:01:53 -04:00
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
});
|
|
|
|
|
|
nodeLayer.append(group);
|
|
|
|
|
|
}
|
2026-07-24 23:40:47 -04:00
|
|
|
|
svg.append(definitions, edgeLayer, nodeLayer);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
}
|
2026-07-25 00:49:17 -04:00
|
|
|
|
function renderDetails(details, node, data, interactiveBadges = false, includeContent = true) {
|
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");
|
2026-07-24 23:15:57 -04:00
|
|
|
|
const descriptors = [
|
|
|
|
|
|
{category: "family", value: node.family},
|
|
|
|
|
|
{category: "authority", value: node.authority},
|
|
|
|
|
|
{category: "status", value: node.status},
|
|
|
|
|
|
...node.tags.map((value) => ({category: "tag", value})),
|
|
|
|
|
|
];
|
|
|
|
|
|
for (const descriptor of descriptors) {
|
|
|
|
|
|
const badge = document.createElement(interactiveBadges ? "button" : "span");
|
|
|
|
|
|
if (interactiveBadges) {
|
|
|
|
|
|
badge.type = "button";
|
|
|
|
|
|
badge.className = "badge badge-button";
|
|
|
|
|
|
badge.title = `Show nodes with ${descriptor.category} ${descriptor.value}`;
|
|
|
|
|
|
badge.addEventListener("click", () => {
|
|
|
|
|
|
filterByDescriptor(descriptor.category, descriptor.value);
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
badge.className = "badge";
|
|
|
|
|
|
}
|
|
|
|
|
|
badge.textContent = descriptor.value;
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-25 00:49:17 -04:00
|
|
|
|
details.append(heading, badges, summary, dl);
|
|
|
|
|
|
if (includeContent) {
|
|
|
|
|
|
const content = document.createElement("pre");
|
|
|
|
|
|
content.textContent = node.content;
|
|
|
|
|
|
details.append(content);
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
}
|
2026-07-24 21:01:53 -04:00
|
|
|
|
function closeNodeDialog() {
|
|
|
|
|
|
const dialog = $("node-dialog");
|
|
|
|
|
|
if (dialog.open) dialog.close();
|
|
|
|
|
|
else state.inspectedNode = null;
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
function closeNodeCard() {
|
|
|
|
|
|
const dialog = $("node-card");
|
|
|
|
|
|
if (dialog.open) dialog.close();
|
|
|
|
|
|
else state.cardNode = null;
|
|
|
|
|
|
}
|
2026-07-25 00:49:17 -04:00
|
|
|
|
function positionNodeCard(dialog, event) {
|
|
|
|
|
|
const canvas = $("graph").getBoundingClientRect();
|
|
|
|
|
|
const centerX = canvas.left + canvas.width / 2;
|
|
|
|
|
|
const centerY = canvas.top + canvas.height / 2;
|
|
|
|
|
|
const clientX = Number.isFinite(event?.clientX) ? event.clientX : centerX;
|
|
|
|
|
|
const clientY = Number.isFinite(event?.clientY) ? event.clientY : centerY;
|
|
|
|
|
|
const offset = 14;
|
|
|
|
|
|
const margin = 8;
|
|
|
|
|
|
const bounds = dialog.getBoundingClientRect();
|
|
|
|
|
|
const maximumLeft = Math.max(margin, window.innerWidth - bounds.width - margin);
|
|
|
|
|
|
const maximumTop = Math.max(margin, window.innerHeight - bounds.height - margin);
|
|
|
|
|
|
const left = clamp(clientX + offset, margin, maximumLeft);
|
|
|
|
|
|
const top = clamp(clientY + offset, margin, maximumTop);
|
|
|
|
|
|
dialog.style.left = `${left}px`;
|
|
|
|
|
|
dialog.style.top = `${top}px`;
|
|
|
|
|
|
}
|
|
|
|
|
|
async function showNodeCard(nodeId, event) {
|
2026-07-24 23:15:57 -04:00
|
|
|
|
try {
|
|
|
|
|
|
selectNode(nodeId);
|
|
|
|
|
|
setStatus(`Loading descriptor for ${nodeId}…`);
|
|
|
|
|
|
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
|
|
|
|
|
|
const data = await api(`node?${params}`);
|
|
|
|
|
|
state.cardNode = nodeId;
|
2026-07-25 00:49:17 -04:00
|
|
|
|
renderDetails($("node-card-details"), data.node, data, true, false);
|
2026-07-24 23:15:57 -04:00
|
|
|
|
const dialog = $("node-card");
|
2026-07-25 00:49:17 -04:00
|
|
|
|
if (!dialog.open) {
|
|
|
|
|
|
dialog.style.visibility = "hidden";
|
|
|
|
|
|
dialog.show();
|
|
|
|
|
|
positionNodeCard(dialog, event);
|
|
|
|
|
|
dialog.style.visibility = "";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
positionNodeCard(dialog, event);
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
setStatus(`Selected ${nodeId}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error.message, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 21:01:53 -04:00
|
|
|
|
async function inspectNode(nodeId) {
|
|
|
|
|
|
try {
|
2026-07-24 22:54:19 -04:00
|
|
|
|
selectNode(nodeId);
|
2026-07-24 21:01:53 -04:00
|
|
|
|
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 || []);
|
2026-07-24 23:15:57 -04:00
|
|
|
|
setResultsContext(
|
|
|
|
|
|
$("search").value.trim() || $("family").value
|
|
|
|
|
|
? `${data.count} search results`
|
|
|
|
|
|
: "All nodes",
|
|
|
|
|
|
);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error.message, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
async function filterByDescriptor(category, value) {
|
|
|
|
|
|
const params = new URLSearchParams({
|
|
|
|
|
|
category,
|
|
|
|
|
|
value,
|
|
|
|
|
|
limit: String(state.searchLimit),
|
|
|
|
|
|
});
|
|
|
|
|
|
try {
|
|
|
|
|
|
closeNodeCard();
|
|
|
|
|
|
setStatus(`Filtering ${category} ${value}…`);
|
|
|
|
|
|
const data = await api(`filter?${params}`);
|
|
|
|
|
|
$("search").value = "";
|
|
|
|
|
|
$("family").value = category === "family" ? value : "";
|
|
|
|
|
|
renderResults(data.results || []);
|
|
|
|
|
|
setResultsContext(`${category}: ${value} (${data.total})`, true);
|
|
|
|
|
|
const suffix = data.truncated ? ` · showing first ${data.count}` : "";
|
|
|
|
|
|
setStatus(`${data.total} nodes assigned ${category} ${value}${suffix}`);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error.message, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-24 16:01:03 -04:00
|
|
|
|
async function loadNode(nodeId) {
|
|
|
|
|
|
try {
|
2026-07-25 01:05:45 -04:00
|
|
|
|
const showingFlow = state.mode === "flow";
|
|
|
|
|
|
setStatus(`${showingFlow ? "Tracing lineage for" : "Loading"} ${nodeId}…`);
|
|
|
|
|
|
const params = new URLSearchParams(
|
|
|
|
|
|
showingFlow
|
|
|
|
|
|
? {id: nodeId, limit: "1000"}
|
|
|
|
|
|
: {id: nodeId, depth: String(state.depth), limit: "100"},
|
|
|
|
|
|
);
|
|
|
|
|
|
const data = await api(`${showingFlow ? "lineage" : "node"}?${params}`);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
renderGraph(data);
|
2026-07-25 01:05:45 -04:00
|
|
|
|
const scope = showingFlow ? "directed ancestry" : "neighborhood";
|
|
|
|
|
|
const suffix = data.truncated ? " · truncated at the safety limit" : "";
|
|
|
|
|
|
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`);
|
2026-07-24 23:40:47 -04:00
|
|
|
|
history.replaceState(
|
|
|
|
|
|
null,
|
|
|
|
|
|
"",
|
|
|
|
|
|
`?node=${encodeURIComponent(nodeId)}&depth=${state.depth}&view=${state.mode}`,
|
|
|
|
|
|
);
|
2026-07-24 16:01:03 -04:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error.message, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-25 01:05:45 -04:00
|
|
|
|
async function setViewMode(mode) {
|
2026-07-24 23:40:47 -04:00
|
|
|
|
if (mode !== "nodes" && mode !== "flow") return;
|
2026-07-24 23:15:57 -04:00
|
|
|
|
state.mode = mode;
|
|
|
|
|
|
$("view-switch").dataset.mode = mode;
|
|
|
|
|
|
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
|
|
|
|
|
|
$("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
|
2026-07-25 01:05:45 -04:00
|
|
|
|
if (state.root) await loadNode(state.root);
|
2026-07-24 23:15:57 -04:00
|
|
|
|
}
|
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 23:15:57 -04:00
|
|
|
|
$("clear-result-filter").addEventListener("click", () => {
|
|
|
|
|
|
$("search").value = "";
|
|
|
|
|
|
$("family").value = "";
|
|
|
|
|
|
search();
|
|
|
|
|
|
});
|
|
|
|
|
|
$("view-nodes").addEventListener("click", () => setViewMode("nodes"));
|
|
|
|
|
|
$("view-flow").addEventListener("click", () => setViewMode("flow"));
|
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);
|
|
|
|
|
|
});
|
2026-07-24 23:15:57 -04:00
|
|
|
|
$("explore-card-node").addEventListener("click", async () => {
|
|
|
|
|
|
const nodeId = state.cardNode;
|
|
|
|
|
|
closeNodeCard();
|
|
|
|
|
|
if (nodeId) await loadNode(nodeId);
|
|
|
|
|
|
});
|
2026-07-24 21:01:53 -04:00
|
|
|
|
$("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 23:15:57 -04:00
|
|
|
|
$("node-card").addEventListener("close", () => {
|
|
|
|
|
|
state.cardNode = 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 22:54:19 -04:00
|
|
|
|
document.addEventListener("keydown", (event) => {
|
2026-07-25 00:49:17 -04:00
|
|
|
|
if (event.key === "Escape" && $("node-card").open) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
closeNodeCard();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-24 23:15:57 -04:00
|
|
|
|
if (event.code !== "Space" || event.defaultPrevented
|
|
|
|
|
|
|| $("node-dialog").open || $("node-card").open) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-24 22:54:19 -04:00
|
|
|
|
const target = event.target;
|
|
|
|
|
|
if (target instanceof Element
|
|
|
|
|
|
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (centerSelectedNode()) event.preventDefault();
|
|
|
|
|
|
});
|
2026-07-25 00:49:17 -04:00
|
|
|
|
document.addEventListener("pointerdown", (event) => {
|
|
|
|
|
|
const card = $("node-card");
|
|
|
|
|
|
if (card.open && event.target instanceof Node && !card.contains(event.target)) {
|
|
|
|
|
|
closeNodeCard();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
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);
|
2026-07-24 23:40:47 -04:00
|
|
|
|
setViewMode(params.get("view") === "flow" ? "flow" : "nodes");
|
2026-07-24 16:01:03 -04:00
|
|
|
|
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>
|
|
|
|
|
|
"""
|