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.
|
- 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue