1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Upgrade generic graph navigation

This commit is contained in:
Andraxion 2026-07-24 21:43:11 -04:00
parent 8ac4fe2a67
commit 5e77cd2adb
13 changed files with 657 additions and 65 deletions

View file

@ -12,13 +12,17 @@ from collections.abc import Generator
from contextlib import contextmanager
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from time import monotonic
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
VISUALIZATION_TEMPLATE = "graph-browser@4"
VISUALIZATION_TEMPLATE = "graph-browser@5"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
DEFAULT_LEASE_SECONDS = 180.0
LEASE_MONITOR_INTERVAL_SECONDS = 1.0
class _VisualizationHttpServer(ThreadingHTTPServer):
@ -264,12 +268,28 @@ class VisualizationIndexSnapshot:
class VisualizationRunner:
"""Start one token-protected loopback reader for one immutable project binding."""
def __init__(self, index: ProjectIndex) -> None:
def __init__(
self,
index: ProjectIndex,
*,
initial_grace_seconds: float = DEFAULT_INITIAL_GRACE_SECONDS,
lease_seconds: float = DEFAULT_LEASE_SECONDS,
monitor_interval_seconds: float = LEASE_MONITOR_INTERVAL_SECONDS,
) -> None:
if initial_grace_seconds <= 0 or lease_seconds <= 0 or monitor_interval_seconds <= 0:
raise ValueError("Visualization lease durations must be positive")
self.index = index
self.initial_grace_seconds = initial_grace_seconds
self.lease_seconds = lease_seconds
self.monitor_interval_seconds = monitor_interval_seconds
self._lock = threading.Lock()
self._token = secrets.token_urlsafe(24)
self._server: _VisualizationHttpServer | None = None
self._thread: threading.Thread | None = None
self._lease_thread: threading.Thread | None = None
self._lease_stop = threading.Event()
self._lease_last_activity = monotonic()
self._lease_connected = False
self._atexit_registered = False
self._reader: VisualizationIndexSnapshot | None = None
@ -330,12 +350,21 @@ class VisualizationRunner:
return
self._server = _VisualizationHttpServer(("127.0.0.1", 0), Handler)
self._lease_last_activity = monotonic()
self._lease_connected = False
self._lease_stop.clear()
self._thread = threading.Thread(
target=self._server.serve_forever,
name="docforge-visualization",
daemon=True,
daemon=False,
)
self._thread.start()
self._lease_thread = threading.Thread(
target=self._monitor_lease,
name="docforge-visualization-lease",
daemon=True,
)
self._lease_thread.start()
if not self._atexit_registered:
atexit.register(self.stop)
self._atexit_registered = True
@ -361,6 +390,11 @@ class VisualizationRunner:
"template": VISUALIZATION_TEMPLATE,
"read_only": True,
"project_bound": True,
"lifetime": {
"policy": "browser_lease",
"initial_grace_seconds": self.initial_grace_seconds,
"lease_seconds": self.lease_seconds,
},
"target": {
"node_id": node_id,
"query": query,
@ -375,7 +409,9 @@ class VisualizationRunner:
thread = self._thread
self._server = None
self._thread = None
self._lease_thread = None
self._reader = None
self._lease_stop.set()
if server is None:
return
server.shutdown()
@ -383,6 +419,26 @@ class VisualizationRunner:
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2)
def _touch_lease(self) -> None:
with self._lock:
if self._server is None:
return
self._lease_last_activity = monotonic()
self._lease_connected = True
def _monitor_lease(self) -> None:
while not self._lease_stop.wait(self.monitor_interval_seconds):
with self._lock:
if self._server is None:
return
timeout = (
self.lease_seconds if self._lease_connected else self.initial_grace_seconds
)
expired = monotonic() - self._lease_last_activity >= timeout
if expired:
self.stop()
return
def _handle_get(self, handler: BaseHTTPRequestHandler, *, include_body: bool = True) -> None:
parsed = urllib.parse.urlparse(handler.path)
prefix = f"/{self._token}"
@ -397,6 +453,7 @@ class VisualizationRunner:
include_body=include_body,
)
return
self._touch_lease()
if parsed.path in {prefix, f"{prefix}/"}:
self._respond(
handler,
@ -411,6 +468,11 @@ class VisualizationRunner:
reader = self._current_reader()
if parsed.path == f"{prefix}/api/overview":
payload = reader.overview()
elif parsed.path == f"{prefix}/api/heartbeat":
payload = reader._result(
viewer="alive",
lease_seconds=self.lease_seconds,
)
elif parsed.path == f"{prefix}/api/search":
payload = self._search(reader, params)
elif parsed.path == f"{prefix}/api/node":
@ -616,6 +678,14 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
--accent: #51d7ff;
--accent-2: #8fffc5;
--warn: #ffd27a;
--left-width: 310px;
--right-width: 350px;
--primary-fill: #176b7d;
--primary-stroke: #83e8ff;
--child-fill: #216c51;
--child-stroke: #91f2bd;
--edge-fill: #634580;
--edge-stroke: #d0a7ff;
font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
@ -631,10 +701,22 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
header h1 { margin: 0; font-size: 17px; }
.stats { display: flex; gap: 14px; color: var(--muted); }
.status { margin-left: auto; color: var(--muted); }
.layout { min-height: 0; display: grid; grid-template-columns: 300px minmax(360px, 1fr) 340px; }
.layout {
min-height: 0; display: grid;
grid-template-columns: var(--left-width) 7px minmax(360px, 1fr) 7px var(--right-width);
}
aside { min-height: 0; overflow: auto; padding: 16px; background: var(--panel); }
.left { border-right: 1px solid var(--line); }
.right { border-left: 1px solid var(--line); }
.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; }
form { display: grid; gap: 8px; }
input, select {
width: 100%; border: 1px solid var(--line); border-radius: 8px;
@ -646,6 +728,43 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
background: #12384a; color: var(--text);
}
.results { display: grid; gap: 7px; margin-top: 14px; }
.section-label {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin: 18px 0 8px; color: var(--muted); font-size: 11px;
font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
}
.section-label span {
min-width: 24px; border: 1px solid var(--line); border-radius: 999px;
padding: 1px 6px; text-align: center; letter-spacing: 0;
}
.neighborhood { margin-top: 18px; padding-top: 2px; border-top: 1px solid var(--line); }
.node-list { display: grid; gap: 6px; }
.node-list-item {
width: 100%; display: grid; grid-template-columns: 8px minmax(0, 1fr);
gap: 9px; align-items: center; text-align: left; border: 1px solid var(--line);
border-radius: 9px; padding: 8px; background: rgba(17, 31, 47, .72); color: var(--text);
}
.node-list-item:hover, .node-list-item:focus-visible {
border-color: var(--item-color, var(--accent)); outline: none;
background: rgba(24, 43, 62, .9);
}
.node-swatch {
width: 8px; height: 28px; border-radius: 999px;
background: var(--item-color, var(--accent));
box-shadow: 0 0 12px color-mix(in srgb, var(--item-color, var(--accent)) 35%, transparent);
}
.node-list-copy { min-width: 0; }
.node-list-copy strong, .node-list-copy span {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node-list-copy span { color: var(--muted); font-size: 11px; }
.legend {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 14px;
}
.legend span {
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
}
.legend i { width: 8px; height: 8px; border-radius: 50%; }
.result {
width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px;
padding: 9px; background: var(--panel-2); color: var(--text);
@ -683,11 +802,14 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
background: rgba(7, 16, 26, .78); color: var(--muted); font-size: 11px;
pointer-events: none;
}
.edge { stroke: #476177; stroke-opacity: .56; stroke-width: 1.2; }
.edge { stroke-opacity: .68; stroke-width: 1.4; }
.edge.child-edge { stroke: #4cbe8a; }
.edge.context-edge { stroke: #a77bd6; }
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
.node { cursor: pointer; }
.node circle { fill: #17344a; stroke: #70b9d4; stroke-width: 1.5; }
.node.root circle { fill: #19566a; stroke: var(--accent-2); stroke-width: 3; }
.node circle { stroke-width: 1.8; transition: stroke-width .15s, filter .15s; }
.node.root circle { stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24)); }
.node:hover circle { stroke: #fff; stroke-width: 3; }
.node text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; }
@ -696,6 +818,14 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
color: var(--muted); pointer-events: none;
}
.empty[hidden] { display: none; }
.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; }
.detail-head { display: flex; align-items: start; gap: 10px; }
.detail-head h2 { margin: 0; font-size: 18px; overflow-wrap: anywhere; }
.badge {
@ -713,18 +843,21 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
background: #07111c; color: #d6e6f5;
}
dialog {
width: min(760px, calc(100vw - 32px)); max-height: min(780px, calc(100vh - 32px));
width: min(760px, calc(100vw - 32px)); height: min(680px, calc(100vh - 32px));
min-width: min(360px, calc(100vw - 20px)); min-height: 280px;
max-width: calc(100vw - 16px); max-height: calc(100vh - 16px);
padding: 0; overflow: hidden; border: 1px solid #36536e; border-radius: 14px;
background: var(--panel); color: var(--text);
box-shadow: 0 24px 80px rgba(0, 0, 0, .6);
box-shadow: 0 24px 80px rgba(0, 0, 0, .58); resize: both;
}
dialog::backdrop { background: rgba(2, 8, 14, .78); backdrop-filter: blur(3px); }
dialog::backdrop { background: rgba(2, 8, 14, .48); }
.dialog-shell {
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: inherit;
display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: 100%; height: 100%;
}
.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);
cursor: move; touch-action: none; user-select: none;
}
.dialog-head strong { font-size: 15px; }
.dialog-close {
@ -743,6 +876,7 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
.error { color: #ff9aac; }
@media (max-width: 980px) {
.layout { grid-template-columns: 240px 1fr; }
.panel-resizer { display: none; }
.right { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
}
@media (max-width: 700px) {
@ -776,7 +910,18 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
<select id="family" name="family"><option value="">All families</option></select>
</form>
<div class="results" id="results"></div>
<div class="neighborhood" id="neighborhood" hidden>
<div class="legend" aria-label="Node role colors">
<span><i style="background: var(--primary-stroke)"></i>Primary</span>
<span><i style="background: var(--child-stroke)"></i>Children</span>
<span><i style="background: var(--edge-stroke)"></i>Edge</span>
</div>
<div id="neighborhood-sections"></div>
</div>
</aside>
<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>
<main class="canvas">
<div class="viewport-controls" aria-label="Graph viewport controls">
<button class="viewport-control" id="zoom-in" type="button"
@ -790,10 +935,17 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
<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>
<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>
<div class="viewport-hint">
Click node to inspect · mouse wheel to zoom · left-drag to pan
</div>
</main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
aria-label="Resize details panel" aria-orientation="vertical"
aria-valuemin="240" aria-valuemax="900" aria-valuenow="350"></div>
<aside class="right">
<div id="details">
<p class="summary">Choose a search result to load its neighborhood.</p>
@ -828,14 +980,23 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
pointer: null,
suppressClick: false,
inspectedNode: null,
dialogDrag: null,
leaseTimer: null,
};
const $ = (id) => document.getElementById(id);
const api = async (path) => {
const response = await fetch(`${base}api/${path}`, {cache: "no-store"});
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");
}
const body = await response.json();
if (!response.ok || body.status === "error") {
throw new Error(body.error?.message || "DocForge request failed");
}
$("connection-state").hidden = true;
return body;
};
const escapeText = (value) => String(value ?? "");
@ -879,6 +1040,22 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
$("status").textContent = message;
$("status").classList.toggle("error", error);
}
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();
});
}
function renderOverview(data) {
state.overview = data;
state.searchLimit = Math.max(1, Number(data.max_results) || 1);
@ -918,22 +1095,153 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
results.append(button);
}
}
function layoutNodes(nodes, rootId) {
const ordered = [...nodes].sort((a, b) => a.node_id.localeCompare(b.node_id));
function analyzeTopology(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const adjacency = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
const outgoing = new Map([...nodeIds].map((nodeId) => [nodeId, new Set()]));
for (const edge of data.edges) {
if (!nodeIds.has(edge.source_id) || !nodeIds.has(edge.target_id)) continue;
adjacency.get(edge.source_id).add(edge.target_id);
adjacency.get(edge.target_id).add(edge.source_id);
outgoing.get(edge.source_id).add(edge.target_id);
}
const hops = new Map([[data.root, 0]]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const neighbor of adjacency.get(nodeId) || []) {
if (hops.has(neighbor)) continue;
hops.set(neighbor, hops.get(nodeId) + 1);
next.push(neighbor);
}
}
frontier = next;
}
const children = new Set();
frontier = [...(outgoing.get(data.root) || [])];
for (const nodeId of frontier) children.add(nodeId);
while (frontier.length) {
const next = [];
for (const nodeId of frontier) {
for (const candidate of outgoing.get(nodeId) || []) {
if (candidate === data.root || children.has(candidate)) continue;
children.add(candidate);
next.push(candidate);
}
}
frontier = next;
}
return new Map(data.nodes.map((node) => [
node.node_id,
{
hop: hops.get(node.node_id) ?? data.depth + 1,
role: node.node_id === data.root
? "primary"
: children.has(node.node_id) ? "child" : "edge",
},
]));
}
function layoutNodes(nodes, rootId, topology) {
const ordered = [...nodes].sort((a, b) => {
const first = topology.get(a.node_id);
const second = topology.get(b.node_id);
return first.hop - second.hop
|| first.role.localeCompare(second.role)
|| a.node_id.localeCompare(b.node_id);
});
const root = ordered.find((node) => node.node_id === rootId);
const others = ordered.filter((node) => node.node_id !== rootId);
const positions = new Map();
if (root) positions.set(root.node_id, {x: 0, y: 0});
others.forEach((node, index) => {
const ring = Math.floor(index / 18) + 1;
const ringStart = (ring - 1) * 18;
const ringCount = Math.min(18, others.length - ringStart);
const angle = ((index - ringStart) / Math.max(1, ringCount)) * Math.PI * 2 - Math.PI / 2;
const radius = 165 + (ring - 1) * 145;
positions.set(node.node_id, {x: Math.cos(angle) * radius, y: Math.sin(angle) * radius});
});
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,
});
});
}
return positions;
}
function darken(hex, amount) {
const value = Number.parseInt(hex.slice(1), 16);
const factor = 1 - Math.min(.5, Math.max(0, amount));
const channels = [
(value >> 16) & 255,
(value >> 8) & 255,
value & 255,
].map((channel) => Math.round(channel * factor).toString(16).padStart(2, "0"));
return `#${channels.join("")}`;
}
function nodePalette(role, hop) {
const colors = {
primary: {fill: "#176b7d", stroke: "#83e8ff"},
child: {fill: "#216c51", stroke: "#91f2bd"},
edge: {fill: "#634580", stroke: "#d0a7ff"},
}[role];
const distanceShade = Math.min(.5, Math.max(0, hop - 1) * .17);
return {
fill: darken(colors.fill, distanceShade),
stroke: darken(colors.stroke, distanceShade),
};
}
function renderNeighborhood(data, topology) {
const sections = [
{role: "primary", label: "Primary focus"},
{role: "child", label: "Children"},
{role: "edge", label: "Edge & context"},
];
const container = $("neighborhood-sections");
container.replaceChildren();
for (const section of sections) {
const nodes = data.nodes
.filter((node) => topology.get(node.node_id).role === section.role)
.sort((a, b) => topology.get(a.node_id).hop - topology.get(b.node_id).hop
|| a.node_id.localeCompare(b.node_id));
if (!nodes.length) continue;
const heading = document.createElement("div");
heading.className = "section-label";
heading.append(document.createTextNode(section.label));
const count = document.createElement("span");
count.textContent = String(nodes.length);
heading.append(count);
const list = document.createElement("div");
list.className = "node-list";
for (const node of nodes) {
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const button = document.createElement("button");
button.type = "button";
button.className = "node-list-item";
button.style.setProperty("--item-color", palette.stroke);
button.title = `Focus ${node.title}`;
const swatch = document.createElement("i");
swatch.className = "node-swatch";
const copy = document.createElement("span");
copy.className = "node-list-copy";
const title = document.createElement("strong");
title.textContent = node.title;
const meta = document.createElement("span");
const hopLabel = `${topologyNode.hop} hop${topologyNode.hop === 1 ? "" : "s"}`;
meta.textContent = `${node.family} · ${hopLabel}`;
copy.append(title, meta);
button.append(swatch, copy);
button.addEventListener("click", () => loadNode(node.node_id));
list.append(button);
}
container.append(heading, list);
}
$("neighborhood").hidden = false;
}
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);
@ -946,15 +1254,24 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
const svg = $("graph");
svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0;
const positions = layoutNodes(data.nodes, data.root);
const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology);
renderNeighborhood(data, topology);
const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g");
for (const edge of data.edges) {
const source = positions.get(edge.source_id);
const target = positions.get(edge.target_id);
if (!source || !target) continue;
const targetRole = topology.get(edge.target_id)?.role || "edge";
const edgeRole = edge.source_id === data.root && targetRole === "child"
? "child-edge"
: edge.target_id === data.root || targetRole === "edge"
? "context-edge"
: "boundary-edge";
edgeLayer.append(svgElement("line", {
x1: source.x, y1: source.y, x2: target.x, y2: target.y, class: "edge"
x1: source.x, y1: source.y, x2: target.x, y2: target.y,
class: `edge ${edgeRole}`
}));
const label = svgElement("text", {
x: (source.x + target.x) / 2,
@ -968,14 +1285,23 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
for (const node of data.nodes) {
const point = positions.get(node.node_id);
if (!point) continue;
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const group = svgElement("g", {
class: `node${node.node_id === data.root ? " root" : ""}`,
class: `node ${topologyNode.role}${node.node_id === data.root ? " root" : ""}`,
transform: `translate(${point.x} ${point.y})`,
"data-hop": String(topologyNode.hop),
tabindex: "0",
role: "button",
"aria-label": `${node.title}, ${node.family}`
"aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ")
});
group.append(svgElement("circle", {r: node.node_id === data.root ? 25 : 18}));
group.append(svgElement("circle", {
r: node.node_id === data.root ? 25 : 18,
fill: palette.fill,
stroke: palette.stroke,
}));
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"});
@ -1043,6 +1369,7 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
const data = await api(`node?${params}`);
state.inspectedNode = nodeId;
renderDetails($("node-dialog-details"), data.node, data);
$("node-dialog-label").textContent = short(data.node.title, 72);
const dialog = $("node-dialog");
if (!dialog.open) dialog.showModal();
$("close-node-dialog").focus();
@ -1079,6 +1406,90 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
setStatus(error.message, true);
}
}
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;
}
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
$("family").addEventListener("change", search);
$("zoom-in").addEventListener("click", () => zoomAt(.8));
@ -1086,6 +1497,12 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
$("reset-view").addEventListener("click", resetViewport);
$("close-node-dialog").addEventListener("click", closeNodeDialog);
$("dismiss-node-dialog").addEventListener("click", closeNodeDialog);
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);
$("explore-node").addEventListener("click", async () => {
const nodeId = state.inspectedNode;
closeNodeDialog();
@ -1162,6 +1579,7 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
state.depth = Math.max(1, Number(params.get("depth")) || 1);
const overview = await api("overview");
renderOverview(overview);
startViewerLease();
const nodeId = params.get("node");
const query = params.get("q");
if (nodeId) {