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

Polish sparse graph navigation

This commit is contained in:
Andraxion 2026-07-24 22:54:19 -04:00
parent 5ccb336e84
commit 7834461eb9
8 changed files with 148 additions and 28 deletions

View file

@ -23,7 +23,7 @@ from typing import cast
from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
VISUALIZATION_TEMPLATE = "graph-browser@5"
VISUALIZATION_TEMPLATE = "graph-browser@6"
DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
@ -1009,9 +1009,22 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
.node { cursor: pointer; }
.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: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));
}
.node text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; }
.empty {
@ -1142,7 +1155,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<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
Click node to inspect · Space centers selection · mouse wheel zooms · left-drag pans
</div>
</main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
@ -1179,6 +1192,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
depth: 1,
searchLimit: 1,
viewport: {...defaultViewport},
homeViewport: {...defaultViewport},
positions: new Map(),
selectedNode: null,
pointer: null,
suppressClick: false,
inspectedNode: null,
@ -1213,9 +1229,57 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
$("zoom-level").textContent = `${zoom}%`;
}
function resetViewport() {
state.viewport = {...defaultViewport};
state.viewport = {...state.homeViewport};
applyViewport();
}
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;
}
function zoomAt(factor, clientX = null, clientY = null) {
const svg = $("graph");
const rect = svg.getBoundingClientRect();
@ -1452,12 +1516,15 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
function renderGraph(data) {
state.graph = data;
state.root = data.root;
resetViewport();
const svg = $("graph");
svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0;
const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology);
state.positions = positions;
state.selectedNode = data.root;
state.homeViewport = viewportForPositions(positions);
resetViewport();
renderNeighborhood(data, topology);
const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g");
@ -1490,11 +1557,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop);
const group = svgElement("g", {
class: `node ${topologyNode.role}${node.node_id === data.root ? " root" : ""}`,
class: [
"node",
topologyNode.role,
node.node_id === data.root ? "root" : "",
node.node_id === state.selectedNode ? "selected" : "",
].filter(Boolean).join(" "),
transform: `translate(${point.x} ${point.y})`,
"data-node-id": node.node_id,
"data-hop": String(topologyNode.hop),
tabindex: "0",
role: "button",
"aria-pressed": String(node.node_id === state.selectedNode),
"aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ")
@ -1504,17 +1578,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
fill: palette.fill,
stroke: palette.stroke,
}));
group.append(svgElement("circle", {
r: node.node_id === data.root ? 32 : 25,
class: "selection-ring",
}));
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);
group.addEventListener("click", () => {
if (!state.suppressClick) inspectNode(node.node_id);
if (!state.suppressClick) {
selectNode(node.node_id);
inspectNode(node.node_id);
}
});
group.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
if (event.key === "Enter") {
event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id);
}
});
@ -1566,6 +1648,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
}
async function inspectNode(nodeId) {
try {
selectNode(nodeId);
setStatus(`Inspecting ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`);
@ -1725,6 +1808,15 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
}
});
document.addEventListener("keydown", (event) => {
if (event.code !== "Space" || event.defaultPrevented || $("node-dialog").open) return;
const target = event.target;
if (target instanceof Element
&& target.closest("input, select, textarea, button, [contenteditable='true']")) {
return;
}
if (centerSelectedNode()) event.preventDefault();
});
$("graph").addEventListener("wheel", (event) => {
event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);