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

Add function-scoped Logic visualization

This commit is contained in:
Andraxion 2026-07-25 21:08:43 -04:00
parent 9fcafc290c
commit 9b4258c852
22 changed files with 1420 additions and 62 deletions

View file

@ -26,7 +26,7 @@ header {
}
header h1 { margin: 0; font-size: 17px; }
.view-switch {
position: relative; display: grid; grid-template-columns: repeat(3, 58px);
position: relative; display: grid; grid-template-columns: repeat(4, 58px);
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
background: #08131f; isolation: isolate;
}
@ -38,6 +38,7 @@ header h1 { margin: 0; font-size: 17px; }
}
.view-switch[data-mode="flow"]::before { transform: translateX(58px); }
.view-switch[data-mode="web"]::before { transform: translateX(116px); }
.view-switch[data-mode="logic"]::before { transform: translateX(174px); }
.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

@ -15,6 +15,7 @@
<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>
<button id="view-logic" type="button" aria-pressed="false">Logic</button>
</div>
<h1 id="project-title">DocForge graph</h1>
<div class="stats">

View file

@ -4,6 +4,7 @@ const state = {
overview: null,
graph: null,
root: null,
focusNode: null,
mode: "nodes",
depth: 1,
searchLimit: 1,
@ -95,6 +96,50 @@ const relationStyles = Object.freeze({
family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow",
flow: null,
},
next: {
family: "Logic", color: "#8da2b8", dash: "", marker: "arrow",
flow: "forward",
},
when_true: {
family: "Logic", color: "#4ade80", dash: "", marker: "arrow",
flow: "forward",
},
when_false: {
family: "Logic", color: "#fb7185", dash: "5 3", marker: "arrow",
flow: "forward",
},
case: {
family: "Logic", color: "#c084fc", dash: "7 3", marker: "arrow",
flow: "forward",
},
loop: {
family: "Logic", color: "#2dd4bf", dash: "4 3", marker: "double-arrow",
flow: "forward",
},
exception: {
family: "Logic", color: "#f97316", dash: "3 3", marker: "open-arrow",
flow: "forward",
},
return: {
family: "Logic", color: "#38bdf8", dash: "", marker: "square-arrow",
flow: "forward",
},
raise: {
family: "Logic", color: "#f43f5e", dash: "", marker: "square-arrow",
flow: "forward",
},
break: {
family: "Logic", color: "#fbbf24", dash: "6 3", marker: "open-arrow",
flow: "forward",
},
continue: {
family: "Logic", color: "#22d3ee", dash: "6 3", marker: "open-arrow",
flow: "forward",
},
omitted: {
family: "Logic", color: "#64748b", dash: "2 5", marker: "open-arrow",
flow: "forward",
},
});
const contributionStyles = Object.freeze({
focus: {
@ -126,10 +171,30 @@ const contributionStyles = Object.freeze({
related: {
label: "Related", section: "Other connections", color: "#fb923c", fill: "#3b2719",
},
"logic-entry": {
label: "Entry", section: "Function boundary", color: "#67e8f9", fill: "#103745",
},
"logic-condition": {
label: "Decision", section: "Conditions & cases", color: "#facc15", fill: "#3b3112",
},
"logic-action": {
label: "Action", section: "Actions & calls", color: "#34d399", fill: "#15372e",
},
"logic-control": {
label: "Control", section: "Loops & exception handling", color: "#c084fc", fill: "#302044",
},
"logic-merge": {
label: "Merge", section: "Branch convergence", color: "#94a3b8", fill: "#252d39",
},
"logic-terminal": {
label: "Terminal", section: "Returns, raises & exits", color: "#fb7185", fill: "#41202a",
},
});
const contributionOrder = Object.freeze([
"focus", "composition", "behavior", "dependency", "execution",
"data", "evidence", "context", "related",
"logic-entry", "logic-condition", "logic-action", "logic-control",
"logic-merge", "logic-terminal",
]);
const compositionRelations = new Set(["contains", "defines", "defined_in"]);
const behaviorRelations = new Set(["inherits", "implemented_by"]);
@ -193,12 +258,17 @@ function humanize(value) {
}
function nodeDisplayName(node) {
const title = escapeText(node.title).trim() || escapeText(node.node_id);
if (Array.isArray(node.tags) && node.tags.includes("logic")) return title;
if (!title || /\s/.test(title)) return title;
const parts = title.split(/::|[./]/).filter(Boolean);
return parts.at(-1) || title;
}
function nodeKindLabel(node) {
const tags = new Set(Array.isArray(node.tags) ? node.tags.map(String) : []);
if (tags.has("logic")) {
const kind = [...tags].find((tag) => tag !== "logic");
return kind ? humanize(kind) : "Logic";
}
const kinds = [
"method", "function", "class", "module", "package", "property", "field",
"route", "command", "service", "plugin", "table", "column", "view",
@ -458,6 +528,7 @@ function restoreGraphStatus() {
nodes: "neighborhood",
flow: "semantic flow",
web: "convergence web",
logic: "control flow",
}[state.mode];
const pruned = state.prunedCount ? ` · ${state.prunedCount} hidden or isolated` : "";
setStatus(`${state.visibleNodeCount} nodes · ${state.visibleEdgeCount} edges in ${scope}${pruned}`);
@ -623,6 +694,40 @@ function buildWebGraph(data) {
]));
return {...data, nodes, edges, topology};
}
function buildLogicGraph(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const edges = data.edges.filter(
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
);
const hops = new Map([[data.root, 0]]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const sourceId of frontier) {
for (const edge of edges) {
if (edge.source_id !== sourceId || hops.has(edge.target_id)) continue;
hops.set(edge.target_id, hops.get(sourceId) + 1);
next.push(edge.target_id);
}
}
frontier = next;
}
const nodes = data.nodes.filter((node) => hops.has(node.node_id));
return {
...data,
nodes,
edges: edges.filter(
(edge) => hops.has(edge.source_id) && hops.has(edge.target_id),
),
topology: new Map(nodes.map((node) => [
node.node_id,
{
hop: hops.get(node.node_id) ?? 0,
role: node.node_id === data.root ? "primary" : "child",
},
])),
};
}
function pruneConvergenceGraph(data, hiddenNodes) {
const candidates = new Set(
data.nodes
@ -656,7 +761,92 @@ function pruneConvergenceGraph(data, hiddenNodes) {
prunedCount: data.nodes.length - reachesFocus.size,
};
}
function pruneLogicGraph(data, hiddenNodes) {
const visible = new Set(
data.nodes
.filter((node) => node.node_id === data.root || !hiddenNodes.has(node.node_id))
.map((node) => node.node_id),
);
const outgoing = new Map(data.nodes.map((node) => [node.node_id, []]));
for (const edge of data.edges) outgoing.get(edge.source_id)?.push(edge);
const edges = [];
const keys = new Set();
const append = (edge) => {
const key = `${edge.source_id}\u0000${edge.relation}\u0000${edge.target_id}`;
if (keys.has(key)) return;
keys.add(key);
edges.push(edge);
};
for (const sourceId of visible) {
const stack = [...(outgoing.get(sourceId) || [])].map(
(edge) => ({edge, omitted: false, seen: new Set([sourceId])}),
);
while (stack.length) {
const current = stack.pop();
const targetId = current.edge.target_id;
if (current.seen.has(targetId)) continue;
const seen = new Set(current.seen);
seen.add(targetId);
if (visible.has(targetId)) {
append(current.omitted
? {
source_id: sourceId,
relation: "omitted",
target_id: targetId,
label: "HIDDEN PATH",
reversed: false,
}
: current.edge);
continue;
}
for (const nextEdge of outgoing.get(targetId) || []) {
stack.push({edge: nextEdge, omitted: true, seen});
}
}
}
const reachable = new Set([data.root]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const sourceId of frontier) {
for (const edge of edges) {
if (edge.source_id !== sourceId || reachable.has(edge.target_id)) continue;
reachable.add(edge.target_id);
next.push(edge.target_id);
}
}
frontier = next;
}
const nodes = data.nodes.filter((node) => reachable.has(node.node_id));
return {
...data,
nodes,
edges: edges.filter(
(edge) => reachable.has(edge.source_id) && reachable.has(edge.target_id),
),
topology: buildLogicGraph({
...data,
nodes,
edges,
}).topology,
prunedCount: data.nodes.length - nodes.length,
};
}
function nodeContributionCategory(nodeId, data, topology) {
const node = data.nodes.find((candidate) => candidate.node_id === nodeId);
if (Array.isArray(node?.tags) && node.tags.includes("logic")) {
const kind = escapeText(node.logic_kind
|| node.tags.find((tag) => tag !== "logic")).toLowerCase();
if (kind === "entry") return "logic-entry";
if (["condition", "case"].includes(kind)) return "logic-condition";
if (["action", "call"].includes(kind)) return "logic-action";
if (["loop", "try", "except", "finally", "break", "continue"].includes(kind)) {
return "logic-control";
}
if (kind === "merge") return "logic-merge";
if (["return", "raise", "exit"].includes(kind)) return "logic-terminal";
return "logic-action";
}
if (nodeId === data.root) return "focus";
const nodeHop = topology.get(nodeId)?.hop ?? Number.POSITIVE_INFINITY;
const candidates = [];
@ -756,6 +946,14 @@ function layoutFlow(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId))
}
return positions;
}
function layoutLogic(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId)) {
const positions = layoutFlow(nodes, rootId, topology, sizes);
for (const [nodeId, point] of positions) {
if (nodeId === rootId) continue;
positions.set(nodeId, {...point, x: Math.abs(point.x)});
}
return positions;
}
function darken(hex, amount) {
const value = Number.parseInt(hex.slice(1), 16);
const factor = 1 - Math.min(.5, Math.max(0, amount));
@ -798,6 +996,7 @@ function renderNeighborhood(data, topology, categories) {
nodes: "Neighborhood",
flow: "Semantic flow",
web: "Convergence web",
logic: "Control flow",
}[state.mode];
renderNodeLegend(categories);
const container = $("neighborhood-sections");
@ -825,7 +1024,9 @@ function renderNeighborhood(data, topology, categories) {
button.type = "button";
button.className = "node-list-item";
button.style.setProperty("--item-color", palette.stroke);
button.title = `Focus ${node.title}`;
button.title = state.mode === "logic"
? `Inspect ${node.title}`
: `Focus ${node.title}`;
const swatch = document.createElement("i");
swatch.className = "node-swatch";
const copy = document.createElement("span");
@ -837,7 +1038,14 @@ function renderNeighborhood(data, topology, categories) {
meta.textContent = `${nodeKindLabel(node)} · ${style.label} · ${hopLabel}`;
copy.append(title, meta);
button.append(swatch, copy);
button.addEventListener("click", () => loadNode(node.node_id));
button.addEventListener("click", (event) => {
if (state.mode === "logic") {
selectNode(node.node_id);
showNodeCard(node.node_id, event);
} else {
loadNode(node.node_id);
}
});
list.append(button);
}
container.append(heading, list);
@ -906,7 +1114,9 @@ function renderGraph(data, preserveSelection = false) {
: data.root;
const completeView = state.mode === "flow"
? buildFlowGraph(data)
: state.mode === "web" ? buildWebGraph(data) : data;
: state.mode === "web"
? buildWebGraph(data)
: state.mode === "logic" ? buildLogicGraph(data) : data;
let view;
if (state.mode === "nodes") {
const visibleIds = new Set(
@ -923,6 +1133,8 @@ function renderGraph(data, preserveSelection = false) {
),
prunedCount: completeView.nodes.length - visibleIds.size,
};
} else if (state.mode === "logic") {
view = pruneLogicGraph(completeView, state.hiddenNodes);
} else {
view = pruneConvergenceGraph(completeView, state.hiddenNodes);
}
@ -945,9 +1157,11 @@ function renderGraph(data, preserveSelection = false) {
const topology = view.topology || analyzeTopology(view);
const categories = nodeCategoryMap(view, topology);
const sizes = nodeSizeMap(view.nodes, view.root);
const positions = state.mode !== "nodes"
? layoutFlow(view.nodes, view.root, topology, sizes)
: layoutNodes(view.nodes, view.root, topology, sizes);
const positions = state.mode === "logic"
? layoutLogic(view.nodes, view.root, topology, sizes)
: state.mode !== "nodes"
? layoutFlow(view.nodes, view.root, topology, sizes)
: layoutNodes(view.nodes, view.root, topology, sizes);
state.positions = positions;
state.homeViewport = viewportForPositions(positions, sizes);
resetViewport();
@ -986,7 +1200,7 @@ function renderGraph(data, preserveSelection = false) {
fill: style.color,
"text-anchor": "middle",
});
label.textContent = relationLabel(edge.relation, edge.reversed);
label.textContent = edge.label || relationLabel(edge.relation, edge.reversed);
edgeLayer.append(label);
}
for (const node of view.nodes) {
@ -1093,6 +1307,10 @@ function hideNode(nodeId) {
: "";
setStatus(`Hidden ${nodeId}.${suffix} Restore hidden nodes from the graph controls.`);
}
function explorationTarget(nodeId) {
const node = state.graph?.nodes.find((candidate) => candidate.node_id === nodeId);
return escapeText(node?.logic_owner_id || nodeId);
}
function restoreHiddenNodes() {
const count = state.hiddenNodes.size;
state.hiddenNodes.clear();
@ -1334,10 +1552,14 @@ async function loadNode(nodeId) {
try {
const showingFlow = state.mode === "flow";
const showingWeb = state.mode === "web";
const showingLogic = state.mode === "logic";
state.focusNode = nodeId;
const action = showingFlow ? "Tracing semantic flow for"
: showingWeb ? "Building convergence web for" : "Loading";
: showingWeb ? "Building convergence web for"
: showingLogic ? "Tracing control flow for" : "Loading";
setStatus(`${action} ${nodeId}`);
const endpoint = showingFlow ? "lineage" : showingWeb ? "web" : "node";
const endpoint = showingFlow ? "lineage"
: showingWeb ? "web" : showingLogic ? "logic" : "node";
const params = new URLSearchParams(
showingFlow
? {id: nodeId, limit: "1000"}
@ -1347,16 +1569,20 @@ async function loadNode(nodeId) {
depth: String(state.depth),
limit: "1000",
}
: {id: nodeId, depth: String(state.depth), limit: "100"},
: showingLogic
? {id: nodeId}
: {id: nodeId, depth: String(state.depth), limit: "100"},
);
const data = await api(`${endpoint}?${params}`);
renderGraph(data);
const scope = showingFlow ? "semantic flow"
: showingWeb ? "convergence web" : "neighborhood";
: showingWeb ? "convergence web"
: showingLogic ? "control flow" : "neighborhood";
const suffix = data.truncated ? " · truncated at the safety limit" : "";
setStatus(
`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`,
);
const status = showingLogic && !data.available
? `No indexed Python logic is available for ${nodeId}`
: `${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`;
setStatus(status, showingLogic && !data.available);
history.replaceState(
null,
"",
@ -1367,13 +1593,14 @@ async function loadNode(nodeId) {
}
}
async function setViewMode(mode) {
if (!["nodes", "flow", "web"].includes(mode)) return;
if (!["nodes", "flow", "web", "logic"].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);
$("view-logic").setAttribute("aria-pressed", String(mode === "logic"));
if (state.focusNode) await loadNode(state.focusNode);
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value));
@ -1474,6 +1701,7 @@ $("clear-result-filter").addEventListener("click", () => {
$("view-nodes").addEventListener("click", () => setViewMode("nodes"));
$("view-flow").addEventListener("click", () => setViewMode("flow"));
$("view-web").addEventListener("click", () => setViewMode("web"));
$("view-logic").addEventListener("click", () => setViewMode("logic"));
$("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport);
@ -1491,7 +1719,7 @@ $("node-dialog").querySelector(".dialog-head").addEventListener("pointercancel",
$("explore-node").addEventListener("click", async () => {
const nodeId = state.inspectedNode;
closeNodeDialog();
if (nodeId) await loadNode(nodeId);
if (nodeId) await loadNode(explorationTarget(nodeId));
});
$("open-node-source").addEventListener("click", () => {
if (state.inspectedNode) openSource(state.inspectedNode);
@ -1508,7 +1736,7 @@ $("hide-card-node").addEventListener("click", () => {
$("explore-card-node").addEventListener("click", async () => {
const nodeId = state.cardNode;
closeNodeCard();
if (nodeId) await loadNode(nodeId);
if (nodeId) await loadNode(explorationTarget(nodeId));
});
$("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return;
@ -1607,7 +1835,9 @@ applyViewport();
const params = new URLSearchParams(location.search);
state.depth = Math.max(1, Number(params.get("depth")) || 1);
const requestedView = params.get("view");
setViewMode(["flow", "web"].includes(requestedView) ? requestedView : "nodes");
setViewMode(
["flow", "web", "logic"].includes(requestedView) ? requestedView : "nodes",
);
const overview = await api("overview");
renderOverview(overview);
startViewerLease();