Add function-scoped Logic visualization
This commit is contained in:
parent
9fcafc290c
commit
9b4258c852
22 changed files with 1420 additions and 62 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue