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 @@ 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue