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 @@ approved contract and measured cross-project evidence; it is not an unimplemente
- Result envelope: `schemas/result.schema.json`, version 1. - Result envelope: `schemas/result.schema.json`, version 1.
- Changeset schema: `schemas/changeset.schema.json`, version 1. - Changeset schema: `schemas/changeset.schema.json`, version 1.
- Index schema: version 1, disposable and reproducible. - Index schema: version 1, disposable and reproducible.
- Core, CLI, and MCP server: version 0.8.1. - Core, CLI, and MCP server: version 0.8.2.
Schema files describe the generic interchange contract. Runtime validation remains responsible for Schema files describe the generic interchange contract. Runtime validation remains responsible for
path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale path confinement, source hashing, relationship resolution, dependency cycles, project limits, stale
@ -105,11 +105,14 @@ overview, bounded search, exact node content, bounded incoming-and-outgoing neig
lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint, lease heartbeat. The heartbeat changes no project or index state. There is no write endpoint,
arbitrary query endpoint, static filesystem handler, external asset, or project-selection control. arbitrary query endpoint, static filesystem handler, external asset, or project-selection control.
The `graph-browser@5` template provides mouse-wheel zoom centered on the pointer, left-button drag The `graph-browser@6` template provides mouse-wheel zoom centered on the pointer, left-button drag
pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A
four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks. four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks.
Loading another root node resets the viewport so the new neighborhood begins centered and fully Loading another root node fits the viewport to the returned neighborhood, including a useful
framed. Empty-canvas guidance is hidden whenever a neighborhood is rendered. minimum scale for a single-node result. The current root begins selected, and activating another
graph node moves the visible selection ring to it. Space centers the viewport on the selected node
without changing zoom. Reset restores the fitted neighborhood view. Empty-canvas guidance is hidden
whenever a neighborhood is rendered.
Activating a graph node opens a modal inspector containing that node's complete validated metadata Activating a graph node opens a modal inspector containing that node's complete validated metadata
and content. Inspection does not replace the current neighborhood or reset the viewport. The modal and content. Inspection does not replace the current neighborhood or reset the viewport. The modal

View file

@ -61,7 +61,7 @@ only through the explicit local CLI integration command.
## Visualization boundary ## Visualization boundary
`docforge_visualize` starts the fixed built-in `graph-browser@5` template against the currently `docforge_visualize` starts the fixed built-in `graph-browser@6` template against the currently
validated derived index. It may focus one stable node, run one bounded lexical query, or open the validated derived index. It may focus one stable node, run one bounded lexical query, or open the
project overview. The tool returns a loopback URL and exact snapshot identity. project overview. The tool returns a loopback URL and exact snapshot identity.
@ -70,12 +70,13 @@ expression. Its HTTP surface is token-bound, read-only, same-origin, and limited
search/filter, node-neighborhood JSON, and a read-only browser-lease heartbeat. The browser exposes search/filter, node-neighborhood JSON, and a read-only browser-lease heartbeat. The browser exposes
an exact validated index snapshot. It rejects index replacement or alteration and requires another an exact validated index snapshot. It rejects index replacement or alteration and requires another
MCP invocation to refresh. MCP invocation to refresh.
Viewport interaction is entirely client-side: wheel zoom, left-button drag pan, explicit zoom Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
buttons, and reset never request or mutate project data. Graph-node activation fetches exact node drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate
data from the same bounded read endpoint and opens a client-side modal inspector. Replacing the project data. Graph-node activation visibly selects the node, fetches exact node data from the same
current root requires the modal's explicit Explore neighborhood action. The open browser renews a bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires
bounded lease in a detached local worker, so standard-input transaction completion does not close the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a
the listener. The worker tracks the longer-lived MCP client host and closes when that owner exits. detached local worker, so standard-input transaction completion does not close the listener. The
worker tracks the longer-lived MCP client host and closes when that owner exits.
Explicit service shutdown closes its tracked worker, and abandoned pages expire. Explicit service shutdown closes its tracked worker, and abandoned pages expire.
## Excluded tools ## Excluded tools

View file

