Add graph viewport controls
This commit is contained in:
parent
195a57210a
commit
5a17830b9a
13 changed files with 204 additions and 20 deletions
|
|
@ -16,7 +16,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||
from .errors import DocForgeError
|
||||
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
|
||||
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@1"
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@2"
|
||||
DEFAULT_EDGE_LIMIT = 100
|
||||
MAX_EDGE_LIMIT = 400
|
||||
|
||||
|
|
@ -655,9 +655,37 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
.result span { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.canvas { position: relative; min-height: 0; overflow: hidden; }
|
||||
svg { width: 100%; height: 100%; min-height: 620px; background:
|
||||
radial-gradient(circle at center, #10243a 0, #07101a 64%); }
|
||||
radial-gradient(circle at center, #10243a 0, #07101a 64%);
|
||||
cursor: grab; touch-action: none; user-select: none;
|
||||
}
|
||||
.canvas.dragging svg { cursor: grabbing; }
|
||||
.viewport-controls {
|
||||
position: absolute; z-index: 2; top: 12px; right: 12px;
|
||||
display: grid; grid-template-columns: repeat(3, 36px) auto;
|
||||
align-items: center; gap: 6px; padding: 6px;
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
background: rgba(7, 16, 26, .9); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.viewport-control {
|
||||
width: 36px; height: 34px; border: 1px solid #31526d; border-radius: 7px;
|
||||
background: #102b3d; color: var(--text); font-weight: 700;
|
||||
}
|
||||
.viewport-control:hover, .viewport-control:focus-visible {
|
||||
border-color: var(--accent); outline: 2px solid transparent;
|
||||
}
|
||||
.zoom-level {
|
||||
min-width: 48px; padding: 0 5px; color: var(--muted);
|
||||
font-variant-numeric: tabular-nums; text-align: right;
|
||||
}
|
||||
.viewport-hint {
|
||||
position: absolute; z-index: 1; left: 12px; bottom: 12px;
|
||||
padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
|
||||
background: rgba(7, 16, 26, .78); color: var(--muted); font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge { stroke: #476177; stroke-opacity: .56; stroke-width: 1.2; }
|
||||
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
|
||||
.node { cursor: pointer; }
|
||||
.node circle { fill: #17344a; stroke: #70b9d4; stroke-width: 1.5; }
|
||||
.node.root circle { fill: #19566a; stroke: var(--accent-2); stroke-width: 3; }
|
||||
.node:hover circle { stroke: #fff; stroke-width: 3; }
|
||||
|
|
@ -721,9 +749,19 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
<div class="results" id="results"></div>
|
||||
</aside>
|
||||
<main class="canvas">
|
||||
<div class="viewport-controls" aria-label="Graph viewport controls">
|
||||
<button class="viewport-control" id="zoom-in" type="button"
|
||||
title="Zoom in" aria-label="Zoom in">+</button>
|
||||
<button class="viewport-control" id="zoom-out" type="button"
|
||||
title="Zoom out" aria-label="Zoom out">−</button>
|
||||
<button class="viewport-control" id="reset-view" type="button"
|
||||
title="Reset view" aria-label="Reset graph view">⌂</button>
|
||||
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
|
||||
</div>
|
||||
<svg id="graph" viewBox="-600 -410 1200 820"
|
||||
role="img" aria-label="Node neighborhood"></svg>
|
||||
<div class="empty" id="empty">Search for a node to inspect its neighborhood.</div>
|
||||
<div class="viewport-hint">Mouse wheel to zoom · left-drag to pan</div>
|
||||
</main>
|
||||
<aside class="right">
|
||||
<div id="details"><p class="summary">Choose a search result or graph node.</p></div>
|
||||
|
|
@ -732,7 +770,17 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
</div>
|
||||
<script>
|
||||
const base = location.pathname.replace(/\/?$/, "/");
|
||||
const state = { overview: null, graph: null, root: null, depth: 1, searchLimit: 1 };
|
||||
const defaultViewport = Object.freeze({x: -600, y: -410, width: 1200, height: 820});
|
||||
const state = {
|
||||
overview: null,
|
||||
graph: null,
|
||||
root: null,
|
||||
depth: 1,
|
||||
searchLimit: 1,
|
||||
viewport: {...defaultViewport},
|
||||
pointer: null,
|
||||
suppressClick: false,
|
||||
};
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const api = async (path) => {
|
||||
const response = await fetch(`${base}api/${path}`, {cache: "no-store"});
|
||||
|
|
@ -747,6 +795,38 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
const text = escapeText(value);
|
||||
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
||||
};
|
||||
function applyViewport() {
|
||||
const view = state.viewport;
|
||||
$("graph").setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
|
||||
const zoom = Math.round((defaultViewport.width / view.width) * 100);
|
||||
$("zoom-level").textContent = `${zoom}%`;
|
||||
}
|
||||
function resetViewport() {
|
||||
state.viewport = {...defaultViewport};
|
||||
applyViewport();
|
||||
}
|
||||
function zoomAt(factor, clientX = null, clientY = null) {
|
||||
const svg = $("graph");
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const current = state.viewport;
|
||||
const nextWidth = Math.min(
|
||||
defaultViewport.width * 4,
|
||||
Math.max(defaultViewport.width * .2, current.width * factor),
|
||||
);
|
||||
const nextHeight = nextWidth * (defaultViewport.height / defaultViewport.width);
|
||||
const ratioX = clientX === null ? .5 : (clientX - rect.left) / rect.width;
|
||||
const ratioY = clientY === null ? .5 : (clientY - rect.top) / rect.height;
|
||||
const anchorX = current.x + ratioX * current.width;
|
||||
const anchorY = current.y + ratioY * current.height;
|
||||
state.viewport = {
|
||||
x: anchorX - ratioX * nextWidth,
|
||||
y: anchorY - ratioY * nextHeight,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
applyViewport();
|
||||
}
|
||||
function setStatus(message, error = false) {
|
||||
$("status").textContent = message;
|
||||
$("status").classList.toggle("error", error);
|
||||
|
|
@ -814,6 +894,7 @@ _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;
|
||||
|
|
@ -852,7 +933,9 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
const family = svgElement("text", {y: 48, "text-anchor": "middle", class: "family"});
|
||||
family.textContent = short(node.family, 22);
|
||||
group.append(title, family);
|
||||
group.addEventListener("click", () => loadNode(node.node_id));
|
||||
group.addEventListener("click", () => {
|
||||
if (!state.suppressClick) loadNode(node.node_id);
|
||||
});
|
||||
group.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") loadNode(node.node_id);
|
||||
});
|
||||
|
|
@ -928,6 +1011,59 @@ _GRAPH_BROWSER_HTML = r"""<!doctype html>
|
|||
}
|
||||
$("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); });
|
||||
$("family").addEventListener("change", search);
|
||||
$("zoom-in").addEventListener("click", () => zoomAt(.8));
|
||||
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
|
||||
$("reset-view").addEventListener("click", resetViewport);
|
||||
$("graph").addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
zoomAt(event.deltaY < 0 ? .85 : 1.18, event.clientX, event.clientY);
|
||||
}, {passive: false});
|
||||
$("graph").addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0) return;
|
||||
const svg = $("graph");
|
||||
state.suppressClick = false;
|
||||
state.pointer = {
|
||||
id: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
viewport: {...state.viewport},
|
||||
moved: false,
|
||||
};
|
||||
svg.setPointerCapture(event.pointerId);
|
||||
});
|
||||
$("graph").addEventListener("pointermove", (event) => {
|
||||
const pointer = state.pointer;
|
||||
if (!pointer || pointer.id !== event.pointerId) return;
|
||||
const svg = $("graph");
|
||||
const rect = svg.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
const deltaX = event.clientX - pointer.startX;
|
||||
const deltaY = event.clientY - pointer.startY;
|
||||
if (!pointer.moved && Math.hypot(deltaX, deltaY) < 4) return;
|
||||
pointer.moved = true;
|
||||
state.suppressClick = true;
|
||||
svg.closest(".canvas").classList.add("dragging");
|
||||
state.viewport = {
|
||||
...pointer.viewport,
|
||||
x: pointer.viewport.x - deltaX * (pointer.viewport.width / rect.width),
|
||||
y: pointer.viewport.y - deltaY * (pointer.viewport.height / rect.height),
|
||||
};
|
||||
applyViewport();
|
||||
});
|
||||
function endPan(event) {
|
||||
const pointer = state.pointer;
|
||||
if (!pointer || pointer.id !== event.pointerId) return;
|
||||
const svg = $("graph");
|
||||
if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId);
|
||||
svg.closest(".canvas").classList.remove("dragging");
|
||||
state.pointer = null;
|
||||
if (pointer.moved) {
|
||||
setTimeout(() => { state.suppressClick = false; }, 0);
|
||||
}
|
||||
}
|
||||
$("graph").addEventListener("pointerup", endPan);
|
||||
$("graph").addEventListener("pointercancel", endPan);
|
||||
applyViewport();
|
||||
(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue