fix: address Codex review — stable layout, scoped infra filter, basename dedup

P1: Separate force layout computation from visual state updates so
clicking/searching/touring doesn't re-randomize node positions. Layout
only recomputes when the graph data or filters change.

P1: Restrict infrastructure-file skipping (index.md, log.md, etc.) to
the wiki root level only. Nested files like concepts/index.md are now
correctly treated as content articles.

P2: Track ambiguous bare basenames in the wikilink resolution map.
Duplicate basenames (e.g., a/foo.md and b/foo.md) are removed from
the flat lookup so [[foo]] doesn't silently resolve to the wrong page.

Also fixed: edge IDs now use stable source-target-type keys instead of
array indices for proper React reconciliation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-12 11:21:24 +08:00
Unverified
parent d3dfbd873b
commit 7e41ba7a68
2 changed files with 132 additions and 104 deletions
@@ -37,7 +37,6 @@ const EDGE_STYLES: Record<string, React.CSSProperties> = {
function getNodeDimensions(
edgeCount: number,
): { width: number; height: number } {
// Scale width/height by degree (connections)
const scale = Math.min(1.5, Math.max(0.85, 0.85 + edgeCount * 0.03));
return {
width: Math.round(NODE_WIDTH * scale),
@@ -45,22 +44,19 @@ function getNodeDimensions(
};
}
function buildKnowledgeGraph(
/**
* Compute the stable layout (positions) from graph topology.
* This only re-runs when the graph data or filters change, NOT on selection/search.
*/
function computeLayout(
graph: KnowledgeGraph,
selectedNodeId: string | null,
focusNodeId: string | null,
searchResults: Map<string, number>,
tourHighlightedNodeIds: Set<string>,
onNodeClick: (nodeId: string) => void,
): { nodes: Node[]; edges: Edge[] } {
// Count edges per node for degree-proportional sizing
): { positionMap: Map<string, { x: number; y: number }>; edgeCounts: Map<string, number>; communityMap: Map<string, number> } {
const edgeCounts = new Map<string, number>();
for (const edge of graph.edges) {
edgeCounts.set(edge.source, (edgeCounts.get(edge.source) ?? 0) + 1);
edgeCounts.set(edge.target, (edgeCounts.get(edge.target) ?? 0) + 1);
}
// Build community map from layers
const communityMap = new Map<string, number>();
graph.layers.forEach((layer, i) => {
for (const nodeId of layer.nodeIds) {
@@ -68,83 +64,33 @@ function buildKnowledgeGraph(
}
});
// Determine neighbor IDs for focus/selection fading
const neighborIds = new Set<string>();
if (focusNodeId || selectedNodeId) {
const focusId = focusNodeId ?? selectedNodeId;
for (const edge of graph.edges) {
if (edge.source === focusId) neighborIds.add(edge.target);
if (edge.target === focusId) neighborIds.add(edge.source);
}
}
// Build node dimensions map
const dims = new Map<string, { width: number; height: number }>();
for (const node of graph.nodes) {
const d = getNodeDimensions(edgeCounts.get(node.id) ?? 0);
dims.set(node.id, d);
dims.set(node.id, getNodeDimensions(edgeCounts.get(node.id) ?? 0));
}
// Build xyflow nodes
const rfNodes: Node[] = graph.nodes.map((node) => {
const isSelected = node.id === selectedNodeId;
const isFocused = node.id === focusNodeId;
const isNeighbor = neighborIds.has(node.id);
const isSelectionFaded =
(focusNodeId || selectedNodeId) &&
!isSelected &&
!isFocused &&
!isNeighbor;
const searchScore = searchResults.get(node.id);
const isHighlighted = searchScore !== undefined;
const isTourHighlighted = tourHighlightedNodeIds.has(node.id);
// Build temporary nodes/edges for layout computation only
const tmpNodes: Node[] = graph.nodes.map((node) => ({
id: node.id,
type: "custom" as const,
position: { x: 0, y: 0 },
data: {},
}));
const data: CustomNodeData = {
label: node.name,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted,
searchScore,
isSelected,
isTourHighlighted,
isDiffChanged: false,
isDiffAffected: false,
isDiffFaded: false,
isNeighbor,
isSelectionFaded: !!isSelectionFaded,
onNodeClick,
incomingCount: edgeCounts.get(node.id) ?? 0,
tags: node.tags,
};
const tmpEdges: Edge[] = graph.edges.map((e, i) => ({
id: `ke-${i}`,
source: e.source,
target: e.target,
}));
return {
id: node.id,
type: "custom" as const,
position: { x: 0, y: 0 },
data,
};
});
const { nodes: layoutedNodes } = applyForceLayout(tmpNodes, tmpEdges, dims, communityMap);
// Build xyflow edges
const rfEdges: Edge[] = graph.edges.map((e, i) => {
const style = EDGE_STYLES[e.type] ?? EDGE_STYLES.related;
return {
id: `ke-${i}-${e.source}-${e.target}`,
source: e.source,
target: e.target,
style,
animated: e.type === "contradicts",
label: e.type !== "related" && e.type !== "categorized_under" ? e.type.replace(/_/g, " ") : undefined,
labelStyle: { fill: "var(--color-text-muted)", fontSize: 9, opacity: 0.7 },
labelBgStyle: { fill: "var(--color-surface)", fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 3,
};
});
const positionMap = new Map<string, { x: number; y: number }>();
for (const n of layoutedNodes) {
positionMap.set(n.id, n.position);
}
// Apply force layout with community clustering
return applyForceLayout(rfNodes, rfEdges, dims, communityMap);
return { positionMap, edgeCounts, communityMap };
}
function KnowledgeGraphViewInner() {
@@ -171,10 +117,10 @@ function KnowledgeGraphViewInner() {
[tourHighlightedNodeIds],
);
const { nodes, edges } = useMemo(() => {
if (!graph) return { nodes: [], edges: [] };
// Filter graph — only recompute when graph data or filters change
const filteredGraph = useMemo((): KnowledgeGraph | null => {
if (!graph) return null;
// Filter graph by active node type filters
const filteredNodes = graph.nodes.filter((n) => {
if (["article", "entity", "topic", "claim", "source"].includes(n.type)) {
return nodeTypeFilters.knowledge !== false;
@@ -187,21 +133,86 @@ function KnowledgeGraphViewInner() {
(e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target),
);
const filteredGraph: KnowledgeGraph = {
...graph,
nodes: filteredNodes,
edges: filteredEdges,
};
return { ...graph, nodes: filteredNodes, edges: filteredEdges };
}, [graph, nodeTypeFilters]);
return buildKnowledgeGraph(
filteredGraph,
selectedNodeId,
focusNodeId,
searchResults,
tourSet,
onNodeClick,
);
}, [graph, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, nodeTypeFilters]);
// Compute layout ONCE per graph/filter change — stable positions
const { positionMap, edgeCounts } = useMemo(() => {
if (!filteredGraph) return { positionMap: new Map(), edgeCounts: new Map() };
return computeLayout(filteredGraph);
}, [filteredGraph]);
// Build visual nodes/edges — recomputes on selection/search/tour WITHOUT re-layout
const { nodes, edges } = useMemo(() => {
if (!filteredGraph) return { nodes: [], edges: [] };
const neighborIds = new Set<string>();
if (focusNodeId || selectedNodeId) {
const focusId = focusNodeId ?? selectedNodeId;
for (const edge of filteredGraph.edges) {
if (edge.source === focusId) neighborIds.add(edge.target);
if (edge.target === focusId) neighborIds.add(edge.source);
}
}
const rfNodes: Node[] = filteredGraph.nodes.map((node) => {
const isSelected = node.id === selectedNodeId;
const isFocused = node.id === focusNodeId;
const isNeighbor = neighborIds.has(node.id);
const isSelectionFaded =
(focusNodeId || selectedNodeId) &&
!isSelected &&
!isFocused &&
!isNeighbor;
const searchScore = searchResults.get(node.id);
const isHighlighted = searchScore !== undefined;
const isTourHighlighted = tourSet.has(node.id);
const data: CustomNodeData = {
label: node.name,
nodeType: node.type,
summary: node.summary,
complexity: node.complexity,
isHighlighted,
searchScore,
isSelected,
isTourHighlighted,
isDiffChanged: false,
isDiffAffected: false,
isDiffFaded: false,
isNeighbor,
isSelectionFaded: !!isSelectionFaded,
onNodeClick,
incomingCount: edgeCounts.get(node.id) ?? 0,
tags: node.tags,
};
return {
id: node.id,
type: "custom" as const,
position: positionMap.get(node.id) ?? { x: 0, y: 0 },
data,
};
});
const rfEdges: Edge[] = filteredGraph.edges.map((e) => {
const style = EDGE_STYLES[e.type] ?? EDGE_STYLES.related;
return {
id: `ke-${e.source}-${e.target}-${e.type}`,
source: e.source,
target: e.target,
style,
animated: e.type === "contradicts",
label: e.type !== "related" && e.type !== "categorized_under" ? e.type.replace(/_/g, " ") : undefined,
labelStyle: { fill: "var(--color-text-muted)", fontSize: 9, opacity: 0.7 },
labelBgStyle: { fill: "var(--color-surface)", fillOpacity: 0.9 },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 3,
};
});
return { nodes: rfNodes, edges: rfEdges };
}, [filteredGraph, selectedNodeId, focusNodeId, searchResults, tourSet, onNodeClick, positionMap, edgeCounts]);
if (!graph) {
return (
@@ -221,15 +221,31 @@ def parse_log(log_path: Path) -> list[dict]:
# ---------------------------------------------------------------------------
def build_name_to_stem_map(wiki_root: Path) -> dict[str, str]:
"""Build a case-insensitive map from filename stem to relative stem path."""
"""Build a case-insensitive map from filename stem to relative stem path.
Full relative paths always map uniquely. Bare basenames map only when
unambiguous — duplicate basenames are removed so they don't silently
resolve to the wrong page.
"""
name_map: dict[str, str] = {}
# Track which bare basenames appear more than once
basename_counts: dict[str, int] = {}
for md_file in wiki_root.rglob("*.md"):
rel = md_file.relative_to(wiki_root)
stem = str(rel.with_suffix("")) # e.g., "decisions/decision-foo"
basename = md_file.stem # e.g., "decision-foo"
# Map both full relative path and bare filename (for flat wikilink resolution)
# Full relative path always maps uniquely
name_map[stem.lower()] = stem
name_map[basename.lower()] = stem
# Track basename for ambiguity detection
key = basename.lower()
basename_counts[key] = basename_counts.get(key, 0) + 1
name_map[key] = stem
# Remove ambiguous basename entries (appear more than once)
for key, count in basename_counts.items():
if count > 1 and key in name_map:
del name_map[key]
return name_map
@@ -292,13 +308,14 @@ def parse_wiki(root: Path) -> dict:
category_lookup[article_target.lower()] = cat["name"]
# --- Pre-compute article IDs (for edge resolution validation) ---
# Must use the same filter logic as the main loop (skip if EITHER matches INFRA_FILES)
# Only skip infra files at the wiki root level, not in subdirectories
# (e.g., wiki/index.md is infra, but wiki/concepts/index.md is content)
article_ids: set[str] = set()
for md_file in sorted(wiki_root.rglob("*.md")):
rel = md_file.relative_to(wiki_root)
stem = str(rel.with_suffix(""))
basename = md_file.stem
if basename.lower() in INFRA_FILES or rel.name.lower() in INFRA_FILES:
# Only filter infra files at root level (no parent directory)
if rel.parent == Path(".") and rel.name.lower() in INFRA_FILES:
continue
article_ids.add(f"article:{stem}")
@@ -313,8 +330,8 @@ def parse_wiki(root: Path) -> dict:
stem = str(rel.with_suffix(""))
basename = md_file.stem
# Skip infrastructure files
if basename.lower() in INFRA_FILES or rel.name.lower() in INFRA_FILES:
# Skip infrastructure files only at wiki root level
if rel.parent == Path(".") and rel.name.lower() in INFRA_FILES:
continue
text = md_file.read_text(encoding="utf-8", errors="replace")