@ -16,10 +16,11 @@ The DocForge repository contains the complete generic CLI and stdio MCP server.
- Project identity, validation, exact-node retrieval, lexical search, and filtering. - Project identity, validation, exact-node retrieval, lexical search, and filtering.
- Backlinks, dependency traversal, impact traversal, and bounded context profiles. - Backlinks, dependency traversal, impact traversal, and bounded context profiles.
- `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@5` viewer. - `docforge_visualize`, which starts the token-protected, loopback-only `graph-browser@6` viewer.
The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods, The viewer supports search, family filtering, exact-node inspection, bounded neighborhoods,
modal inspection without losing the current neighborhood, explicit neighborhood exploration, modal inspection without losing the current neighborhood, explicit neighborhood exploration,
mouse-wheel zoom, left-button drag panning, zoom controls, and viewport reset. fitted single- and multi-node framing, visible node selection, Space-to-center, mouse-wheel zoom,
left-button drag panning, zoom controls, and viewport reset.
- Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a - Isolated documentation changesets, proposal validation, diffs, and escaped HTML previews when a
proposal writer and render view are configured. proposal writer and render view are configured.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "docforge" name = "docforge"
version = "0.8.1" version = "0.8.2"
description = "Project-scoped documentation indexing and context service" description = "Project-scoped documentation indexing and context service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -23,7 +23,7 @@ from typing import cast
from .errors import DocForgeError from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize 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 DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400 MAX_EDGE_LIMIT = 400
DEFAULT_INITIAL_GRACE_SECONDS = 120.0 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.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; } .edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
.node { cursor: pointer; } .node { cursor: pointer; }
.node circle { stroke-width: 1.8; transition: stroke-width .15s, filter .15s; } .node:focus { outline: none; }
.node.root circle { stroke-width: 3; filter: drop-shadow(0 0 8px rgba(81, 215, 255, .24)); } .node > circle:not(.selection-ring) {
.node:hover circle { stroke: #fff; stroke-width: 3; } 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 text { fill: var(--text); font-size: 10px; pointer-events: none; }
.node .family { fill: var(--muted); font-size: 8px; } .node .family { fill: var(--muted); font-size: 8px; }
.empty { .empty {
@ -1142,7 +1155,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
<span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span> <span>The project-bound listener is unavailable. Invoke docforge_visualize again.</span>
</div> </div>
<div class="viewport-hint"> <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> </div>
</main> </main>
<div class="panel-resizer" id="right-resizer" role="separator" tabindex="0" <div class="panel-resizer" id="right-resizer" role="separator" tabindex="0"
@ -1179,6 +1192,9 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
depth: 1, depth: 1,
searchLimit: 1, searchLimit: 1,
viewport: {...defaultViewport}, viewport: {...defaultViewport},
homeViewport: {...defaultViewport},
positions: new Map(),
selectedNode: null,
pointer: null, pointer: null,
suppressClick: false, suppressClick: false,
inspectedNode: null, inspectedNode: null,
@ -1213,9 +1229,57 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
$("zoom-level").textContent = `${zoom}%`; $("zoom-level").textContent = `${zoom}%`;
} }
function resetViewport() { function resetViewport() {
state.viewport = {...defaultViewport}; state.viewport = {...state.homeViewport};
applyViewport(); 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) { function zoomAt(factor, clientX = null, clientY = null) {
const svg = $("graph"); const svg = $("graph");
const rect = svg.getBoundingClientRect(); const rect = svg.getBoundingClientRect();
@ -1452,12 +1516,15 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
function renderGraph(data) { function renderGraph(data) {
state.graph = data; state.graph = data;
state.root = data.root; state.root = data.root;
resetViewport();
const svg = $("graph"); const svg = $("graph");
svg.replaceChildren(); svg.replaceChildren();
$("empty").hidden = data.nodes.length > 0; $("empty").hidden = data.nodes.length > 0;
const topology = analyzeTopology(data); const topology = analyzeTopology(data);
const positions = layoutNodes(data.nodes, data.root, topology); const positions = layoutNodes(data.nodes, data.root, topology);
state.positions = positions;
state.selectedNode = data.root;
state.homeViewport = viewportForPositions(positions);
resetViewport();
renderNeighborhood(data, topology); renderNeighborhood(data, topology);
const edgeLayer = svgElement("g"); const edgeLayer = svgElement("g");
const nodeLayer = svgElement("g"); const nodeLayer = svgElement("g");
@ -1490,11 +1557,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
const topologyNode = topology.get(node.node_id); const topologyNode = topology.get(node.node_id);
const palette = nodePalette(topologyNode.role, topologyNode.hop); const palette = nodePalette(topologyNode.role, topologyNode.hop);
const group = svgElement("g", { 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})`, transform: `translate(${point.x} ${point.y})`,
"data-node-id": node.node_id,
"data-hop": String(topologyNode.hop), "data-hop": String(topologyNode.hop),
tabindex: "0", tabindex: "0",
role: "button", role: "button",
"aria-pressed": String(node.node_id === state.selectedNode),
"aria-label": [ "aria-label": [
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops` node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
].join(", ") ].join(", ")
@ -1504,17 +1578,25 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
fill: palette.fill, fill: palette.fill,
stroke: palette.stroke, 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"}); const title = svgElement("text", {y: 35, "text-anchor": "middle"});
title.textContent = short(node.title, 26); title.textContent = short(node.title, 26);
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"}); const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
family.textContent = short(node.family, 22); family.textContent = short(node.family, 22);
group.append(title, family); group.append(title, family);
group.addEventListener("click", () => { 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) => { group.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter") {
event.preventDefault(); event.preventDefault();
selectNode(node.node_id);
inspectNode(node.node_id); inspectNode(node.node_id);
} }
}); });
@ -1566,6 +1648,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
} }
async function inspectNode(nodeId) { async function inspectNode(nodeId) {
try { try {
selectNode(nodeId);
setStatus(`Inspecting ${nodeId}`); setStatus(`Inspecting ${nodeId}`);
const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"}); const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"});
const data = await api(`node?${params}`); const data = await api(`node?${params}`);
@ -1725,6 +1808,15 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`); 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) => { $("graph").addEventListener("wheel", (event) => {
event.preventDefault(); event.preventDefault();
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY); zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);

View file

@ -101,7 +101,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"] visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"]) self.assertTrue(visualization["project_bound"])
self.assertEqual("graph-browser@5", visualization["template"]) self.assertEqual("graph-browser@6", visualization["template"])
self.assertEqual("browser_lease", visualization["lifetime"]["policy"]) self.assertEqual("browser_lease", visualization["lifetime"]["policy"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
context = results[8].structuredContent context = results[8].structuredContent

View file

@ -81,11 +81,17 @@ class VisualizationTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation") @unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None: def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
script = _GRAPH_BROWSER_HTML.split("<script>", 1)[1].split("</script>", 1)[0] script = _GRAPH_BROWSER_HTML.split("<script>", 1)[1].split("</script>", 1)[0]
viewport_logic = script.split("function viewportForPositions", 1)[1].split(
"function selectNode", 1
)[0]
topology_logic = script.split("function analyzeTopology", 1)[1].split( topology_logic = script.split("function analyzeTopology", 1)[1].split(
"function renderNeighborhood", 1 "function renderNeighborhood", 1
)[0] )[0]
harness = ( harness = (
"function analyzeTopology" "const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});\n"
"function viewportForPositions"
+ viewport_logic
+ "function analyzeTopology"
+ topology_logic + topology_logic
+ """ + """
const data = { const data = {
@ -116,6 +122,18 @@ if (Math.hypot(positions.get("child-two").x, positions.get("child-two").y)
fail("hop rings"); fail("hop rings");
} }
if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop shading"); if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop shading");
const singleViewport = viewportForPositions(new Map([["primary", {x: 0, y: 0}]]));
if (singleViewport.width !== 440) fail("single-node fit");
if (singleViewport.x !== -220 || singleViewport.y !== -singleViewport.height / 2) {
fail("single-node centered");
}
const centered = viewportCenteredOn(
positions.get("child-two"),
{x: 20, y: 30, width: 500, height: 300},
);
if (centered.width !== 500 || centered.height !== 300) fail("center preserves zoom");
if (centered.x !== positions.get("child-two").x - 250) fail("center x");
if (centered.y !== positions.get("child-two").y - 150) fail("center y");
""" """
) )
result = subprocess.run( result = subprocess.run(
@ -160,6 +178,7 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
self.assertIn('id="right-resizer"', html) self.assertIn('id="right-resizer"', html)
self.assertIn('id="neighborhood-sections"', html) self.assertIn('id="neighborhood-sections"', html)
self.assertIn(".empty[hidden] { display: none; }", html) self.assertIn(".empty[hidden] { display: none; }", html)
self.assertIn("Space centers selection", html)
self.assertIn("resize: both", html) self.assertIn("resize: both", html)
self.assertNotIn("backdrop-filter", html) self.assertNotIn("backdrop-filter", html)
self.assertIn('addEventListener("wheel"', html) self.assertIn('addEventListener("wheel"', html)
@ -173,6 +192,10 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
self.assertIn("Primary focus", html) self.assertIn("Primary focus", html)
self.assertIn("Edge & context", html) self.assertIn("Edge & context", html)
self.assertIn("distanceShade", html) self.assertIn("distanceShade", html)
self.assertIn("viewportForPositions", html)
self.assertIn("centerSelectedNode", html)
self.assertIn('event.code !== "Space"', html)
self.assertIn('class: "selection-ring"', html)
self.assertIn("renewViewerLease", html) self.assertIn("renewViewerLease", html)
pointerdown = html.split('$("graph").addEventListener("pointerdown"', 1)[1].split( pointerdown = html.split('$("graph").addEventListener("pointerdown"', 1)[1].split(
'$("graph").addEventListener("pointermove"', 1 '$("graph").addEventListener("pointermove"', 1
@ -182,7 +205,7 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
)[0] )[0]
self.assertNotIn("setPointerCapture", pointerdown) self.assertNotIn("setPointerCapture", pointerdown)
self.assertIn("setPointerCapture", pointermove) self.assertIn("setPointerCapture", pointermove)
self.assertIn("left-drag to pan", html) self.assertIn("left-drag pans", html)
self.assertIn("default-src 'none'", headers["Content-Security-Policy"]) self.assertIn("default-src 'none'", headers["Content-Security-Policy"])
self.assertEqual("no-store", headers["Cache-Control"]) self.assertEqual("no-store", headers["Cache-Control"])
self.assertEqual("DENY", headers["X-Frame-Options"]) self.assertEqual("DENY", headers["X-Frame-Options"])

2
uv.lock generated
View file

@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "docforge" name = "docforge"
version = "0.8.1" version = "0.8.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },