Polish sparse graph navigation
This commit is contained in:
parent
5ccb336e84
commit
7834461eb9
8 changed files with 148 additions and 28 deletions
|
|
@ -23,7 +23,7 @@ approved contract and measured cross-project evidence; it is not an unimplemente
|
|||
- Result envelope: `schemas/result.schema.json`, version 1.
|
||||
- Changeset schema: `schemas/changeset.schema.json`, version 1.
|
||||
- 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
|
||||
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,
|
||||
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
|
||||
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
|
||||
framed. Empty-canvas guidance is hidden whenever a neighborhood is rendered.
|
||||
Loading another root node fits the viewport to the returned neighborhood, including a useful
|
||||
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
|
||||
and content. Inspection does not replace the current neighborhood or reset the viewport. The modal
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ only through the explicit local CLI integration command.
|
|||
|
||||
## 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
|
||||
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
|
||||
an exact validated index snapshot. It rejects index replacement or alteration and requires another
|
||||
MCP invocation to refresh.
|
||||
Viewport interaction is entirely client-side: wheel zoom, left-button drag pan, explicit zoom
|
||||
buttons, and reset never request or mutate project data. Graph-node activation fetches exact node
|
||||
data from the same bounded read endpoint and opens a client-side modal inspector. Replacing the
|
||||
current root requires the modal's explicit Explore neighborhood action. The open browser renews a
|
||||
bounded lease in a 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.
|
||||
Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
|
||||
drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate
|
||||
project data. Graph-node activation visibly selects the node, fetches exact node data from the same
|
||||
bounded read endpoint, and opens a client-side modal inspector. Replacing the current root requires
|
||||
the modal's explicit Explore neighborhood action. The open browser renews a bounded lease in a
|
||||
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.
|
||||
|
||||
## Excluded tools
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- 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,
|
||||
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
|
||||
proposal writer and render view are configured.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||
|
||||
[project]
|
||||
name = "docforge"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
description = "Project-scoped documentation indexing and context service"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
visualization = results[11].structuredContent["visualization"]
|
||||
self.assertTrue(visualization["read_only"])
|
||||
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.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
|
||||
context = results[8].structuredContent
|
||||
|
|
|
|||
|
|
@ -81,11 +81,17 @@ class VisualizationTests(unittest.TestCase):
|
|||
@unittest.skipUnless(shutil.which("node"), "Node.js is required for topology validation")
|
||||
def test_embedded_topology_roles_hops_and_shading_are_deterministic(self) -> None:
|
||||
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(
|
||||
"function renderNeighborhood", 1
|
||||
)[0]
|
||||
harness = (
|
||||
"function analyzeTopology"
|
||||
"const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});\n"
|
||||
"function viewportForPositions"
|
||||
+ viewport_logic
|
||||
+ "function analyzeTopology"
|
||||
+ topology_logic
|
||||
+ """
|
||||
const data = {
|
||||
|
|
@ -116,6 +122,18 @@ if (Math.hypot(positions.get("child-two").x, positions.get("child-two").y)
|
|||
fail("hop rings");
|
||||
}
|
||||
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(
|
||||
|
|
@ -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="neighborhood-sections"', html)
|
||||
self.assertIn(".empty[hidden] { display: none; }", html)
|
||||
self.assertIn("Space centers selection", html)
|
||||
self.assertIn("resize: both", html)
|
||||
self.assertNotIn("backdrop-filter", 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("Edge & context", 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)
|
||||
pointerdown = html.split('$("graph").addEventListener("pointerdown"', 1)[1].split(
|
||||
'$("graph").addEventListener("pointermove"', 1
|
||||
|
|
@ -182,7 +205,7 @@ if (nodePalette("child", 2).fill === nodePalette("child", 1).fill) fail("hop sha
|
|||
)[0]
|
||||
self.assertNotIn("setPointerCapture", pointerdown)
|
||||
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.assertEqual("no-store", headers["Cache-Control"])
|
||||
self.assertEqual("DENY", headers["X-Frame-Options"])
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -206,7 +206,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "docforge"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue