Add relationship-aware graph flow
This commit is contained in:
parent
9bd41c5982
commit
440ca7510f
11 changed files with 567 additions and 80 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@7"
|
||||
VISUALIZATION_TEMPLATE = "graph-browser@8"
|
||||
DEFAULT_EDGE_LIMIT = 100
|
||||
MAX_EDGE_LIMIT = 400
|
||||
DEFAULT_INITIAL_GRACE_SECONDS = 120.0
|
||||
|
|
@ -1046,17 +1046,18 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.node-list-copy span { color: var(--muted); font-size: 11px; }
|
||||
.legend {
|
||||
.node-legend {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 12px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.legend span {
|
||||
.node-legend span {
|
||||
display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px;
|
||||
}
|
||||
.legend i { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.legend-primary i { background: var(--primary-stroke); }
|
||||
.legend-child i { background: var(--child-stroke); }
|
||||
.legend-edge i { background: var(--edge-stroke); }
|
||||
.node-legend span[hidden] { display: none; }
|
||||
.node-legend i { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.node-legend-primary i { background: var(--primary-stroke); }
|
||||
.node-legend-child i { background: var(--child-stroke); }
|
||||
.node-legend-edge i { background: var(--edge-stroke); }
|
||||
.result {
|
||||
width: 100%; text-align: left; border: 1px solid var(--line); border-radius: 9px;
|
||||
padding: 9px; background: var(--panel-2); color: var(--text);
|
||||
|
|
@ -1094,11 +1095,41 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
background: rgba(7, 16, 26, .78); color: var(--muted); font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.edge { stroke-opacity: .68; stroke-width: 1.4; }
|
||||
.edge.child-edge { stroke: #4cbe8a; }
|
||||
.edge.context-edge { stroke: #a77bd6; }
|
||||
.edge.boundary-edge { stroke: #52718b; stroke-dasharray: 5 4; }
|
||||
.edge-label { fill: #8198ae; font-size: 9px; pointer-events: none; }
|
||||
.relationship-key {
|
||||
position: absolute; z-index: 2; top: 12px; left: 12px;
|
||||
width: min(310px, calc(100% - 100px)); max-height: calc(100% - 64px);
|
||||
overflow: hidden; border: 1px solid var(--line); border-radius: 10px;
|
||||
background: rgba(7, 16, 26, .92); box-shadow: 0 5px 18px rgba(0, 0, 0, .28);
|
||||
}
|
||||
.relationship-key summary {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
padding: 8px 10px; color: var(--text); cursor: pointer; font-size: 11px;
|
||||
font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
}
|
||||
.relationship-key summary::marker { color: var(--accent); }
|
||||
.relationship-key-count {
|
||||
color: var(--muted); font-size: 10px; font-weight: 500; letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.relationship-key-list {
|
||||
display: grid; gap: 5px; max-height: min(360px, calc(100vh - 190px));
|
||||
overflow: auto; margin: 0; padding: 2px 10px 10px; list-style: none;
|
||||
}
|
||||
.relationship-key-item {
|
||||
display: grid; grid-template-columns: 58px minmax(0, 1fr) auto;
|
||||
gap: 8px; align-items: center; color: var(--text); font-size: 11px;
|
||||
}
|
||||
.relationship-symbol { display: block; width: 58px; height: 14px; overflow: visible; }
|
||||
.relationship-key-item small { color: var(--muted); }
|
||||
.relationship-key-empty { margin: 2px 0; color: var(--muted); font-size: 11px; }
|
||||
.relationship-edge {
|
||||
fill: none; stroke-opacity: .8; stroke-width: 1.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.edge-label {
|
||||
font-size: 9px; font-weight: 650; pointer-events: none;
|
||||
paint-order: stroke; stroke: #07101a; stroke-width: 3px; stroke-linejoin: round;
|
||||
}
|
||||
.node { cursor: pointer; }
|
||||
.node:focus { outline: none; }
|
||||
.node > circle:not(.selection-ring) {
|
||||
|
|
@ -1253,6 +1284,14 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
title="Reset view" aria-label="Reset graph view">⌂</button>
|
||||
<output class="zoom-level" id="zoom-level" aria-live="polite">100%</output>
|
||||
</div>
|
||||
<details class="relationship-key" id="relationship-key" open>
|
||||
<summary>
|
||||
<span>Relationships</span>
|
||||
<span class="relationship-key-count" id="relationship-key-count">0 visible</span>
|
||||
</summary>
|
||||
<ul class="relationship-key-list" id="relationship-key-list"
|
||||
aria-label="Visible relationship color and symbol key"></ul>
|
||||
</details>
|
||||
<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>
|
||||
|
|
@ -1270,10 +1309,10 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
<aside class="right" aria-label="Neighborhood navigation">
|
||||
<div class="neighborhood" id="neighborhood" hidden>
|
||||
<h2 class="neighborhood-title">Neighborhood</h2>
|
||||
<div class="legend" role="group" aria-label="Node role colors">
|
||||
<span class="legend-primary"><i></i>Primary</span>
|
||||
<span class="legend-child"><i></i>Children</span>
|
||||
<span class="legend-edge"><i></i>Edge</span>
|
||||
<div class="node-legend" role="group" aria-label="Node role colors">
|
||||
<span class="node-legend-primary"><i></i><b id="primary-role-label">Focus</b></span>
|
||||
<span class="node-legend-child"><i></i><b id="child-role-label">Outgoing</b></span>
|
||||
<span class="node-legend-edge"><i></i><b id="edge-role-label">Incoming</b></span>
|
||||
</div>
|
||||
<div id="neighborhood-sections"></div>
|
||||
</div>
|
||||
|
|
@ -1329,6 +1368,79 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
dialogDrag: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
const relationStyles = Object.freeze({
|
||||
contains: {
|
||||
family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow",
|
||||
flow: null,
|
||||
},
|
||||
defines: {
|
||||
family: "Structure", color: "#38bdf8", dash: "7 3", marker: "diamond-arrow",
|
||||
flow: null,
|
||||
},
|
||||
defined_in: {
|
||||
family: "Structure", color: "#7dd3fc", dash: "3 3", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
implemented_by: {
|
||||
family: "Structure", color: "#818cf8", dash: "8 3", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
calls: {
|
||||
family: "Execution", color: "#34d399", dash: "", marker: "arrow",
|
||||
flow: "forward",
|
||||
},
|
||||
dispatches_to: {
|
||||
family: "Execution", color: "#2dd4bf", dash: "9 3", marker: "double-arrow",
|
||||
flow: "forward",
|
||||
},
|
||||
launches: {
|
||||
family: "Execution", color: "#a3e635", dash: "11 4", marker: "arrow",
|
||||
flow: "forward",
|
||||
},
|
||||
activates: {
|
||||
family: "Execution", color: "#facc15", dash: "4 3", marker: "double-arrow",
|
||||
flow: "forward",
|
||||
},
|
||||
reads: {
|
||||
family: "Data", color: "#22d3ee", dash: "3 4", marker: "circle-arrow",
|
||||
flow: "reverse",
|
||||
},
|
||||
writes: {
|
||||
family: "Data", color: "#fb7185", dash: "", marker: "square-arrow",
|
||||
flow: "forward",
|
||||
},
|
||||
imports: {
|
||||
family: "Dependency", color: "#fbbf24", dash: "3 3", marker: "open-arrow",
|
||||
flow: "reverse",
|
||||
},
|
||||
depends_on: {
|
||||
family: "Dependency", color: "#f59e0b", dash: "9 4", marker: "open-arrow",
|
||||
flow: "reverse",
|
||||
},
|
||||
tested_by: {
|
||||
family: "Evidence", color: "#c084fc", dash: "2 4", marker: "circle-arrow",
|
||||
flow: null,
|
||||
},
|
||||
verifies: {
|
||||
family: "Evidence", color: "#a78bfa", dash: "2 4", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
documents: {
|
||||
family: "Evidence", color: "#e879f9", dash: "2 5", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
governs: {
|
||||
family: "Evidence", color: "#f472b6", dash: "8 3 2 3", marker: "diamond-arrow",
|
||||
flow: null,
|
||||
},
|
||||
relates_to: {
|
||||
family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
});
|
||||
const fallbackRelationColors = Object.freeze([
|
||||
"#67e8f9", "#86efac", "#fde047", "#fdba74", "#f0abfc", "#a5b4fc",
|
||||
]);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const api = async (path) => {
|
||||
let response;
|
||||
|
|
@ -1350,6 +1462,134 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
const text = escapeText(value);
|
||||
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
||||
};
|
||||
function relationHash(relation) {
|
||||
let value = 2166136261;
|
||||
for (const character of relation) {
|
||||
value ^= character.codePointAt(0);
|
||||
value = Math.imul(value, 16777619);
|
||||
}
|
||||
return value >>> 0;
|
||||
}
|
||||
function relationStyle(relation) {
|
||||
if (relationStyles[relation]) return relationStyles[relation];
|
||||
return {
|
||||
family: "Other",
|
||||
color: fallbackRelationColors[relationHash(relation) % fallbackRelationColors.length],
|
||||
dash: "6 4",
|
||||
marker: "open-arrow",
|
||||
flow: null,
|
||||
};
|
||||
}
|
||||
function relationLabel(relation) {
|
||||
return relation.replaceAll("_", " ");
|
||||
}
|
||||
function relationMarkerId(relation) {
|
||||
return `relation-marker-${relationHash(relation).toString(36)}`;
|
||||
}
|
||||
function markerArtwork(marker, color) {
|
||||
const group = svgElement("g", {"aria-hidden": "true"});
|
||||
const path = (data, attributes = {}) => group.append(svgElement("path", {
|
||||
d: data,
|
||||
...attributes,
|
||||
}));
|
||||
if (marker === "open-arrow") {
|
||||
path("M1 1 L9 5 L1 9", {
|
||||
fill: "none", stroke: color, "stroke-width": "1.8",
|
||||
"stroke-linecap": "round", "stroke-linejoin": "round",
|
||||
});
|
||||
} else if (marker === "double-arrow") {
|
||||
path("M1 1 L5 5 L1 9 M5 1 L9 5 L5 9", {
|
||||
fill: "none", stroke: color, "stroke-width": "1.6",
|
||||
"stroke-linecap": "round", "stroke-linejoin": "round",
|
||||
});
|
||||
} else if (marker === "diamond-arrow") {
|
||||
path("M0 5 L3 2 L6 5 L3 8 Z", {fill: color});
|
||||
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
||||
} else if (marker === "circle-arrow") {
|
||||
group.append(svgElement("circle", {
|
||||
cx: "3.5", cy: "5", r: "2.3", fill: "none", stroke: color,
|
||||
"stroke-width": "1.4",
|
||||
}));
|
||||
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
||||
} else if (marker === "square-arrow") {
|
||||
group.append(svgElement("rect", {
|
||||
x: "1", y: "2.5", width: "5", height: "5", rx: ".7", fill: color,
|
||||
}));
|
||||
path("M6 1 L10 5 L6 9 Z", {fill: color});
|
||||
} else {
|
||||
path("M1 1 L10 5 L1 9 Z", {fill: color});
|
||||
}
|
||||
return group;
|
||||
}
|
||||
function appendRelationMarker(defs, relation) {
|
||||
const style = relationStyle(relation);
|
||||
const marker = svgElement("marker", {
|
||||
id: relationMarkerId(relation),
|
||||
viewBox: "0 0 11 10",
|
||||
refX: "10",
|
||||
refY: "5",
|
||||
markerWidth: "11",
|
||||
markerHeight: "10",
|
||||
orient: "auto",
|
||||
markerUnits: "userSpaceOnUse",
|
||||
});
|
||||
marker.append(markerArtwork(style.marker, style.color));
|
||||
defs.append(marker);
|
||||
}
|
||||
function relationSymbol(relation) {
|
||||
const style = relationStyle(relation);
|
||||
const svg = svgElement("svg", {
|
||||
viewBox: "0 0 58 14",
|
||||
class: "relationship-symbol",
|
||||
role: "img",
|
||||
"aria-label": `${relationLabel(relation)} relationship symbol`,
|
||||
});
|
||||
const line = svgElement("line", {
|
||||
x1: "2", y1: "7", x2: "43", y2: "7",
|
||||
stroke: style.color, "stroke-width": "2",
|
||||
});
|
||||
if (style.dash) line.setAttribute("stroke-dasharray", style.dash);
|
||||
const marker = svgElement("g", {transform: "translate(45 2) scale(.9)"});
|
||||
marker.append(markerArtwork(style.marker, style.color));
|
||||
svg.append(line, marker);
|
||||
return svg;
|
||||
}
|
||||
function renderRelationshipKey(edges) {
|
||||
const counts = new Map();
|
||||
for (const edge of edges) {
|
||||
counts.set(edge.relation, (counts.get(edge.relation) || 0) + 1);
|
||||
}
|
||||
const entries = [...counts.entries()].sort((first, second) => {
|
||||
const firstStyle = relationStyle(first[0]);
|
||||
const secondStyle = relationStyle(second[0]);
|
||||
return firstStyle.family.localeCompare(secondStyle.family)
|
||||
|| first[0].localeCompare(second[0]);
|
||||
});
|
||||
const container = $("relationship-key-list");
|
||||
container.replaceChildren();
|
||||
$("relationship-key-count").textContent = `${entries.length} visible`;
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "relationship-key-empty";
|
||||
empty.textContent = state.mode === "flow"
|
||||
? "No flow-capable relationships reach this focus."
|
||||
: "No relationships in this neighborhood.";
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const [relation, count] of entries) {
|
||||
const style = relationStyle(relation);
|
||||
const item = document.createElement("li");
|
||||
item.className = "relationship-key-item";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = relationLabel(relation);
|
||||
label.title = `${style.family} relationship`;
|
||||
const total = document.createElement("small");
|
||||
total.textContent = String(count);
|
||||
item.append(relationSymbol(relation), label, total);
|
||||
container.append(item);
|
||||
}
|
||||
}
|
||||
function applyViewport() {
|
||||
const view = state.viewport;
|
||||
$("graph").setAttribute("viewBox", `${view.x} ${view.y} ${view.width} ${view.height}`);
|
||||
|
|
@ -1540,6 +1780,58 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
},
|
||||
]));
|
||||
}
|
||||
function semanticFlowEdge(edge) {
|
||||
const direction = relationStyle(edge.relation).flow;
|
||||
if (direction === null) return null;
|
||||
if (direction === "reverse") {
|
||||
return {
|
||||
...edge,
|
||||
source_id: edge.target_id,
|
||||
target_id: edge.source_id,
|
||||
stored_source_id: edge.source_id,
|
||||
stored_target_id: edge.target_id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...edge,
|
||||
stored_source_id: edge.source_id,
|
||||
stored_target_id: edge.target_id,
|
||||
};
|
||||
}
|
||||
function buildFlowGraph(data) {
|
||||
const semanticEdges = data.edges.map(semanticFlowEdge).filter(Boolean);
|
||||
const upstreamHops = new Map([[data.root, 0]]);
|
||||
let frontier = [data.root];
|
||||
while (frontier.length) {
|
||||
const next = [];
|
||||
for (const targetId of frontier) {
|
||||
for (const edge of semanticEdges) {
|
||||
if (edge.target_id !== targetId || upstreamHops.has(edge.source_id)) continue;
|
||||
upstreamHops.set(edge.source_id, upstreamHops.get(targetId) + 1);
|
||||
next.push(edge.source_id);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
const nodes = data.nodes.filter((node) => upstreamHops.has(node.node_id));
|
||||
const nodeIds = new Set(nodes.map((node) => node.node_id));
|
||||
const edges = semanticEdges.filter(
|
||||
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
|
||||
);
|
||||
const topology = new Map(nodes.map((node) => [
|
||||
node.node_id,
|
||||
{
|
||||
hop: upstreamHops.get(node.node_id),
|
||||
role: node.node_id === data.root ? "primary" : "child",
|
||||
},
|
||||
]));
|
||||
return {
|
||||
...data,
|
||||
nodes,
|
||||
edges,
|
||||
topology,
|
||||
};
|
||||
}
|
||||
function layoutNodes(nodes, rootId, topology) {
|
||||
const ordered = [...nodes].sort((a, b) => {
|
||||
const first = topology.get(a.node_id);
|
||||
|
|
@ -1570,6 +1862,27 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
}
|
||||
return positions;
|
||||
}
|
||||
function layoutFlow(nodes, rootId, topology) {
|
||||
const layers = new Map();
|
||||
for (const node of nodes) {
|
||||
const hop = topology.get(node.node_id).hop;
|
||||
if (!layers.has(hop)) layers.set(hop, []);
|
||||
layers.get(hop).push(node);
|
||||
}
|
||||
const positions = new Map();
|
||||
for (const [hop, layer] of [...layers.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
layer.sort((first, second) => first.node_id.localeCompare(second.node_id));
|
||||
const spacing = 118;
|
||||
const top = -((layer.length - 1) * spacing) / 2;
|
||||
layer.forEach((node, index) => {
|
||||
positions.set(node.node_id, {
|
||||
x: node.node_id === rootId ? 0 : -hop * 230,
|
||||
y: top + index * spacing,
|
||||
});
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
function darken(hex, amount) {
|
||||
const value = Number.parseInt(hex.slice(1), 16);
|
||||
const factor = 1 - Math.min(.5, Math.max(0, amount));
|
||||
|
|
@ -1593,11 +1906,23 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
};
|
||||
}
|
||||
function renderNeighborhood(data, topology) {
|
||||
const sections = [
|
||||
{role: "primary", label: "Primary focus"},
|
||||
{role: "child", label: "Children"},
|
||||
{role: "edge", label: "Edge & context"},
|
||||
];
|
||||
const sections = state.mode === "flow"
|
||||
? [
|
||||
{role: "primary", label: "Flow destination"},
|
||||
{role: "child", label: "Upstream lineage"},
|
||||
]
|
||||
: [
|
||||
{role: "primary", label: "Focus node"},
|
||||
{role: "child", label: "Outgoing paths"},
|
||||
{role: "edge", label: "Incoming & lateral"},
|
||||
];
|
||||
$("neighborhood").querySelector(".neighborhood-title").textContent = state.mode === "flow"
|
||||
? "Upstream flow"
|
||||
: "Neighborhood";
|
||||
$("primary-role-label").textContent = state.mode === "flow" ? "Destination" : "Focus";
|
||||
$("child-role-label").textContent = state.mode === "flow" ? "Upstream" : "Outgoing";
|
||||
$("edge-role-label").textContent = "Incoming";
|
||||
$("edge-role-label").closest("span").hidden = state.mode === "flow";
|
||||
const container = $("neighborhood-sections");
|
||||
container.replaceChildren();
|
||||
for (const section of sections) {
|
||||
|
|
@ -1645,45 +1970,81 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value);
|
||||
return element;
|
||||
}
|
||||
function renderGraph(data) {
|
||||
function edgeEndpoints(source, target, sourceRadius, targetRadius) {
|
||||
const deltaX = target.x - source.x;
|
||||
const deltaY = target.y - source.y;
|
||||
const distance = Math.hypot(deltaX, deltaY) || 1;
|
||||
const unitX = deltaX / distance;
|
||||
const unitY = deltaY / distance;
|
||||
return {
|
||||
x1: source.x + unitX * (sourceRadius + 4),
|
||||
y1: source.y + unitY * (sourceRadius + 4),
|
||||
x2: target.x - unitX * (targetRadius + 13),
|
||||
y2: target.y - unitY * (targetRadius + 13),
|
||||
};
|
||||
}
|
||||
function topologyRoleLabel(role) {
|
||||
if (role === "primary") return state.mode === "flow" ? "flow destination" : "focus node";
|
||||
if (role === "child") return state.mode === "flow" ? "upstream node" : "outgoing node";
|
||||
return "incoming or lateral node";
|
||||
}
|
||||
function renderGraph(data, preserveSelection = false) {
|
||||
state.graph = data;
|
||||
state.root = data.root;
|
||||
const selectedCandidate = preserveSelection
|
||||
&& data.nodes.some((node) => node.node_id === state.selectedNode)
|
||||
? state.selectedNode
|
||||
: data.root;
|
||||
const view = state.mode === "flow" ? buildFlowGraph(data) : data;
|
||||
state.selectedNode = view.nodes.some((node) => node.node_id === selectedCandidate)
|
||||
? selectedCandidate
|
||||
: view.root;
|
||||
const svg = $("graph");
|
||||
svg.replaceChildren();
|
||||
$("empty").hidden = data.nodes.length > 0;
|
||||
const topology = analyzeTopology(data);
|
||||
const positions = layoutNodes(data.nodes, data.root, topology);
|
||||
$("empty").hidden = view.nodes.length > 0;
|
||||
const topology = view.topology || analyzeTopology(view);
|
||||
const positions = state.mode === "flow"
|
||||
? layoutFlow(view.nodes, view.root, topology)
|
||||
: layoutNodes(view.nodes, view.root, topology);
|
||||
state.positions = positions;
|
||||
state.selectedNode = data.root;
|
||||
state.homeViewport = viewportForPositions(positions);
|
||||
resetViewport();
|
||||
renderNeighborhood(data, topology);
|
||||
renderNeighborhood(view, topology);
|
||||
renderRelationshipKey(view.edges);
|
||||
const definitions = svgElement("defs");
|
||||
for (const relation of new Set(view.edges.map((edge) => edge.relation))) {
|
||||
appendRelationMarker(definitions, relation);
|
||||
}
|
||||
const edgeLayer = svgElement("g");
|
||||
const nodeLayer = svgElement("g");
|
||||
for (const edge of data.edges) {
|
||||
for (const edge of view.edges) {
|
||||
const source = positions.get(edge.source_id);
|
||||
const target = positions.get(edge.target_id);
|
||||
if (!source || !target) continue;
|
||||
const targetRole = topology.get(edge.target_id)?.role || "edge";
|
||||
const edgeRole = edge.source_id === data.root && targetRole === "child"
|
||||
? "child-edge"
|
||||
: edge.target_id === data.root || targetRole === "edge"
|
||||
? "context-edge"
|
||||
: "boundary-edge";
|
||||
edgeLayer.append(svgElement("line", {
|
||||
x1: source.x, y1: source.y, x2: target.x, y2: target.y,
|
||||
class: `edge ${edgeRole}`
|
||||
}));
|
||||
const label = svgElement("text", {
|
||||
x: (source.x + target.x) / 2,
|
||||
y: (source.y + target.y) / 2,
|
||||
class: "edge-label",
|
||||
"text-anchor": "middle"
|
||||
const style = relationStyle(edge.relation);
|
||||
const sourceRadius = edge.source_id === view.root ? 25 : 18;
|
||||
const targetRadius = edge.target_id === view.root ? 25 : 18;
|
||||
const points = edgeEndpoints(source, target, sourceRadius, targetRadius);
|
||||
const line = svgElement("line", {
|
||||
...points,
|
||||
class: "relationship-edge",
|
||||
stroke: style.color,
|
||||
"marker-end": `url(#${relationMarkerId(edge.relation)})`,
|
||||
"data-relation": edge.relation,
|
||||
});
|
||||
label.textContent = edge.relation;
|
||||
if (style.dash) line.setAttribute("stroke-dasharray", style.dash);
|
||||
edgeLayer.append(line);
|
||||
const label = svgElement("text", {
|
||||
x: (points.x1 + points.x2) / 2,
|
||||
y: (points.y1 + points.y2) / 2 - 5,
|
||||
class: "edge-label",
|
||||
fill: style.color,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = relationLabel(edge.relation);
|
||||
edgeLayer.append(label);
|
||||
}
|
||||
for (const node of data.nodes) {
|
||||
for (const node of view.nodes) {
|
||||
const point = positions.get(node.node_id);
|
||||
if (!point) continue;
|
||||
const topologyNode = topology.get(node.node_id);
|
||||
|
|
@ -1692,7 +2053,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
class: [
|
||||
"node",
|
||||
topologyNode.role,
|
||||
node.node_id === data.root ? "root" : "",
|
||||
node.node_id === view.root ? "root" : "",
|
||||
node.node_id === state.selectedNode ? "selected" : "",
|
||||
].filter(Boolean).join(" "),
|
||||
transform: `translate(${point.x} ${point.y})`,
|
||||
|
|
@ -1702,16 +2063,17 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
role: "button",
|
||||
"aria-pressed": String(node.node_id === state.selectedNode),
|
||||
"aria-label": [
|
||||
node.title, node.family, topologyNode.role, `${topologyNode.hop} hops`
|
||||
node.title, node.family, topologyRoleLabel(topologyNode.role),
|
||||
`${topologyNode.hop} hops`,
|
||||
].join(", ")
|
||||
});
|
||||
group.append(svgElement("circle", {
|
||||
r: node.node_id === data.root ? 25 : 18,
|
||||
r: node.node_id === view.root ? 25 : 18,
|
||||
fill: palette.fill,
|
||||
stroke: palette.stroke,
|
||||
}));
|
||||
group.append(svgElement("circle", {
|
||||
r: node.node_id === data.root ? 32 : 25,
|
||||
r: node.node_id === view.root ? 32 : 25,
|
||||
class: "selection-ring",
|
||||
}));
|
||||
const title = svgElement("text", {y: 35, "text-anchor": "middle"});
|
||||
|
|
@ -1740,7 +2102,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
});
|
||||
nodeLayer.append(group);
|
||||
}
|
||||
svg.append(edgeLayer, nodeLayer);
|
||||
svg.append(definitions, edgeLayer, nodeLayer);
|
||||
}
|
||||
function renderDetails(details, node, data, interactiveBadges = false) {
|
||||
details.replaceChildren();
|
||||
|
|
@ -1885,16 +2247,31 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
const data = await api(`node?${params}`);
|
||||
renderGraph(data);
|
||||
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`);
|
||||
history.replaceState(null, "", `?node=${encodeURIComponent(nodeId)}&depth=${state.depth}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`?node=${encodeURIComponent(nodeId)}&depth=${state.depth}&view=${state.mode}`,
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(error.message, true);
|
||||
}
|
||||
}
|
||||
function setViewMode(mode) {
|
||||
if (mode !== "nodes" && mode !== "flow") return;
|
||||
state.mode = mode;
|
||||
$("view-switch").dataset.mode = mode;
|
||||
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
|
||||
$("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
|
||||
if (state.graph) {
|
||||
renderGraph(state.graph, true);
|
||||
const suffix = mode === "flow" ? "upstream flow" : "node neighborhood";
|
||||
setStatus(`Showing ${suffix} for ${state.root}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`?node=${encodeURIComponent(state.root)}&depth=${state.depth}&view=${state.mode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
|
|
@ -2108,6 +2485,7 @@ _GRAPH_BROWSER_HTML = r"""<!DOCTYPE html>
|
|||
try {
|
||||
const params = new URLSearchParams(location.search);
|
||||
state.depth = Math.max(1, Number(params.get("depth")) || 1);
|
||||
setViewMode(params.get("view") === "flow" ? "flow" : "nodes");
|
||||
const overview = await api("overview");
|
||||
renderOverview(overview);
|
||||
startViewerLease();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue