1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add semantic flow and convergence web views

This commit is contained in:
Andraxion 2026-07-25 17:34:58 -04:00
parent f9f7105983
commit 6609edc804
14 changed files with 616 additions and 128 deletions

View file

@ -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;

View file

@ -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">

View file

@ -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();