Add semantic flow and convergence web views
This commit is contained in:
parent
f9f7105983
commit
6609edc804
14 changed files with 616 additions and 128 deletions
|
|
@ -32,7 +32,7 @@ header {
|
|||
}
|
||||
header h1 { margin: 0; font-size: 17px; }
|
||||
.view-switch {
|
||||
position: relative; display: grid; grid-template-columns: repeat(2, 58px);
|
||||
position: relative; display: grid; grid-template-columns: repeat(3, 58px);
|
||||
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
|
||||
background: #08131f; isolation: isolate;
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ header h1 { margin: 0; font-size: 17px; }
|
|||
transition: transform .18s ease;
|
||||
}
|
||||
.view-switch[data-mode="flow"]::before { transform: translateX(58px); }
|
||||
.view-switch[data-mode="web"]::before { transform: translateX(116px); }
|
||||
.view-switch button {
|
||||
min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px;
|
||||
background: transparent; color: var(--muted); font-size: 12px; font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
role="group" aria-label="Graph view">
|
||||
<button id="view-nodes" type="button" aria-pressed="true">Nodes</button>
|
||||
<button id="view-flow" type="button" aria-pressed="false">Flow</button>
|
||||
<button id="view-web" type="button" aria-pressed="false">Web</button>
|
||||
</div>
|
||||
<h1 id="project-title">DocForge graph</h1>
|
||||
<div class="stats">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const state = {
|
|||
inspectedNode: null,
|
||||
cardNode: null,
|
||||
hiddenNodes: new Set(),
|
||||
visibleNodeCount: 0,
|
||||
visibleEdgeCount: 0,
|
||||
prunedCount: 0,
|
||||
dialogDrag: null,
|
||||
leaseTimer: null,
|
||||
};
|
||||
|
|
@ -36,6 +39,10 @@ const relationStyles = Object.freeze({
|
|||
family: "Structure", color: "#818cf8", dash: "8 3", marker: "open-arrow",
|
||||
flow: null,
|
||||
},
|
||||
inherits: {
|
||||
family: "Structure", color: "#a5b4fc", dash: "5 3", marker: "diamond-arrow",
|
||||
flow: "reverse",
|
||||
},
|
||||
calls: {
|
||||
family: "Execution", color: "#34d399", dash: "", marker: "arrow",
|
||||
flow: "forward",
|
||||
|
|
@ -92,6 +99,26 @@ const relationStyles = Object.freeze({
|
|||
const fallbackRelationColors = Object.freeze([
|
||||
"#67e8f9", "#86efac", "#fde047", "#fdba74", "#f0abfc", "#a5b4fc",
|
||||
]);
|
||||
const reversedRelationLabels = Object.freeze({
|
||||
contains: "part of",
|
||||
defines: "defined by",
|
||||
defined_in: "contains",
|
||||
implemented_by: "implements",
|
||||
inherits: "base of",
|
||||
calls: "used by",
|
||||
dispatches_to: "receives dispatch from",
|
||||
launches: "launched by",
|
||||
activates: "activated by",
|
||||
reads: "read by",
|
||||
writes: "written by",
|
||||
imports: "imported by",
|
||||
depends_on: "required by",
|
||||
tested_by: "tests",
|
||||
verifies: "verified by",
|
||||
documents: "documented by",
|
||||
governs: "governed by",
|
||||
relates_to: "related from",
|
||||
});
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const api = async (path) => {
|
||||
let response;
|
||||
|
|
@ -134,7 +161,9 @@ function relationStyle(relation) {
|
|||
flow: null,
|
||||
};
|
||||
}
|
||||
function relationLabel(relation) {
|
||||
function relationLabel(relation, reversed = false) {
|
||||
if (reversed && reversedRelationLabels[relation]) return reversedRelationLabels[relation];
|
||||
if (reversed) return `reverse ${relation.replaceAll("_", " ")}`;
|
||||
return relation.replaceAll("_", " ");
|
||||
}
|
||||
function relationMarkerId(relation) {
|
||||
|
|
@ -190,13 +219,13 @@ function appendRelationMarker(defs, relation) {
|
|||
marker.append(markerArtwork(style.marker, style.color));
|
||||
defs.append(marker);
|
||||
}
|
||||
function relationSymbol(relation) {
|
||||
function relationSymbol(relation, reversed = false) {
|
||||
const style = relationStyle(relation);
|
||||
const svg = svgElement("svg", {
|
||||
viewBox: "0 0 58 14",
|
||||
class: "relationship-symbol",
|
||||
role: "img",
|
||||
"aria-label": `${relationLabel(relation)} relationship symbol`,
|
||||
"aria-label": `${relationLabel(relation, reversed)} relationship symbol`,
|
||||
});
|
||||
const line = svgElement("line", {
|
||||
x1: "2", y1: "7", x2: "43", y2: "7",
|
||||
|
|
@ -211,11 +240,14 @@ function relationSymbol(relation) {
|
|||
function renderRelationshipKey(edges) {
|
||||
const counts = new Map();
|
||||
for (const edge of edges) {
|
||||
counts.set(edge.relation, (counts.get(edge.relation) || 0) + 1);
|
||||
const key = `${edge.relation}\u0000${edge.reversed ? "1" : "0"}`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
const entries = [...counts.entries()].sort((first, second) => {
|
||||
const firstStyle = relationStyle(first[0]);
|
||||
const secondStyle = relationStyle(second[0]);
|
||||
const firstRelation = first[0].split("\u0000", 1)[0];
|
||||
const secondRelation = second[0].split("\u0000", 1)[0];
|
||||
const firstStyle = relationStyle(firstRelation);
|
||||
const secondStyle = relationStyle(secondRelation);
|
||||
return firstStyle.family.localeCompare(secondStyle.family)
|
||||
|| first[0].localeCompare(second[0]);
|
||||
});
|
||||
|
|
@ -225,22 +257,24 @@ function renderRelationshipKey(edges) {
|
|||
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.";
|
||||
empty.textContent = state.mode === "nodes"
|
||||
? "No relationships in this neighborhood."
|
||||
: `No ${state.mode}-capable relationships reach this focus.`;
|
||||
container.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const [relation, count] of entries) {
|
||||
for (const [key, count] of entries) {
|
||||
const [relation, reversedValue] = key.split("\u0000");
|
||||
const reversed = reversedValue === "1";
|
||||
const style = relationStyle(relation);
|
||||
const item = document.createElement("li");
|
||||
item.className = "relationship-key-item";
|
||||
const label = document.createElement("span");
|
||||
label.textContent = relationLabel(relation);
|
||||
label.textContent = relationLabel(relation, reversed);
|
||||
label.title = `${style.family} relationship`;
|
||||
const total = document.createElement("small");
|
||||
total.textContent = String(count);
|
||||
item.append(relationSymbol(relation), label, total);
|
||||
item.append(relationSymbol(relation, reversed), label, total);
|
||||
container.append(item);
|
||||
}
|
||||
}
|
||||
|
|
@ -330,9 +364,13 @@ function setStatus(message, error = false) {
|
|||
}
|
||||
function restoreGraphStatus() {
|
||||
if (!state.graph) return;
|
||||
const nodeCount = state.graph.nodes.length;
|
||||
const edgeCount = state.graph.edges.length;
|
||||
setStatus(`${nodeCount} nodes · ${edgeCount} edges in neighborhood`);
|
||||
const scope = {
|
||||
nodes: "neighborhood",
|
||||
flow: "semantic flow",
|
||||
web: "convergence web",
|
||||
}[state.mode];
|
||||
const pruned = state.prunedCount ? ` · ${state.prunedCount} hidden or isolated` : "";
|
||||
setStatus(`${state.visibleNodeCount} nodes · ${state.visibleEdgeCount} edges in ${scope}${pruned}`);
|
||||
}
|
||||
async function renewViewerLease() {
|
||||
try {
|
||||
|
|
@ -441,10 +479,9 @@ function analyzeTopology(data) {
|
|||
]));
|
||||
}
|
||||
function buildFlowGraph(data) {
|
||||
// The server has already supplied the complete directed ancestry for this
|
||||
// focus. Keep every stored edge as-is: source -> target. Flow must show
|
||||
// what literally leads to the selected terminal, not infer an alternate
|
||||
// direction from a relationship label.
|
||||
// The server supplies relation-aware semantic ancestry. Dependency, import,
|
||||
// inheritance, read, and tested-by edges may be reversed there so every
|
||||
// rendered arrow points from an origin or prerequisite toward the focus.
|
||||
const lineageEdges = data.edges;
|
||||
const upstreamHops = new Map([[data.root, 0]]);
|
||||
let frontier = [data.root];
|
||||
|
|
@ -478,6 +515,57 @@ function buildFlowGraph(data) {
|
|||
topology,
|
||||
};
|
||||
}
|
||||
function buildWebGraph(data) {
|
||||
const hops = new Map(Object.entries(data.hops || {}).map(
|
||||
([nodeId, hop]) => [nodeId, Number(hop)],
|
||||
));
|
||||
const nodes = data.nodes.filter((node) => hops.has(node.node_id));
|
||||
const nodeIds = new Set(nodes.map((node) => node.node_id));
|
||||
const edges = data.edges.filter(
|
||||
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
|
||||
);
|
||||
const topology = new Map(nodes.map((node) => [
|
||||
node.node_id,
|
||||
{
|
||||
hop: hops.get(node.node_id) ?? 0,
|
||||
role: node.node_id === data.root ? "primary" : "child",
|
||||
},
|
||||
]));
|
||||
return {...data, nodes, edges, topology};
|
||||
}
|
||||
function pruneConvergenceGraph(data, hiddenNodes) {
|
||||
const candidates = new Set(
|
||||
data.nodes
|
||||
.filter((node) => node.node_id === data.root || !hiddenNodes.has(node.node_id))
|
||||
.map((node) => node.node_id),
|
||||
);
|
||||
const edges = data.edges.filter(
|
||||
(edge) => candidates.has(edge.source_id) && candidates.has(edge.target_id),
|
||||
);
|
||||
const incoming = new Map([...candidates].map((nodeId) => [nodeId, new Set()]));
|
||||
for (const edge of edges) incoming.get(edge.target_id).add(edge.source_id);
|
||||
const reachesFocus = new Set([data.root]);
|
||||
let frontier = [data.root];
|
||||
while (frontier.length) {
|
||||
const next = [];
|
||||
for (const targetId of frontier) {
|
||||
for (const sourceId of incoming.get(targetId) || []) {
|
||||
if (reachesFocus.has(sourceId)) continue;
|
||||
reachesFocus.add(sourceId);
|
||||
next.push(sourceId);
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
nodes: data.nodes.filter((node) => reachesFocus.has(node.node_id)),
|
||||
edges: edges.filter(
|
||||
(edge) => reachesFocus.has(edge.source_id) && reachesFocus.has(edge.target_id),
|
||||
),
|
||||
prunedCount: data.nodes.length - reachesFocus.size,
|
||||
};
|
||||
}
|
||||
function layoutNodes(nodes, rootId, topology) {
|
||||
const ordered = [...nodes].sort((a, b) => {
|
||||
const first = topology.get(a.node_id);
|
||||
|
|
@ -553,23 +641,29 @@ function nodePalette(role, hop) {
|
|||
}
|
||||
function renderNeighborhood(data, topology) {
|
||||
$("neighborhood-empty").hidden = true;
|
||||
const sections = state.mode === "flow"
|
||||
const sections = state.mode === "nodes"
|
||||
? [
|
||||
{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"},
|
||||
]
|
||||
: [
|
||||
{role: "primary", label: `${state.mode === "flow" ? "Flow" : "Web"} destination`},
|
||||
{role: "child", label: state.mode === "flow" ? "Semantic origins" : "Contributors"},
|
||||
];
|
||||
$("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";
|
||||
$("neighborhood").querySelector(".neighborhood-title").textContent = {
|
||||
nodes: "Neighborhood",
|
||||
flow: "Semantic flow",
|
||||
web: "Convergence web",
|
||||
}[state.mode];
|
||||
$("primary-role-label").textContent = state.mode === "nodes" ? "Focus" : "Destination";
|
||||
$("child-role-label").textContent = {
|
||||
nodes: "Outgoing",
|
||||
flow: "Origins",
|
||||
web: "Contributors",
|
||||
}[state.mode];
|
||||
$("edge-role-label").textContent = "Incoming";
|
||||
$("edge-role-label").closest("span").hidden = state.mode === "flow";
|
||||
$("edge-role-label").closest("span").hidden = state.mode !== "nodes";
|
||||
const container = $("neighborhood-sections");
|
||||
container.replaceChildren();
|
||||
for (const section of sections) {
|
||||
|
|
@ -631,8 +725,10 @@ function edgeEndpoints(source, target, sourceRadius, targetRadius) {
|
|||
};
|
||||
}
|
||||
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";
|
||||
if (role === "primary") return state.mode === "nodes" ? "focus node" : `${state.mode} destination`;
|
||||
if (role === "child") {
|
||||
return state.mode === "nodes" ? "outgoing node" : `${state.mode} contributor`;
|
||||
}
|
||||
return "incoming or lateral node";
|
||||
}
|
||||
function renderGraph(data, preserveSelection = false) {
|
||||
|
|
@ -642,26 +738,37 @@ function renderGraph(data, preserveSelection = false) {
|
|||
&& data.nodes.some((node) => node.node_id === state.selectedNode)
|
||||
? state.selectedNode
|
||||
: data.root;
|
||||
const completeView = state.mode === "flow" ? buildFlowGraph(data) : data;
|
||||
const visibleIds = new Set(
|
||||
completeView.nodes
|
||||
.filter((node) => node.node_id === completeView.root
|
||||
|| !state.hiddenNodes.has(node.node_id))
|
||||
.map((node) => node.node_id),
|
||||
);
|
||||
const view = {
|
||||
...completeView,
|
||||
nodes: completeView.nodes.filter((node) => visibleIds.has(node.node_id)),
|
||||
edges: completeView.edges.filter(
|
||||
(edge) => visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id),
|
||||
),
|
||||
};
|
||||
const completeView = state.mode === "flow"
|
||||
? buildFlowGraph(data)
|
||||
: state.mode === "web" ? buildWebGraph(data) : data;
|
||||
let view;
|
||||
if (state.mode === "nodes") {
|
||||
const visibleIds = new Set(
|
||||
completeView.nodes
|
||||
.filter((node) => node.node_id === completeView.root
|
||||
|| !state.hiddenNodes.has(node.node_id))
|
||||
.map((node) => node.node_id),
|
||||
);
|
||||
view = {
|
||||
...completeView,
|
||||
nodes: completeView.nodes.filter((node) => visibleIds.has(node.node_id)),
|
||||
edges: completeView.edges.filter(
|
||||
(edge) => visibleIds.has(edge.source_id) && visibleIds.has(edge.target_id),
|
||||
),
|
||||
prunedCount: completeView.nodes.length - visibleIds.size,
|
||||
};
|
||||
} else {
|
||||
view = pruneConvergenceGraph(completeView, state.hiddenNodes);
|
||||
}
|
||||
const hiddenCount = completeView.nodes.length - view.nodes.length;
|
||||
state.visibleNodeCount = view.nodes.length;
|
||||
state.visibleEdgeCount = view.edges.length;
|
||||
state.prunedCount = view.prunedCount || 0;
|
||||
const restore = $("restore-hidden");
|
||||
restore.hidden = state.hiddenNodes.size === 0;
|
||||
restore.textContent = `Restore hidden (${state.hiddenNodes.size})`;
|
||||
restore.title = hiddenCount
|
||||
? `${hiddenCount} hidden in this view; restore all hidden nodes`
|
||||
? `${hiddenCount} hidden or isolated upstream in this view; restore all hidden nodes`
|
||||
: "Restore hidden nodes from other views";
|
||||
state.selectedNode = view.nodes.some((node) => node.node_id === selectedCandidate)
|
||||
? selectedCandidate
|
||||
|
|
@ -670,7 +777,7 @@ function renderGraph(data, preserveSelection = false) {
|
|||
svg.replaceChildren();
|
||||
$("empty").hidden = view.nodes.length > 0;
|
||||
const topology = view.topology || analyzeTopology(view);
|
||||
const positions = state.mode === "flow"
|
||||
const positions = state.mode !== "nodes"
|
||||
? layoutFlow(view.nodes, view.root, topology)
|
||||
: layoutNodes(view.nodes, view.root, topology);
|
||||
state.positions = positions;
|
||||
|
|
@ -708,7 +815,7 @@ function renderGraph(data, preserveSelection = false) {
|
|||
fill: style.color,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = relationLabel(edge.relation);
|
||||
label.textContent = relationLabel(edge.relation, edge.reversed);
|
||||
edgeLayer.append(label);
|
||||
}
|
||||
for (const node of view.nodes) {
|
||||
|
|
@ -780,7 +887,11 @@ function hideNode(nodeId) {
|
|||
closeNodeCard();
|
||||
closeNodeDialog();
|
||||
renderGraph(state.graph, true);
|
||||
setStatus(`Hidden ${nodeId}. Restore hidden nodes from the graph controls.`);
|
||||
const isolated = Math.max(0, state.prunedCount - 1);
|
||||
const suffix = isolated
|
||||
? ` ${isolated} upstream node${isolated === 1 ? " was" : "s were"} isolated.`
|
||||
: "";
|
||||
setStatus(`Hidden ${nodeId}.${suffix} Restore hidden nodes from the graph controls.`);
|
||||
}
|
||||
function restoreHiddenNodes() {
|
||||
const count = state.hiddenNodes.size;
|
||||
|
|
@ -939,7 +1050,9 @@ async function showNodeCard(nodeId, event) {
|
|||
$("hide-card-node").disabled = nodeId === state.root;
|
||||
$("hide-card-node").title = nodeId === state.root
|
||||
? "Focus another node before hiding this one"
|
||||
: "Hide this node from the current visualization";
|
||||
: state.mode === "nodes"
|
||||
? "Hide this node from the current visualization"
|
||||
: "Hide this node and isolate upstream-only branches";
|
||||
const dialog = $("node-card");
|
||||
if (!dialog.open) {
|
||||
dialog.style.visibility = "hidden";
|
||||
|
|
@ -965,7 +1078,9 @@ async function inspectNode(nodeId) {
|
|||
$("hide-node").disabled = nodeId === state.root;
|
||||
$("hide-node").title = nodeId === state.root
|
||||
? "Focus another node before hiding this one"
|
||||
: "Hide this node from the current visualization";
|
||||
: state.mode === "nodes"
|
||||
? "Hide this node from the current visualization"
|
||||
: "Hide this node and isolate upstream-only branches";
|
||||
$("node-dialog-label").textContent = short(data.node.title, 72);
|
||||
const dialog = $("node-dialog");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
|
|
@ -1018,17 +1133,30 @@ async function filterByDescriptor(category, value) {
|
|||
async function loadNode(nodeId) {
|
||||
try {
|
||||
const showingFlow = state.mode === "flow";
|
||||
setStatus(`${showingFlow ? "Tracing lineage for" : "Loading"} ${nodeId}…`);
|
||||
const showingWeb = state.mode === "web";
|
||||
const action = showingFlow ? "Tracing semantic flow for"
|
||||
: showingWeb ? "Building convergence web for" : "Loading";
|
||||
setStatus(`${action} ${nodeId}…`);
|
||||
const endpoint = showingFlow ? "lineage" : showingWeb ? "web" : "node";
|
||||
const params = new URLSearchParams(
|
||||
showingFlow
|
||||
? {id: nodeId, limit: "1000"}
|
||||
: {id: nodeId, depth: String(state.depth), limit: "100"},
|
||||
: showingWeb
|
||||
? {
|
||||
id: nodeId,
|
||||
depth: String(state.depth),
|
||||
limit: "1000",
|
||||
}
|
||||
: {id: nodeId, depth: String(state.depth), limit: "100"},
|
||||
);
|
||||
const data = await api(`${showingFlow ? "lineage" : "node"}?${params}`);
|
||||
const data = await api(`${endpoint}?${params}`);
|
||||
renderGraph(data);
|
||||
const scope = showingFlow ? "directed ancestry" : "neighborhood";
|
||||
const scope = showingFlow ? "semantic flow"
|
||||
: showingWeb ? "convergence web" : "neighborhood";
|
||||
const suffix = data.truncated ? " · truncated at the safety limit" : "";
|
||||
setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`);
|
||||
setStatus(
|
||||
`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`,
|
||||
);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
|
|
@ -1039,11 +1167,12 @@ async function loadNode(nodeId) {
|
|||
}
|
||||
}
|
||||
async function setViewMode(mode) {
|
||||
if (mode !== "nodes" && mode !== "flow") return;
|
||||
if (!["nodes", "flow", "web"].includes(mode)) 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"));
|
||||
$("view-web").setAttribute("aria-pressed", String(mode === "web"));
|
||||
if (state.root) await loadNode(state.root);
|
||||
}
|
||||
function clamp(value, minimum, maximum) {
|
||||
|
|
@ -1144,6 +1273,7 @@ $("clear-result-filter").addEventListener("click", () => {
|
|||
});
|
||||
$("view-nodes").addEventListener("click", () => setViewMode("nodes"));
|
||||
$("view-flow").addEventListener("click", () => setViewMode("flow"));
|
||||
$("view-web").addEventListener("click", () => setViewMode("web"));
|
||||
$("zoom-in").addEventListener("click", () => zoomAt(.8));
|
||||
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
|
||||
$("reset-view").addEventListener("click", resetViewport);
|
||||
|
|
@ -1276,7 +1406,8 @@ applyViewport();
|
|||
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 requestedView = params.get("view");
|
||||
setViewMode(["flow", "web"].includes(requestedView) ? requestedView : "nodes");
|
||||
const overview = await api("overview");
|
||||
renderOverview(overview);
|
||||
startViewerLease();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue