Merge pull request #111 from Lum1104/feature/graph-layout-scaling

feat(dashboard): replace dagre with ELK + folder/community containers + lazy two-stage layout
This commit is contained in:
Yuxiang Lin
2026-05-03 18:12:21 +08:00
committed by GitHub
Unverified
21 changed files with 5176 additions and 158 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,488 @@
# Dashboard Graph Layout Scaling — Design
## Problem
When a structural-graph layer contains many nodes, the current `applyDagreLayout` (TB direction) places same-rank nodes in a single horizontal row. With 50+ nodes per rank, the row stretches into thousands of pixels and the view becomes unreadable: nodes shrink, labels disappear, edges tangle, and there are no visual anchors to orient the reader.
This design replaces dagre with ELK across all structural-style views, introduces folder/community-based **containers** for the layer-detail view, and computes layout in **two lazy stages** — a single-pass over containers, then per-container child layout on demand.
The graph schema and pipeline output (`graph.json`) are unchanged. All improvements derive from existing data.
## Goals
- Eliminate horizontal sprawl in layer-detail views at ≤100 nodes per layer (current target), and remain workable up to 1000+ nodes (future scaling).
- Give each layer-detail view explicit visual anchors so structure is readable at a glance.
- Aggregate cross-cluster edges by default; surface individual edges on demand.
- Keep visual style continuous with the existing layer-cluster (overview-level) presentation.
- Treat layout failures with the same `GraphIssue` model already used for schema validation.
## Non-Goals
- No regeneration of `graph.json`. All grouping is derived client-side.
- No change to KnowledgeGraphView (already force-directed; out of scope).
- No multi-level container nesting (single depth only in v1).
- No remote error reporting (Sentry-style) — open-source plugin, no default telemetry.
- No persona-specific grouping behavior beyond the existing node-type filter.
## Scope
Three views are affected:
| View | Change |
|---|---|
| Overview (layer clusters) | Replace dagre → ELK. No new grouping (layers are already groups). |
| DomainGraphView | Replace dagre → ELK with domain-as-parent of flow/step. |
| Layer-detail | Replace dagre → ELK + new folder/community containers + edge aggregation + lazy two-stage layout. |
KnowledgeGraphView remains on `applyForceLayout` and is not touched.
---
## §1. Architecture
```
existing graph (immutable)
deriveContainers(nodes, edges) // §2 — folder strategy with community fallback
buildCompoundGraph() // §4 — aggregate inter-container edges, keep intra-container
runStage1Layout(containers, aggEdges) // §6 — ELK on containers only; uses size memory
▼ ┌──────────────────────────────┐
│ │ render: containers laid │
│ │ out, children unrendered │
│ └──────────────────────────────┘
│ triggered by: click | zoom > 1.0 | search/focus/tour hit child
runStage2Layout(container) // §6 — ELK on one container's children; cached
React Flow render (parentId for parent-child) + visual overlay (selection/diff/search/tour)
```
Two invariants preserved from current code:
1. **Layout computation is pure and memoized.** It only re-runs when graph topology / persona / diff / focus / nodeTypeFilters change.
2. **Visual state is a separate O(n) overlay pass.** Selection, search highlight, tour highlight, hover do not trigger relayout.
This matches the existing `useLayerDetailTopology` / `useLayerDetailGraph` split in `GraphView.tsx`.
---
## §2. Container Derivation (Layer-Detail Only)
### 2.1 Folder strategy (default)
1. Collect every node's `filePath` in the layer.
2. Compute longest common prefix (LCP) across all paths and strip it.
3. Group by the **first path segment after the LCP**.
- `auth/login.go` → container `auth`
- `auth/handlers/oauth.go` → container `auth`
- `cart/cart.go` → container `cart`
4. Single-depth grouping only; no recursive nesting in v1.
5. Nodes with no `filePath` (e.g. `concept` type) → container `~` (rendered as `(root)`, dimmed).
### 2.2 Community fallback (Louvain)
Triggered when **any** of:
- All nodes share the same single folder after LCP stripping.
- Bucket count (folders + rooted) `< 2`.
- Any single bucket (folder or rooted) holds `> 70%` of nodes.
Run Louvain modularity-based community detection on the layer's internal edges. Each community becomes a container. Names are placeholders (`Cluster A`, `Cluster B`, ...) since no semantic name is available.
Implementation: use `graphology` + `graphology-communities-louvain` (~30KB total). Pure JS, no native deps, runs on main thread synchronously for layer-internal edges.
### 2.3 Edge cases
| Case | Behavior |
|---|---|
| Container has 1 child (only when layer total ≥ 3) | No container box rendered; child becomes a top-level node in Stage 1 layout |
| Container has 2 children | Container rendered; label dimmed |
| All nodes lack `filePath` | All go to `~` container; if it would become single-child, fall back to flat |
### 2.4 Function signature
```ts
function deriveContainers(
nodes: GraphNode[],
edges: GraphEdge[],
): {
containers: Array<{
id: string; // e.g. "container:auth" or "container:cluster-0"
name: string; // "auth" or "Cluster A"
nodeIds: string[];
strategy: "folder" | "community";
}>;
ungrouped: string[]; // nodes that bypass containerization
};
```
The `strategy` field is exposed in the UI ("Grouped by folder" vs "Grouped by edge density") so the user knows how a particular layer was organized.
---
## §3. ELK Integration
### 3.1 Package
- `elkjs` ^0.9 (~250KB gzipped). Use `elk.bundled.js`, not the worker variant.
- Promise-based API. Runs on main thread for graphs ≤500 nodes; <100ms typical.
### 3.2 Configuration
```ts
{
algorithm: "layered",
"elk.direction": "DOWN", // matches dagre TB
"elk.layered.spacing.nodeNodeBetweenLayers": 80,
"elk.spacing.nodeNode": 60,
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.compaction.postCompaction.strategy": "LEFT",
"elk.padding": "[top=40,left=20,right=20,bottom=20]", // container internal padding
}
```
`hierarchyHandling: INCLUDE_CHILDREN` is **not** used — the two-stage approach (§6) issues separate ELK calls for top-level containers and per-container children, so a single compound graph is never assembled.
### 3.3 Per-view input shaping
| View | ELK input |
|---|---|
| Overview | Flat. Children = layer-cluster nodes. |
| DomainGraphView | Flat in v1 (domain stays as the only grouping; flow/step nodes positioned within). |
| Layer-detail Stage 1 | Flat. Children = containers (treated as opaque atoms). |
| Layer-detail Stage 2 | Flat per container. Children = files within. |
A single `runElk(input): Promise<positioned>` function services all four cases.
### 3.4 Boundaries with existing `utils/layout.ts`
| Function | Status |
|---|---|
| `applyDagreLayout` | Kept temporarily; removed in the version after layout migration is verified stable |
| `applyForceLayout` | Untouched (KnowledgeGraphView only) |
| `applyElkLayout` (new) | Wrapper that handles repair → ELK → result coercion |
### 3.5 Async + loading state
Stage 1 runs in a `useEffect` with cancellation on dependency change:
```ts
useEffect(() => {
let cancelled = false;
setLayoutStatus("computing");
applyElkLayout(input).then(result => {
if (!cancelled) {
setLayout(result);
setLayoutStatus("ready");
}
});
return () => { cancelled = true };
}, [graph, activeLayerId, persona, diffMode, nodeTypeFilters]);
```
While `layoutStatus === "computing"`, render a `"Computing layout…"` overlay (semi-transparent, centered). Stale layout from the previous state is kept underneath so the viewport doesn't blink.
### 3.6 Failure handling — reuses existing GraphIssue model
Before invoking ELK, run `repairElkInput()` over the assembled input. Each repair emits a `GraphIssue` consumed by the existing `WarningBanner`.
| Repair function | Triggered by | Issue level |
|---|---|---|
| `ensureNodeDimensions` | Node missing width/height | `auto-corrected` |
| `dedupeNodeIds` | Duplicate child id under same parent | `auto-corrected` |
| `dropOrphanEdges` | Edge source/target not in node set | `dropped` |
| `dropOrphanChildren` | Child references a non-existent parent | `dropped` |
| `dropCircularContainment` | Container containment cycle | `dropped` |
If ELK still rejects after repair → emit a `fatal` `GraphIssue`, render an empty graph + the existing fatal banner. The fatal copy text is augmented with "this looks like a dashboard rendering bug — please file an issue with the copied error" so the user knows to direct the report at the dashboard, not the graph data.
### 3.7 Dev mode strict failures
Both `repairElkInput` and `runElk` accept a `strict: boolean`. In `import.meta.env.DEV`, strict is on — repairs and ELK errors throw immediately rather than producing graceful issues. This catches input-construction bugs during development before they ship as silent fallbacks.
---
## §4. Edge Aggregation
### 4.1 Algorithm
Performed inside `buildCompoundGraph()`, before either ELK stage.
```ts
function aggregateContainerEdges(
nodes: GraphNode[],
edges: GraphEdge[],
nodeToContainer: Map<string, string>,
): {
intraContainer: Edge[]; // preserved as-is
interContainerAggregated: AggregatedEdge[]; // one per (sourceContainer, targetContainer)
};
```
Rules:
- For each edge, look up source/target containers.
- Same container → intra (unchanged).
- Different containers → bucket by `(sourceContainer, targetContainer)`. Direction matters: A→B and B→A are independent.
- Each aggregated edge carries `count` and `types` (set of edge types appearing in the bucket).
### 4.2 Visual
Reuse the styling pattern already in overview-level edge aggregation (`GraphView.tsx` line ~186):
- `strokeWidth: Math.min(1 + Math.log2(count + 1), 5)`
- Label: count number
- Color: existing `rgba(212,165,116,0.4)`
### 4.3 Expand / collapse
State (zustand store):
```ts
expandedContainers: Set<string>; // currently expanded container ids
```
Triggers:
- **Click container** → toggle membership.
- **Click empty canvas** or `Esc` → clear all.
- **Multi-container expansion is allowed** (user comparing two folders' relationships).
When a container is expanded:
- Its inter-container aggregated edges (both directions) are replaced with the underlying file→file individual edges.
- Other containers' aggregated edges remain aggregated.
- Position re-layout is **not** triggered. Only React Flow's edge array changes.
### 4.4 Interactions with persona / diff
- **Persona filter** changes `count` (post-filter edges only). Aggregated edge re-derived in the memoized pipeline.
- **Diff mode**: aggregated edge containing any changed node → red stroke + animated; on expand, individual edges follow normal diff styling.
---
## §5. Container Visual
### 5.1 New component: `ContainerNode`
A new React Flow node type `"container"` registered alongside the existing `custom` / `layer-cluster` / `portal`.
It does **not** reuse `LayerClusterNode` because:
- Click semantics differ (`LayerClusterNode` drills into a layer; `ContainerNode` toggles edge expansion).
- Metadata differs (`ContainerNode` does not carry `aggregateComplexity`).
Visual language is shared: rounded translucent box, gold border, DM Serif title.
### 5.2 Spec
| Element | Style |
|---|---|
| Border (default) | `1px solid rgba(212,165,116,0.25)` |
| Border (hover / expanded) | `1.5px rgba(212,165,116,0.6)`, expanded adds chevron `▾` |
| Background | `rgba(255,255,255,0.02)` |
| Corner radius | `12px` |
| Title | DM Serif, 14px, `#d4a574`, top-left padding `12px 16px` |
| Child-count badge | top-right chip, `#a39787`, 11px |
| Internal padding (around children) | `40px top / 20px L,R,B` |
### 5.3 Color coding
Container index modulo 12-color palette (same palette used for `layerColorIndex` in `LayerClusterNode`). Hue is applied at low saturation to border + title only — never to the body fill — so the palette doesn't overpower individual nodes inside.
### 5.4 State styles
| State | Visual |
|---|---|
| `default` | Base spec |
| `hover` | Brighter border, title underline |
| `expanded` | 1.5px gold border + chevron `▾` |
| `search-hit-inside` | Search badge in title row showing match count |
| `diff-affected` | Border swaps to `rgba(224,82,82,0.5)` |
| `focused-via-child` | Same as expanded plus brightness boost |
### 5.5 Label source
| Strategy | Label |
|---|---|
| `folder` | First path segment after LCP (e.g. `auth`) |
| `community` | `Cluster A`, `Cluster B`, ... ordered by community id |
| `~` (root) | `(root)` in dimmed style |
---
## §6. Lazy Two-Stage Layout
### 6.1 State machine
```
[layer entered]
│ Stage 1: ELK on containers (always runs)
[containers laid out, children unrendered]
├── click container ─────┐
├── zoom > 1.0 in viewport (200ms debounce, hysteresis) ─┤
└── search / focus / tour hit a child ─┘
Stage 2 (per container)
[container expanded, children laid out + rendered]
```
### 6.2 Store extensions
```ts
expandedContainers: Set<string>;
containerLayoutCache: Map<string, {
childPositions: Map<string, { x: number; y: number }>;
actualSize: { width: number; height: number };
}>;
containerSizeMemory: Map<string, { width: number; height: number }>;
```
- `containerLayoutCache` invalidated by `(graphHash, containerId)`.
- `containerSizeMemory` persists across container collapses to prevent jitter on next expand.
### 6.3 Stage 1
```ts
async function runStage1Layout(containers, aggregatedInterEdges, sizeMemory) {
const elkInput = {
id: "root",
children: containers.map(c => ({
id: c.id,
width: sizeMemory.get(c.id)?.width
?? Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2,
height: sizeMemory.get(c.id)?.height
?? Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2,
})),
edges: aggregatedInterEdges.map(toElkEdge),
};
return runElk(elkInput);
}
```
Container size is estimated from `sqrt(childCount)` so it grows sub-linearly with content. If memory has the actual size from a previous run, that wins.
### 6.4 Stage 2
```ts
async function runStage2Layout(container, intraEdges) {
if (containerLayoutCache.has(container.id)) {
return containerLayoutCache.get(container.id)!;
}
const elkInput = {
id: container.id,
children: container.nodeIds.map(toElkChild),
edges: intraEdges.filter(e => isWithin(container, e)).map(toElkEdge),
};
const result = await runElk(elkInput);
containerLayoutCache.set(container.id, result);
containerSizeMemory.set(container.id, result.actualSize);
return result;
}
```
If `result.actualSize` differs from the Stage 1 estimate by **> 20%** in either dimension, trigger a Stage 1 re-layout (full re-run; <100ms at this scale, so the user perceives a small reflow rather than two distinct layouts).
### 6.5 Auto-expand triggers
| Trigger | Implementation |
|---|---|
| Click | `onClick` toggles `expandedContainers` |
| Zoom | React Flow `onMove` listener (200ms debounce). When viewport zoom > 1.0, all containers in viewport added to `expandedContainers`. Hysteresis: containers don't auto-collapse until zoom < 0.6, preventing flapping. |
| Search / focus / tour | `useEffect` watches `searchResults` / `focusNodeId` / `tourHighlightedNodeIds`; finds the parent container of any matched leaf node and adds to `expandedContainers` |
### 6.6 Performance budget
| Operation | Target |
|---|---|
| Stage 1 (any layer) | < 100ms |
| Stage 2 (first expand of a container) | < 100ms |
| Stage 2 (cache hit) | < 5ms |
| Zoom-driven auto-expand | 200ms debounce |
| Stage 1 re-layout after >20% deviation | < 100ms (re-uses Stage 1 path) |
---
## §7. Interaction Matrix
| Existing feature | Behavior with new layout |
|---|---|
| Persona filter | Drives `nodeTypeFilters` dependency in Stage 1 memo. Filtered-out nodes don't enter container derivation; containers with all-filtered children disappear. |
| Diff mode | Container with a changed child gets red border (§5.4); aggregated edges containing a changed node animate red; on expand, individual diff styling applies. |
| Focus mode (1-hop) | Focus node's container auto-expands. Non-neighbor containers fade to opacity 0.2; their children remain unrendered. |
| Search | Container with a hit gets search badge in title; container does **not** auto-expand to avoid expanding many at once. Clicking the badge expands and `fitView`s. |
| Tour | Tour-highlighted child auto-expands its container. `TourFitView` fits to the highlighted leaf positions (cached after expand). |
| Drill-in (`overview → layer-detail`) | Unchanged. After drill-in, Stage 1 runs on the new layer's containers. |
| Breadcrumb | Containers do not enter the breadcrumb. Path remains `Project > LAYER`. |
| Code viewer | Unchanged. Click a file node inside a container → existing slide-up viewer. |
| WarningBanner | Layout repair issues feed the same banner. Fatal copy text augmented to differentiate render bugs from data bugs. |
| Export (PNG/SVG) | Captures current state including expanded containers. Filename includes layer name. |
---
## §8. Files & Test Plan
### 8.1 Files
```
packages/dashboard/src/
├── utils/
│ ├── layout.ts [modify] add applyElkLayout export
│ ├── elk-layout.ts [new] runElk + repairElkInput + GraphIssue mapping
│ ├── containers.ts [new] deriveContainers (folder + community fallback)
│ ├── louvain.ts [new] thin wrapper around graphology-communities-louvain
│ └── edgeAggregation.ts [modify] add aggregateContainerEdges
├── components/
│ ├── ContainerNode.tsx [new] container box visual
│ ├── GraphView.tsx [modify] Stage 1 / Stage 2 wiring, expand state, auto-expand triggers
│ └── DomainGraphView.tsx [modify] dagre → ELK
├── store.ts [modify] expandedContainers, containerLayoutCache, containerSizeMemory
└── package.json [modify] add elkjs ^0.9, graphology, graphology-communities-louvain
```
### 8.2 Test matrix
| Type | Target | Cases |
|---|---|---|
| Unit | `deriveContainers` | folder grouping happy path; all-in-root fallback; <2 buckets fallback; >70% concentration fallback; no-`filePath` nodes; single-child container suppression (gated by layer ≥ 3) |
| Unit | `aggregateContainerEdges` | empty edges; multiple same-direction edges merge; bidirectional edges split; intra + inter mix; types deduped |
| Unit | `repairElkInput` | each repair function in isolation; validates correct `GraphIssue` level emitted |
| Unit | `runElk` | minimal valid input; dev-mode strict throw; production graceful fatal; cancellation on dependency change |
| Integration | Stage 1 + Stage 2 flow | 50-node fixture; click → cache miss; second click → cache hit; size-deviation >20% → re-layout |
| Integration | Persona / focus / search interactions | switching persona reruns Stage 1; focusing a child auto-expands its container; search hit adds badge without auto-expanding |
| Visual regression (optional) | Playwright + microservices-demo fixture | baseline screenshots for overview, layer-detail, domain views |
### 8.3 Performance benchmarks
Generate fixtures with `scripts/generate-large-graph.mjs` at 500 / 1000 / 3000 nodes. Verify:
- Stage 1 < 200ms at 500 nodes; < 500ms at 3000 nodes.
- Stage 2 any container < 100ms.
If 3000-node Stage 1 misses the budget, revisit container size estimation or ELK config — do not lower the budget.
---
## Open Questions
None at this point. All decisions made during brainstorming are captured above.
## Migration Notes
- `applyDagreLayout` is kept in the codebase for one release after this lands, then removed in the next. This gives a fallback path during the rollout and a clean uninstall once stable.
- No graph data migration needed.
- New dependencies (elkjs, graphology, graphology-communities-louvain) are pure JS, no native bindings — safe across the supported platform matrix.
+106 -1
View File
@@ -115,6 +115,18 @@ importers:
devlop:
specifier: ^1.1.0
version: 1.1.0
elkjs:
specifier: ^0.9.3
version: 0.9.3
graphology:
specifier: ^0.25.4
version: 0.25.4(graphology-types@0.24.8)
graphology-communities-louvain:
specifier: ^2.0.1
version: 2.0.2(graphology-types@0.24.8)
graphology-types:
specifier: ^0.24.8
version: 0.24.8
hast-util-to-jsx-runtime:
specifier: ^2.3.6
version: 2.3.6
@@ -149,6 +161,9 @@ importers:
'@vitejs/plugin-react':
specifier: ^4.3.0
version: 4.7.0(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@vitest/coverage-v8':
specifier: ^3.2.4
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
tailwindcss:
specifier: ^4.0.0
version: 4.2.1
@@ -158,6 +173,9 @@ importers:
vite:
specifier: ^6.0.0
version: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
vitest:
specifier: ^3.1.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
packages:
@@ -1455,6 +1473,9 @@ packages:
electron-to-chromium@1.5.335:
resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==}
elkjs@0.9.3:
resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1509,6 +1530,10 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1564,6 +1589,29 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphology-communities-louvain@2.0.2:
resolution: {integrity: sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==}
peerDependencies:
graphology-types: '>=0.19.0'
graphology-indices@0.17.0:
resolution: {integrity: sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==}
peerDependencies:
graphology-types: '>=0.20.0'
graphology-types@0.24.8:
resolution: {integrity: sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==}
graphology-utils@2.5.2:
resolution: {integrity: sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==}
peerDependencies:
graphology-types: '>=0.23.0'
graphology@0.25.4:
resolution: {integrity: sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==}
peerDependencies:
graphology-types: '>=0.24.0'
h3@1.15.6:
resolution: {integrity: sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==}
@@ -2034,6 +2082,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mnemonist@0.39.8:
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -2077,6 +2128,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
obliterator@2.0.5:
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
@@ -2110,6 +2164,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pandemonium@2.4.1:
resolution: {integrity: sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -3656,6 +3713,14 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 3.2.4
@@ -4048,6 +4113,8 @@ snapshots:
electron-to-chromium@1.5.335: {}
elkjs@0.9.3: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -4137,6 +4204,8 @@ snapshots:
eventemitter3@5.0.4: {}
events@3.3.0: {}
expect-type@1.3.0: {}
extend@3.0.2: {}
@@ -4184,6 +4253,32 @@ snapshots:
graceful-fs@4.2.11: {}
graphology-communities-louvain@2.0.2(graphology-types@0.24.8):
dependencies:
graphology-indices: 0.17.0(graphology-types@0.24.8)
graphology-types: 0.24.8
graphology-utils: 2.5.2(graphology-types@0.24.8)
mnemonist: 0.39.8
pandemonium: 2.4.1
graphology-indices@0.17.0(graphology-types@0.24.8):
dependencies:
graphology-types: 0.24.8
graphology-utils: 2.5.2(graphology-types@0.24.8)
mnemonist: 0.39.8
graphology-types@0.24.8: {}
graphology-utils@2.5.2(graphology-types@0.24.8):
dependencies:
graphology-types: 0.24.8
graphology@0.25.4(graphology-types@0.24.8):
dependencies:
events: 3.3.0
graphology-types: 0.24.8
obliterator: 2.0.5
h3@1.15.6:
dependencies:
cookie-es: 1.2.2
@@ -4888,6 +4983,10 @@ snapshots:
minipass@7.1.3: {}
mnemonist@0.39.8:
dependencies:
obliterator: 2.0.5
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -4916,6 +5015,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
obliterator@2.0.5: {}
obug@2.1.1: {}
ofetch@1.5.1:
@@ -4949,6 +5050,10 @@ snapshots:
package-manager-detector@1.6.0: {}
pandemonium@2.4.1:
dependencies:
mnemonist: 0.39.8
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -5616,7 +5721,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"build:demo": "tsc -b && vite build --config vite.config.demo.ts",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
@@ -15,6 +17,10 @@
"@xyflow/react": "^12.0.0",
"d3-force": "^3.0.0",
"devlop": "^1.1.0",
"elkjs": "^0.9.3",
"graphology": "^0.25.4",
"graphology-communities-louvain": "^2.0.1",
"graphology-types": "^0.24.8",
"hast-util-to-jsx-runtime": "^2.3.6",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
@@ -28,8 +34,10 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"@vitest/coverage-v8": "^3.2.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^3.1.0"
}
}
@@ -0,0 +1,80 @@
// Stage 1 ELK layout perf benchmark.
//
// Mirrors `applyElkLayout` from `src/utils/elk-layout.ts` using `elkjs`
// directly. The dashboard build is a Vite bundle (hashed chunks), so it has
// no per-module `dist/utils/elk-layout.js` we can import. The Stage 1 hot
// path is `elk.layout()` on a sized input, which we reproduce faithfully
// here — same default node dimensions, same dim-defaulting behavior.
//
// Targets (spec §8.3):
// - Stage 1 < 200ms at 500 nodes
// - Stage 1 < 500ms at 3000 nodes
//
// Usage:
// node understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs
import { performance } from "node:perf_hooks";
import ELK from "elkjs/lib/elk.bundled.js";
// Keep in lockstep with NODE_WIDTH / NODE_HEIGHT in src/utils/layout.ts.
const DEFAULT_NODE_WIDTH = 280;
const DEFAULT_NODE_HEIGHT = 120;
const elk = new ELK();
/**
* Default missing width/height on every node (mirrors repairElkInput's
* ensureNodeDimensions step). Stage 1 in prod always feeds ELK sized nodes,
* but the repair pass is part of the measured path so we model it.
*/
function fillDims(children) {
return children.map((c) => {
const next = { ...c };
if (next.width == null) next.width = DEFAULT_NODE_WIDTH;
if (next.height == null) next.height = DEFAULT_NODE_HEIGHT;
if (next.children) next.children = fillDims(next.children);
return next;
});
}
async function applyElkLayout(input) {
const repaired = { ...input, children: fillDims(input.children) };
return elk.layout(repaired);
}
/**
* Synthetic Stage 1 graph: top-level container nodes with a sparse edge mesh.
* Stage 1 only lays out containers (lazy children — see plan §3), so the
* "node count" parameter is interpreted as total leaves while the container
* count scales sub-linearly, matching production shape.
*/
function makeGraph(nodeCount, containerCount = Math.min(20, Math.ceil(nodeCount / 25))) {
const containers = Array.from({ length: containerCount }, (_, i) => ({
id: `c${i}`,
width: 400,
height: 300,
}));
const edges = [];
for (let i = 0; i < containerCount; i++) {
for (let j = i + 1; j < containerCount; j++) {
if (Math.random() < 0.3) {
edges.push({ id: `e-${i}-${j}`, sources: [`c${i}`], targets: [`c${j}`] });
}
}
}
return { id: "root", children: containers, edges };
}
async function bench(label, n) {
const input = makeGraph(n);
const t0 = performance.now();
await applyElkLayout(input);
const t1 = performance.now();
const ms = t1 - t0;
console.log(`${label} (${n} nodes): ${ms.toFixed(1)}ms`);
return ms;
}
await bench("Stage1", 500);
await bench("Stage1", 1000);
await bench("Stage1", 3000);
@@ -117,6 +117,13 @@ function Dashboard({ accessToken }: { accessToken: string }) {
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
const domainGraph = useDashboardStore((s) => s.domainGraph);
const setDomainGraph = useDashboardStore((s) => s.setDomainGraph);
const layoutIssues = useDashboardStore((s) => s.layoutIssues);
// Schema issues + ELK layout issues share the WarningBanner — graph-load
// problems and dashboard rendering problems are equally surfaced.
const allIssues = useMemo(
() => [...graphIssues, ...layoutIssues],
[graphIssues, layoutIssues],
);
useEffect(() => {
fetch(dataUrl("meta.json", accessToken))
@@ -506,8 +513,8 @@ function Dashboard({ accessToken }: { accessToken: string }) {
<SearchBar />
{/* Validation warning banner */}
{graphIssues.length > 0 && !loadError && (
<WarningBanner issues={graphIssues} />
{allIssues.length > 0 && !loadError && (
<WarningBanner issues={allIssues} />
)}
{/* Error banner */}
@@ -0,0 +1,101 @@
import { memo } from "react";
import type { NodeProps, Node } from "@xyflow/react";
import { getLayerColor } from "./LayerLegend";
export interface ContainerNodeData extends Record<string, unknown> {
containerId: string;
name: string;
childCount: number;
strategy: "folder" | "community";
colorIndex: number;
isExpanded: boolean;
hasSearchHits: boolean;
searchHitCount?: number;
isDiffAffected: boolean;
isFocusedViaChild: boolean;
onToggle: (containerId: string) => void;
}
export type ContainerFlowNode = Node<ContainerNodeData, "container">;
function ContainerNodeComponent({ data, width, height }: NodeProps<ContainerFlowNode>) {
const color = getLayerColor(data.colorIndex);
const borderColor = data.isDiffAffected
? "var(--color-diff-changed)"
: data.isExpanded || data.isFocusedViaChild
? "rgba(212,165,116,0.6)"
: "rgba(212,165,116,0.25)";
const borderWidth = data.isExpanded || data.isFocusedViaChild ? 1.5 : 1;
const labelDimmed = data.name === "~";
const labelText = labelDimmed ? "(root)" : data.name;
const handleToggle = (e: React.SyntheticEvent) => {
e.stopPropagation();
data.onToggle(data.containerId);
};
return (
<div
role="button"
tabIndex={0}
aria-expanded={data.isExpanded}
aria-label={`${labelText} container, ${data.childCount} item${data.childCount !== 1 ? "s" : ""}, ${data.isExpanded ? "expanded" : "collapsed"}`}
className="rounded-xl cursor-pointer transition-all focus:outline-none focus:ring-2 focus:ring-[rgba(212,165,116,0.6)]"
style={{
width,
height,
background: "rgba(255,255,255,0.02)",
border: `${borderWidth}px solid ${borderColor}`,
position: "relative",
}}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggle(e);
}
}}
>
<div
className="flex items-center justify-between font-serif"
style={{
padding: "12px 16px",
color: color.label,
fontSize: 14,
fontWeight: 400,
}}
>
<span
className={labelDimmed ? "opacity-50" : ""}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
{data.isExpanded && <span style={{ fontSize: 10 }}></span>}
{labelText}
{data.searchHitCount != null && data.searchHitCount > 0 && (
<span
className="font-mono"
style={{
marginLeft: 6,
fontSize: 10,
background: "rgba(212,165,116,0.2)",
color: "var(--color-gold, #d4a574)",
padding: "1px 6px",
borderRadius: 8,
}}
>
{data.searchHitCount} hit{data.searchHitCount !== 1 ? "s" : ""}
</span>
)}
</span>
<span style={{ color: "#a39787", fontSize: 11 }}>{data.childCount}</span>
</div>
</div>
);
}
const ContainerNode = memo(ContainerNodeComponent);
ContainerNode.displayName = "ContainerNode";
export default ContainerNode;
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ReactFlow,
ReactFlowProvider,
@@ -17,7 +17,8 @@ import type { FlowFlowNode } from "./FlowNode";
import StepNode from "./StepNode";
import type { StepFlowNode } from "./StepNode";
import { useDashboardStore } from "../store";
import { applyDagreLayout } from "../utils/layout";
import { mergeElkPositions, nodesToElkInput } from "../utils/layout";
import { applyElkLayout } from "../utils/elk-layout";
import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types";
const nodeTypes = {
@@ -30,7 +31,13 @@ function getDomainMeta(node: GraphNode) {
return node.domainMeta;
}
function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edge[] } {
interface BuiltGraph {
nodes: Node[];
edges: Edge[];
dims: Map<string, { width: number; height: number }>;
}
function buildDomainOverview(graph: KnowledgeGraph): BuiltGraph {
const dims = new Map<string, { width: number; height: number }>();
const domainNodes = graph.nodes.filter((n) => n.type === "domain");
@@ -76,17 +83,13 @@ function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edg
animated: true,
}));
// Compute spacing based on longest edge label (~6px per char at fontSize 10)
const maxLabelLen = Math.max(0, ...rfEdges.map((e) => String(e.label ?? "").length));
const ranksep = Math.max(120, maxLabelLen * 6);
return applyDagreLayout(rfNodes, rfEdges, "LR", dims, { ranksep });
return { nodes: rfNodes as unknown as Node[], edges: rfEdges, dims };
}
function buildDomainDetail(
graph: KnowledgeGraph,
domainId: string,
): { nodes: Node[]; edges: Edge[] } {
): BuiltGraph {
// Find flows for this domain
const flowIds = new Set(
graph.edges
@@ -157,7 +160,7 @@ function buildDomainDetail(
animated: false,
}));
return applyDagreLayout(rfNodes, rfEdges, "LR", dims);
return { nodes: rfNodes, edges: rfEdges, dims };
}
function DomainGraphViewInner() {
@@ -165,14 +168,56 @@ function DomainGraphViewInner() {
const activeDomainId = useDashboardStore((s) => s.activeDomainId);
const clearActiveDomain = useDashboardStore((s) => s.clearActiveDomain);
const { nodes, edges } = useMemo(() => {
if (!domainGraph) return { nodes: [], edges: [] };
// Build structural nodes/edges/dims synchronously; only the layout call
// itself is async, so we memo the structural pieces and run ELK in an
// effect.
const built = useMemo<BuiltGraph | null>(() => {
if (!domainGraph) return null;
if (activeDomainId) {
return buildDomainDetail(domainGraph, activeDomainId);
}
return buildDomainOverview(domainGraph);
}, [domainGraph, activeDomainId]);
const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] }>({
nodes: [],
edges: [],
});
useEffect(() => {
if (!built) {
setLayout({ nodes: [], edges: [] });
return;
}
let cancelled = false;
const { nodes: nodesArray, edges: edgesArray, dims } = built;
// DomainGraphView used dagre LR; preserve that direction with ELK.
const elkInput = nodesToElkInput(nodesArray, edgesArray, dims, {
"elk.direction": "RIGHT",
});
applyElkLayout(elkInput, { strict: import.meta.env.DEV })
.then(({ positioned, issues }) => {
if (cancelled) return;
if (issues.length > 0) {
// Funnel into store so WarningBanner surfaces them.
useDashboardStore.getState().appendLayoutIssues(issues);
}
setLayout({
nodes: mergeElkPositions(nodesArray, positioned),
edges: edgesArray,
});
})
.catch((err) => {
if (cancelled) return;
console.error("[domain ELK] layout failed:", err);
});
return () => {
cancelled = true;
};
}, [built]);
const { nodes, edges } = layout;
// Double-click is handled by individual node components (e.g. DomainClusterNode)
if (!domainGraph) {
File diff suppressed because it is too large Load Diff
@@ -6,17 +6,27 @@ interface WarningBannerProps {
}
function buildCopyText(issues: GraphIssue[]): string {
const lines = [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
const hasFatal = issues.some((i) => i.level === "fatal");
// Fatal issues are dashboard rendering bugs (e.g. ELK layout failures), not
// LLM generation errors — route the user to file a bug report instead of
// asking their agent to "fix" the knowledge-graph.json.
const lines = hasFatal
? [
"Some of these issues look like dashboard rendering bugs.",
"Please file an issue at github.com/Lum1104/Understand-Anything/issues with the text below.",
"",
]
: [
"The following issues were found in your knowledge-graph.json.",
"These are LLM generation errors — not a system bug.",
"You can ask your agent to fix these specific issues in the knowledge-graph.json file:",
"",
];
// Auto-corrected first, then dropped
// Show fatal first (most actionable for bug reports), then dropped, then auto-corrected.
const sorted = [...issues].sort((a, b) => {
const order: Record<string, number> = { "auto-corrected": 0, dropped: 1, fatal: 2 };
return (order[a.level] ?? 2) - (order[b.level] ?? 2);
const order: Record<string, number> = { fatal: 0, dropped: 1, "auto-corrected": 2 };
return (order[a.level] ?? 3) - (order[b.level] ?? 3);
});
for (const issue of sorted) {
@@ -36,18 +46,25 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const fatal = issues.filter((i) => i.level === "fatal");
const autoCorrected = issues.filter((i) => i.level === "auto-corrected");
const dropped = issues.filter((i) => i.level === "dropped");
const hasFatal = fatal.length > 0;
// Build summary text — only mention counts > 0
const parts: string[] = [];
if (fatal.length > 0) {
parts.push(`${fatal.length} fatal error${fatal.length !== 1 ? "s" : ""}`);
}
if (autoCorrected.length > 0) {
parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`);
}
if (dropped.length > 0) {
parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`);
}
const summary = `Knowledge graph loaded with ${parts.join(" and ")}`;
const summary = hasFatal
? `Dashboard hit ${parts.join(", ")}`
: `Knowledge graph loaded with ${parts.join(" and ")}`;
const handleCopy = useCallback(async () => {
const text = buildCopyText(issues);
@@ -62,18 +79,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
if (issues.length === 0) return null;
// Fatal issues escalate the banner from amber (warning) to red (error).
const containerClasses = hasFatal
? "bg-red-900/25 border-b border-red-700 text-red-200 text-sm"
: "bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm";
const hoverClasses = hasFatal
? "hover:bg-red-900/15"
: "hover:bg-amber-900/10";
const iconClasses = hasFatal ? "text-red-400" : "text-amber-400";
const hintClasses = hasFatal ? "text-red-400/60" : "text-amber-400/60";
const dividerClasses = hasFatal ? "border-red-700/50" : "border-amber-700/50";
const footerTextClasses = hasFatal ? "text-red-200/70" : "text-amber-200/60";
const buttonClasses = hasFatal
? "bg-red-800/40 text-red-200 hover:bg-red-800/60"
: "bg-amber-800/40 text-amber-200 hover:bg-amber-800/60";
const footerCopy = hasFatal
? "Copy these issues and file a bug report on GitHub"
: "Copy these issues and ask your agent to fix them in knowledge-graph.json";
return (
<div className="bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm">
<div className={containerClasses}>
{/* Collapsed summary row */}
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((prev) => !prev)}
className="w-full flex items-center gap-2 px-5 py-3 text-left hover:bg-amber-900/10 transition-colors"
className={`w-full flex items-center gap-2 px-5 py-3 text-left transition-colors ${hoverClasses}`}
>
{/* Chevron icon */}
<svg
className={`w-4 h-4 shrink-0 text-amber-400 transition-transform duration-200 ${
className={`w-4 h-4 shrink-0 ${iconClasses} transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
fill="none"
@@ -90,7 +125,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Warning icon */}
<svg
className="w-4 h-4 shrink-0 text-amber-400"
className={`w-4 h-4 shrink-0 ${iconClasses}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -105,7 +140,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
<span className="flex-1">{summary}</span>
<span className="text-amber-400/60 text-xs shrink-0">
<span className={`text-xs shrink-0 ${hintClasses}`}>
{expanded ? "click to collapse" : "click to expand"}
</span>
</button>
@@ -115,9 +150,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
<div className="px-5 pb-4">
{/* Issue list */}
<div className="space-y-1 mb-3">
{/* Fatal issues — top of list, red, most prominent */}
{fatal.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-red-400 mb-1">
Fatal ({fatal.length})
</h4>
{fatal.map((issue, i) => (
<div
key={`ft-${i}`}
className="flex items-start gap-2 py-0.5 pl-2 text-red-200"
>
<span className="text-red-400 shrink-0 mt-0.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</span>
<span className="text-xs">{issue.message}</span>
</div>
))}
</div>
)}
{/* Auto-corrected issues */}
{autoCorrected.length > 0 && (
<div>
<div className={fatal.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-amber-400 mb-1">
Auto-corrected ({autoCorrected.length})
</h4>
@@ -136,7 +198,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Dropped issues */}
{dropped.length > 0 && (
<div className={autoCorrected.length > 0 ? "mt-2" : ""}>
<div className={fatal.length > 0 || autoCorrected.length > 0 ? "mt-2" : ""}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-orange-400 mb-1">
Dropped ({dropped.length})
</h4>
@@ -155,14 +217,12 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
</div>
{/* Footer with copy button and actionable message */}
<div className="flex items-center justify-between pt-2 border-t border-amber-700/50">
<p className="text-xs text-amber-200/60">
Copy these issues and ask your agent to fix them in knowledge-graph.json
</p>
<div className={`flex items-center justify-between pt-2 border-t ${dividerClasses}`}>
<p className={`text-xs ${footerTextClasses}`}>{footerCopy}</p>
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium bg-amber-800/40 text-amber-200 hover:bg-amber-800/60 transition-colors shrink-0 ml-4"
className={`flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium transition-colors shrink-0 ml-4 ${buttonClasses}`}
>
{copied ? (
<>
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { SearchEngine } from "@understand-anything/core/search";
import type { SearchResult } from "@understand-anything/core/search";
import type { GraphIssue } from "@understand-anything/core/schema";
import type {
KnowledgeGraph,
TourStep,
@@ -148,6 +149,37 @@ interface DashboardStore {
setIsKnowledgeGraph: (value: boolean) => void;
navigateToDomain: (domainId: string) => void;
clearActiveDomain: () => void;
// Container expand/collapse + lazy layout caches
expandedContainers: Set<string>;
toggleContainer: (containerId: string) => void;
expandContainer: (containerId: string) => void;
collapseAllContainers: () => void;
containerLayoutCache: Map<
string,
{
childPositions: Map<string, { x: number; y: number }>;
actualSize: { width: number; height: number };
}
>;
setContainerLayout: (
containerId: string,
childPositions: Map<string, { x: number; y: number }>,
actualSize: { width: number; height: number },
) => void;
clearContainerLayouts: () => void;
containerSizeMemory: Map<string, { width: number; height: number }>;
stage1Tick: number;
bumpStage1Tick: () => void;
// Layout-time issues (e.g. ELK input repair). Funneled into the
// WarningBanner alongside graph-validation issues.
layoutIssues: GraphIssue[];
appendLayoutIssues: (issues: GraphIssue[]) => void;
clearLayoutIssues: () => void;
}
function getSortedTour(graph: KnowledgeGraph): TourStep[] {
@@ -212,6 +244,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
...state.nodeTypeFilters,
[category]: !state.nodeTypeFilters[category],
},
// Filter changes shift container.nodeIds; cached child positions
// may reference filtered-out children. Drop the cache so Stage 2
// recomputes against the current set.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
})),
setGraph: (graph) => {
@@ -232,6 +270,11 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
nodeHistory: [],
viewMode: keepDomainView ? "domain" as const : "structural" as const,
activeDomainId: keepDomainView ? activeDomainId : null,
containerLayoutCache: new Map(),
expandedContainers: new Set(),
containerSizeMemory: new Map(),
stage1Tick: 0,
layoutIssues: [],
});
},
@@ -322,6 +365,12 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
// Container ids derive from folder names and collide across layers
// (e.g. `container:auth` exists in many layers). Drop the cache so
// we don't render stale positions for the new layer's children.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
}),
navigateToOverview: () =>
@@ -333,9 +382,22 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
codeViewerOpen: false,
codeViewerNodeId: null,
codeViewerExpanded: false,
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
}),
setFocusNode: (nodeId) => set({ focusNodeId: nodeId, selectedNodeId: nodeId }),
setFocusNode: (nodeId) =>
set({
focusNodeId: nodeId,
selectedNodeId: nodeId,
// Focus mode narrows filteredGraphNodes to focus + 1-hop; the
// surviving containers have a subset of their original children,
// and the cache must not return positions for filtered-out ids.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
}),
setSearchMode: (mode) => set({ searchMode: mode }),
setSearchQuery: (query) => {
const engine = get().searchEngine;
@@ -351,7 +413,14 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
set({ searchQuery: query, searchResults });
},
setPersona: (persona) => set({ persona }),
setPersona: (persona) =>
set({
persona,
// Persona changes filter node types, which shifts container.nodeIds.
containerLayoutCache: new Map(),
containerSizeMemory: new Map(),
expandedContainers: new Set(),
}),
openCodeViewer: (nodeId) =>
set({ codeViewerOpen: true, codeViewerNodeId: nodeId, codeViewerExpanded: false }),
@@ -521,4 +590,53 @@ export const useDashboardStore = create<DashboardStore>()((set, get) => ({
focusNodeId: null,
});
},
expandedContainers: new Set<string>(),
toggleContainer: (containerId) =>
set((state) => {
const next = new Set(state.expandedContainers);
if (next.has(containerId)) next.delete(containerId);
else next.add(containerId);
return { expandedContainers: next };
}),
expandContainer: (containerId) =>
set((state) => {
if (state.expandedContainers.has(containerId)) return {};
const next = new Set(state.expandedContainers);
next.add(containerId);
return { expandedContainers: next };
}),
collapseAllContainers: () => set({ expandedContainers: new Set() }),
containerLayoutCache: new Map(),
setContainerLayout: (containerId, childPositions, actualSize) =>
set((state) => {
const next = new Map(state.containerLayoutCache);
next.set(containerId, { childPositions, actualSize });
const sizeNext = new Map(state.containerSizeMemory);
sizeNext.set(containerId, actualSize);
return { containerLayoutCache: next, containerSizeMemory: sizeNext };
}),
clearContainerLayouts: () =>
set({ containerLayoutCache: new Map(), expandedContainers: new Set() }),
containerSizeMemory: new Map(),
stage1Tick: 0,
bumpStage1Tick: () => set((s) => ({ stage1Tick: s.stage1Tick + 1 })),
layoutIssues: [],
appendLayoutIssues: (issues) =>
set((state) => {
if (issues.length === 0) return {};
// Dedupe by level+message so a re-running effect doesn't repeatedly
// pile up identical issues.
const seen = new Set(
state.layoutIssues.map((i) => `${i.level}|${i.message}`),
);
const fresh = issues.filter((i) => !seen.has(`${i.level}|${i.message}`));
if (fresh.length === 0) return {};
return { layoutIssues: [...state.layoutIssues, ...fresh] };
}),
clearLayoutIssues: () => set({ layoutIssues: [] }),
}));
@@ -0,0 +1,131 @@
import { describe, it, expect } from "vitest";
import { deriveContainers } from "../containers";
import type { GraphNode, GraphEdge } from "@understand-anything/core/types";
function node(id: string, filePath?: string): GraphNode {
return {
id,
type: "file",
name: id,
filePath,
summary: "",
complexity: "simple",
tags: [],
} as GraphNode;
}
describe("deriveContainers — folder strategy", () => {
it("groups nodes by first folder segment after LCP", () => {
const nodes = [
node("a", "src/auth/login.go"),
node("b", "src/auth/oauth.go"),
node("c", "src/cart/cart.go"),
node("d", "src/cart/checkout.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
expect(ungrouped).toEqual([]);
expect(containers).toHaveLength(2);
const names = containers.map((c) => c.name).sort();
expect(names).toEqual(["auth", "cart"]);
const auth = containers.find((c) => c.name === "auth")!;
expect(auth.strategy).toBe("folder");
expect(auth.nodeIds.sort()).toEqual(["a", "b"]);
});
it("strips deep LCP", () => {
const nodes = [
node("a", "monorepo/backend/src/auth/login.go"),
node("b", "monorepo/backend/src/cart/cart.go"),
];
const { containers } = deriveContainers(nodes, []);
const names = containers.map((c) => c.name).sort();
expect(names).toEqual(["auth", "cart"]);
});
it("collapses nested folders into the first segment", () => {
const nodes = [
node("a", "auth/handlers/oauth.go"),
node("b", "auth/services/token.go"),
node("c", "cart/cart.go"),
];
const { containers } = deriveContainers(nodes, []);
expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]);
});
it("places nodes without filePath in '~' container", () => {
const nodes = [
node("a", "auth/login.go"),
node("b", "auth/oauth.go"),
node("c"),
node("d"),
];
const { containers } = deriveContainers(nodes, []);
expect(containers.find((c) => c.name === "~")?.nodeIds.sort()).toEqual(["c", "d"]);
});
it("suppresses single-child containers (single child becomes ungrouped)", () => {
const nodes = [
node("a", "auth/login.go"),
node("b", "auth/oauth.go"),
node("c", "cart/cart.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
// 'cart' has only 1 child → suppressed
expect(containers.find((c) => c.name === "cart")).toBeUndefined();
expect(ungrouped).toContain("c");
// 'auth' kept
expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]);
});
it("returns flat (no containers) when total nodes < 8", () => {
const nodes = [
node("a", "auth/x.go"),
node("b", "cart/y.go"),
node("c", "logs/z.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
expect(containers).toHaveLength(0);
expect(ungrouped.sort()).toEqual(["a", "b", "c"]);
});
});
describe("deriveContainers — community fallback", () => {
it("falls back to communities when only one folder present", () => {
const nodes = Array.from({ length: 10 }, (_, i) =>
node(`n${i}`, `services/n${i}.go`),
);
// Two clusters of 5 nodes; densely connected within, no edges between
const edges: GraphEdge[] = [];
for (const i of [0, 1, 2, 3, 4]) {
for (const j of [0, 1, 2, 3, 4]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
for (const i of [5, 6, 7, 8, 9]) {
for (const j of [5, 6, 7, 8, 9]) {
if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge);
}
}
const { containers } = deriveContainers(nodes, edges);
expect(containers.length).toBeGreaterThanOrEqual(2);
for (const c of containers) {
expect(c.strategy).toBe("community");
expect(c.name).toMatch(/^Cluster [A-Z]$/);
}
});
it("falls back when one folder holds > 70%", () => {
const nodes = [
...Array.from({ length: 8 }, (_, i) => node(`big${i}`, `big/file${i}.go`)),
node("a", "small1/a.go"),
node("b", "small2/b.go"),
];
const { containers, ungrouped } = deriveContainers(nodes, []);
// Folder strategy would have produced a 'big' container with 8 children.
// Community fallback (no edges) gives each node its own community → all
// single-child → all suppressed. The non-vacuous evidence the fallback
// path was taken: NO folder-strategy 'big' container survives.
expect(containers.find((c) => c.strategy === "folder" && c.name === "big")).toBeUndefined();
expect(ungrouped.length).toBe(10);
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { aggregateContainerEdges } from "../edgeAggregation";
import type { GraphEdge, EdgeType } from "@understand-anything/core/types";
const ce = (source: string, target: string, type: EdgeType = "calls"): GraphEdge => ({
source,
target,
type,
direction: "forward",
weight: 1,
});
describe("aggregateContainerEdges", () => {
it("returns empty arrays for empty input", () => {
const r = aggregateContainerEdges([], new Map());
expect(r.intraContainer).toEqual([]);
expect(r.interContainerAggregated).toEqual([]);
});
it("preserves intra-container edges as-is", () => {
const m = new Map([
["a", "auth"],
["b", "auth"],
]);
const r = aggregateContainerEdges([ce("a", "b")], m);
expect(r.intraContainer).toHaveLength(1);
expect(r.interContainerAggregated).toEqual([]);
});
it("merges multiple same-direction inter edges into one", () => {
const m = new Map([
["a", "auth"],
["b", "auth"],
["c", "cart"],
["d", "cart"],
]);
const edges = [ce("a", "c"), ce("a", "d"), ce("b", "c", "imports")];
const r = aggregateContainerEdges(edges, m);
expect(r.interContainerAggregated).toHaveLength(1);
const agg = r.interContainerAggregated[0];
expect(agg.sourceContainerId).toBe("auth");
expect(agg.targetContainerId).toBe("cart");
expect(agg.count).toBe(3);
expect(agg.edgeTypes.sort()).toEqual(["calls", "imports"]);
});
it("treats opposite directions as separate aggregated edges", () => {
const m = new Map([
["a", "auth"],
["c", "cart"],
]);
const r = aggregateContainerEdges([ce("a", "c"), ce("c", "a")], m);
expect(r.interContainerAggregated).toHaveLength(2);
const dirs = r.interContainerAggregated.map(
(e) => `${e.sourceContainerId}${e.targetContainerId}`,
);
expect(dirs.sort()).toEqual(["auth→cart", "cart→auth"]);
});
it("ignores edges whose endpoints have no container mapping", () => {
const m = new Map([["a", "auth"]]);
const r = aggregateContainerEdges([ce("a", "z")], m);
expect(r.intraContainer).toEqual([]);
expect(r.interContainerAggregated).toEqual([]);
});
it("does not collide when container ids contain the separator character", () => {
// Pre-fix: key was `${sc} ${tc}` so `("x y", "z")` and `("x", "y z")`
// would both map to `"x y z"`. Length-prefix on source prevents this.
const m = new Map([
["a", "x y"],
["b", "z"],
["c", "x"],
["d", "y z"],
]);
const r = aggregateContainerEdges([ce("a", "b"), ce("c", "d")], m);
expect(r.interContainerAggregated).toHaveLength(2);
});
});
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { applyElkLayout, repairElkInput, type ElkInput } from "../elk-layout";
describe("repairElkInput", () => {
it("ensures node dimensions when missing", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a" }, { id: "b", width: 100, height: 50 }] as ElkInput["children"],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children![0].width).toBeGreaterThan(0);
expect(out.children![0].height).toBeGreaterThan(0);
expect(out.children![1]).toEqual({ id: "b", width: 100, height: 50 });
expect(issues.some((i) => i.level === "auto-corrected" && /dimensions/.test(i.message))).toBe(true);
});
it("dedupes duplicate child ids and reports auto-corrected", () => {
const input: ElkInput = {
id: "root",
children: [
{ id: "a", width: 1, height: 1 },
{ id: "a", width: 1, height: 1 },
],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children).toHaveLength(1);
expect(issues.some((i) => i.level === "auto-corrected" && /duplicate/.test(i.message))).toBe(true);
});
it("drops orphan edges referencing nonexistent nodes", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a", width: 1, height: 1 }],
edges: [
{ id: "e1", sources: ["a"], targets: ["ghost"] },
],
};
const { input: out, issues } = repairElkInput(input);
expect(out.edges).toHaveLength(0);
expect(issues.some((i) => i.level === "dropped" && /edge/.test(i.message))).toBe(true);
});
it("drops children referencing nonexistent parents", () => {
const input: ElkInput = {
id: "root",
children: [
{
id: "p",
width: 100,
height: 100,
children: [{ id: "c1", width: 1, height: 1 }],
},
{ id: "orphan", width: 1, height: 1, parentId: "ghost" } as ElkInput["children"][0] & { parentId: string },
],
edges: [],
};
const { input: out, issues } = repairElkInput(input);
expect(out.children!.find((c) => c.id === "orphan")).toBeUndefined();
expect(issues.some((i) => i.level === "dropped" && /parent/.test(i.message))).toBe(true);
});
it("strict mode throws on any issue", () => {
const input: ElkInput = {
id: "root",
children: [{ id: "a" }] as ElkInput["children"],
edges: [],
};
expect(() => repairElkInput(input, { strict: true })).toThrow(/dimensions/);
});
});
describe("applyElkLayout", () => {
it("lays out a small graph and returns positions", async () => {
const result = await applyElkLayout({
id: "root",
children: [
{ id: "a", width: 100, height: 50 },
{ id: "b", width: 100, height: 50 },
],
edges: [{ id: "e1", sources: ["a"], targets: ["b"] }],
layoutOptions: { algorithm: "layered", "elk.direction": "DOWN" },
});
expect(result.issues).toEqual([]);
expect(result.positioned.children).toHaveLength(2);
for (const c of result.positioned.children) {
expect(typeof c.x).toBe("number");
expect(typeof c.y).toBe("number");
}
});
it("returns fatal issue when ELK rejects (without throwing in non-strict)", async () => {
// Force ELK rejection by giving an invalid algorithm
const result = await applyElkLayout(
{
id: "root",
children: [{ id: "a", width: 1, height: 1 }],
edges: [],
layoutOptions: { algorithm: "this-algorithm-does-not-exist" },
},
{ strict: false },
);
expect(result.issues.some((i) => i.level === "fatal")).toBe(true);
});
});
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest";
import ELK from "elkjs/lib/elk.bundled.js";
import Graph from "graphology";
import louvain from "graphology-communities-louvain";
describe("dependency smoke test", () => {
it("imports elkjs", () => {
expect(typeof ELK).toBe("function");
});
it("imports graphology", () => {
const g = new Graph();
g.addNode("a");
expect(g.order).toBe(1);
});
it("imports graphology-communities-louvain", () => {
expect(typeof louvain).toBe("function");
});
});
@@ -0,0 +1,156 @@
import type {
GraphNode,
GraphEdge,
} from "@understand-anything/core/types";
import { detectCommunities } from "./louvain";
export interface DerivedContainer {
id: string;
name: string;
nodeIds: string[];
strategy: "folder" | "community";
}
export interface DeriveResult {
containers: DerivedContainer[];
ungrouped: string[];
}
const MIN_BUCKET_COUNT = 2;
const MAX_CONCENTRATION = 0.7;
const MIN_NODES_FOR_SUPPRESSION = 3;
const ROOT_BUCKET = "~";
/**
* Longest common prefix of the *directory* portion of paths, trimmed to a
* `/` boundary. Using dirs (not full paths) avoids consuming the only
* folder segment when all paths sit directly under the same folder
* (e.g. `[auth/x, auth/y]` → LCP `""`, so we still group on `auth`).
*/
function commonPrefix(paths: string[]): string {
if (paths.length === 0) return "";
const dirs = paths.map((p) => {
const slash = p.lastIndexOf("/");
return slash >= 0 ? p.slice(0, slash) : "";
});
let prefix = dirs[0];
for (const d of dirs) {
while (!d.startsWith(prefix)) {
prefix = prefix.slice(0, -1);
if (!prefix) return "";
}
}
const lastSlash = prefix.lastIndexOf("/");
return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : "";
}
function firstSegment(path: string): string {
const slash = path.indexOf("/");
return slash >= 0 ? path.slice(0, slash) : path;
}
function groupByFolder(
nodes: GraphNode[],
): { groups: Map<string, string[]>; rooted: string[] } {
const withPath = nodes.filter((n) => n.filePath);
const lcp = commonPrefix(withPath.map((n) => n.filePath!));
const groups = new Map<string, string[]>();
const rooted: string[] = [];
for (const n of nodes) {
if (!n.filePath) {
rooted.push(n.id);
continue;
}
const stripped = n.filePath.slice(lcp.length);
if (!stripped.includes("/")) {
rooted.push(n.id);
continue;
}
const seg = firstSegment(stripped);
const arr = groups.get(seg) ?? [];
arr.push(n.id);
groups.set(seg, arr);
}
return { groups, rooted };
}
function shouldFallbackToCommunity(
groups: Map<string, string[]>,
rooted: string[],
totalNodes: number,
): boolean {
const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0);
if (bucketCount < MIN_BUCKET_COUNT) return true;
for (const ids of groups.values()) {
if (ids.length / totalNodes > MAX_CONCENTRATION) return true;
}
if (rooted.length / totalNodes > MAX_CONCENTRATION) return true;
return false;
}
export function deriveContainers(
nodes: GraphNode[],
edges: GraphEdge[],
): DeriveResult {
if (nodes.length === 0) {
return { containers: [], ungrouped: [] };
}
const { groups, rooted } = groupByFolder(nodes);
const useCommunity = shouldFallbackToCommunity(groups, rooted, nodes.length);
let containers: DerivedContainer[];
if (useCommunity) {
const communities = detectCommunities(
nodes.map((n) => n.id),
edges,
);
const byCommunity = new Map<number, string[]>();
for (const [nodeId, cid] of communities) {
const arr = byCommunity.get(cid) ?? [];
arr.push(nodeId);
byCommunity.set(cid, arr);
}
const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]);
containers = sorted.map(([cid, ids], i) => ({
id: `container:cluster-${cid}`,
// A-Z for the first 26, then numeric. Avoids `String.fromCharCode(65+i)`
// wrapping into `[`, `\`, `]` ... once the cluster count exceeds 26.
name: i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`,
nodeIds: ids,
strategy: "community" as const,
}));
} else {
containers = [...groups.entries()].map(([seg, ids]) => ({
id: `container:${seg}`,
name: seg,
nodeIds: ids,
strategy: "folder" as const,
}));
if (rooted.length > 0) {
containers.push({
id: `container:${ROOT_BUCKET}`,
name: ROOT_BUCKET,
nodeIds: rooted,
strategy: "folder" as const,
});
}
}
// Suppress single-child containers (their child becomes ungrouped).
// Skip suppression for tiny layers — with so few nodes, even single-item
// boxes carry useful folder context that shouldn't be discarded.
const ungrouped: string[] = [];
if (nodes.length >= MIN_NODES_FOR_SUPPRESSION) {
containers = containers.filter((c) => {
if (c.nodeIds.length === 1) {
ungrouped.push(c.nodeIds[0]);
return false;
}
return true;
});
}
return { containers, ungrouped };
}
@@ -0,0 +1,244 @@
import ELK from "elkjs/lib/elk.bundled.js";
import type { GraphIssue } from "@understand-anything/core/schema";
import { NODE_WIDTH, NODE_HEIGHT } from "./layout";
export interface ElkChild {
id: string;
width?: number;
height?: number;
/** Set by ELK after layout; absent on input. Downstream consumers must default. */
x?: number;
y?: number;
children?: ElkChild[];
parentId?: string;
}
export interface ElkEdge {
id: string;
sources: string[];
targets: string[];
}
export interface ElkInput {
id: string;
children: ElkChild[];
edges: ElkEdge[];
layoutOptions?: Record<string, string>;
}
// Keep ELK fallback dimensions in lockstep with the dagre/force NODE
// dimensions in utils/layout.ts so layouts stay collision-consistent
// during the migration.
const DEFAULT_NODE_WIDTH = NODE_WIDTH;
const DEFAULT_NODE_HEIGHT = NODE_HEIGHT;
interface RepairOptions {
strict?: boolean;
}
interface RepairResult {
input: ElkInput;
issues: GraphIssue[];
}
function makeIssue(
level: GraphIssue["level"],
category: string,
message: string,
): GraphIssue {
return { level, category, message };
}
function maybeThrow(strict: boolean | undefined, issue: GraphIssue): void {
if (strict) throw new Error(`[ELK repair] ${issue.level}: ${issue.message}`);
}
export function repairElkInput(
input: ElkInput,
opts: RepairOptions = {},
): RepairResult {
const issues: GraphIssue[] = [];
const strict = opts.strict;
// 1. ensureNodeDimensions
let dimsAdded = 0;
const fillDims = (children: ElkChild[]): ElkChild[] =>
children.map((c) => {
const next: ElkChild = { ...c };
if (next.width == null || next.height == null) {
next.width = next.width ?? DEFAULT_NODE_WIDTH;
next.height = next.height ?? DEFAULT_NODE_HEIGHT;
dimsAdded++;
}
if (next.children) next.children = fillDims(next.children);
return next;
});
const childrenA = fillDims(input.children);
if (dimsAdded > 0) {
const issue = makeIssue(
"auto-corrected",
"elk-missing-dimensions",
`Set default dimensions on ${dimsAdded} node(s) missing width/height.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 2. dedupeNodeIds (per parent)
let dupesRemoved = 0;
const dedupe = (children: ElkChild[]): ElkChild[] => {
const seen = new Set<string>();
const out: ElkChild[] = [];
for (const c of children) {
if (seen.has(c.id)) {
dupesRemoved++;
continue;
}
seen.add(c.id);
out.push({
...c,
children: c.children ? dedupe(c.children) : undefined,
});
}
return out;
};
const childrenB = dedupe(childrenA);
if (dupesRemoved > 0) {
const issue = makeIssue(
"auto-corrected",
"elk-duplicate-id",
`Removed ${dupesRemoved} duplicate child id(s).`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 3. dropOrphanChildren — children whose parentId references nonexistent parent
const allIds = new Set<string>();
const walk = (children: ElkChild[]) => {
for (const c of children) {
allIds.add(c.id);
if (c.children) walk(c.children);
}
};
walk(childrenB);
let orphanChildren = 0;
const childrenC = childrenB.filter((c) => {
if (c.parentId && !allIds.has(c.parentId)) {
orphanChildren++;
return false;
}
return true;
});
if (orphanChildren > 0) {
const issue = makeIssue(
"dropped",
"elk-orphan-parent",
`Dropped ${orphanChildren} child(ren) with missing parent reference.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 4. dropOrphanEdges
let orphanEdges = 0;
const edges = input.edges.filter((e) => {
const ok = e.sources.every((s) => allIds.has(s)) &&
e.targets.every((t) => allIds.has(t));
if (!ok) {
orphanEdges++;
return false;
}
return true;
});
if (orphanEdges > 0) {
const issue = makeIssue(
"dropped",
"elk-orphan-edge",
`Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
// 5. dropCircularContainment
const parentOf = new Map<string, string>();
const fillParents = (children: ElkChild[], parent?: string) => {
for (const c of children) {
if (parent) parentOf.set(c.id, parent);
if (c.children) fillParents(c.children, c.id);
}
};
fillParents(childrenC);
let cyclesRemoved = 0;
const isCyclic = (id: string): boolean => {
const seen = new Set<string>();
let cur = parentOf.get(id);
while (cur) {
if (cur === id || seen.has(cur)) return true;
seen.add(cur);
cur = parentOf.get(cur);
}
return false;
};
const stripCycles = (children: ElkChild[]): ElkChild[] =>
children
.filter((c) => {
if (isCyclic(c.id)) {
cyclesRemoved++;
return false;
}
return true;
})
.map((c) => ({
...c,
children: c.children ? stripCycles(c.children) : undefined,
}));
const childrenD = stripCycles(childrenC);
if (cyclesRemoved > 0) {
const issue = makeIssue(
"dropped",
"elk-containment-cycle",
`Dropped ${cyclesRemoved} node(s) in containment cycles.`,
);
issues.push(issue);
maybeThrow(strict, issue);
}
return {
input: { ...input, children: childrenD, edges },
issues,
};
}
const elk = new ELK();
export interface ElkLayoutOptions {
strict?: boolean;
}
export interface ElkLayoutResult {
positioned: ElkInput;
issues: GraphIssue[];
}
export async function applyElkLayout(
input: ElkInput,
opts: ElkLayoutOptions = {},
): Promise<ElkLayoutResult> {
const { input: repaired, issues } = repairElkInput(input, opts);
try {
const positioned = (await elk.layout(repaired as never)) as ElkInput;
return { positioned, issues };
} catch (err) {
const fatal: GraphIssue = {
level: "fatal",
category: "elk-layout-failed",
message:
`ELK layout failed: ${err instanceof Error ? err.message : String(err)}. ` +
`This looks like a dashboard rendering bug — please file an issue with the copied error.`,
};
if (opts.strict) throw err;
return { positioned: { ...repaired, children: [], edges: [] }, issues: [...issues, fatal] };
}
}
@@ -10,6 +10,7 @@ import {
} from "d3-force";
import type { SimulationNodeDatum, SimulationLinkDatum } from "d3-force";
import type { Node, Edge } from "@xyflow/react";
import type { ElkInput } from "./elk-layout";
export const NODE_WIDTH = 280;
export const NODE_HEIGHT = 120;
@@ -20,6 +21,11 @@ export const PORTAL_NODE_HEIGHT = 80;
/**
* Synchronous dagre layout — used for small graphs.
*
* @deprecated The dashboard's structural views all use ELK now
* (`applyElkLayout` from `./elk-layout`). This helper is kept for one
* release to allow a quick fallback if ELK has a regression. Slated for
* removal in the version after layout migration is verified stable.
*/
export function applyDagreLayout(
nodes: Node[],
@@ -182,4 +188,79 @@ export function applyForceLayout(
return { nodes: layoutedNodes, edges };
}
// ---------------------------------------------------------------------------
// ELK helpers
// ---------------------------------------------------------------------------
export const ELK_DEFAULT_LAYOUT_OPTIONS: Record<string, string> = {
algorithm: "layered",
"elk.direction": "DOWN",
"elk.layered.spacing.nodeNodeBetweenLayers": "80",
"elk.spacing.nodeNode": "60",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.compaction.postCompaction.strategy": "LEFT",
"elk.padding": "[top=40,left=20,right=20,bottom=20]",
};
export function nodesToElkInput(
nodes: Node[],
edges: Edge[],
dims: Map<string, { width: number; height: number }>,
layoutOptionsOverride?: Record<string, string>,
): ElkInput {
return {
id: "root",
layoutOptions: { ...ELK_DEFAULT_LAYOUT_OPTIONS, ...layoutOptionsOverride },
children: nodes.map((n) => {
const d = dims.get(n.id);
return {
id: n.id,
width: d?.width ?? NODE_WIDTH,
height: d?.height ?? NODE_HEIGHT,
};
}),
edges: edges.map((e, i) => ({
id: e.id ?? `e${i}`,
sources: [String(e.source)],
targets: [String(e.target)],
})),
};
}
export function mergeElkPositions<T extends Node>(
nodes: T[],
positioned: ElkInput,
): T[] {
const positionedMap = new Map<
string,
{ x: number; y: number; width?: number; height?: number }
>();
for (const c of positioned.children ?? []) {
positionedMap.set(c.id, {
x: c.x ?? 0,
y: c.y ?? 0,
width: c.width,
height: c.height,
});
}
return nodes.map((n) => {
const merged = positionedMap.get(n.id);
if (!merged) {
return {
...n,
position: n.position ?? { x: 0, y: 0 },
};
}
// Propagate width/height for container nodes so a tick-driven
// Stage 1 re-layout (Task 15) can resize the visible atom to match
// the actual Stage 2 footprint. ELK echoes back the same width/height
// we passed in for non-container nodes, so this is a no-op for them.
return {
...n,
position: { x: merged.x, y: merged.y },
...(merged.width != null ? { width: merged.width } : {}),
...(merged.height != null ? { height: merged.height } : {}),
};
});
}
@@ -0,0 +1,47 @@
import Graph from "graphology";
import louvain from "graphology-communities-louvain";
import type { GraphEdge } from "@understand-anything/core/types";
/**
* Run Louvain community detection over the provided node set and the
* subset of edges whose endpoints are both in the set. Returns a map of
* nodeId → communityId.
*
* graphology-communities-louvain v2 already gives each disconnected node
* its own community id, but the contract isn't documented. The
* post-Louvain reassignment loop below is defensive: if a future version
* starts returning -1 (or omits a node, which the `?? -1` catches) for
* unmatched nodes, we'll still hand back unique ids rather than letting
* them collapse into a single cluster.
*/
export function detectCommunities(
nodeIds: string[],
edges: GraphEdge[],
): Map<string, number> {
const ids = new Set(nodeIds);
const g = new Graph({ type: "undirected", multi: false });
for (const id of nodeIds) g.addNode(id);
for (const e of edges) {
if (!ids.has(e.source) || !ids.has(e.target)) continue;
if (e.source === e.target) continue;
if (g.hasEdge(e.source, e.target)) continue;
g.addEdge(e.source, e.target);
}
// graphology-communities-louvain returns Record<nodeId, communityId>
const result = louvain(g) as Record<string, number>;
const map = new Map<string, number>();
for (const id of nodeIds) {
map.set(id, result[id] ?? -1);
}
// Defensive: reassign any -1 sentinels to unique ids past the max.
// See the JSDoc on detectCommunities for why this is kept despite the
// current library already producing unique ids for disconnected nodes.
let next =
Math.max(...Array.from(map.values()).filter((v) => v >= 0), -1) + 1;
for (const [id, c] of map) {
if (c === -1) {
map.set(id, next++);
}
}
return map;
}
@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -176,6 +177,11 @@ function readSourceFile(url: URL) {
}
export default defineConfig({
test: {
environment: "node",
include: ["src/**/__tests__/**/*.test.ts"],
},
// FIX 1 — bind only to localhost, not 0.0.0.0
// This blocks access from any other device on the same LAN / WiFi.
server: {
@@ -201,6 +207,10 @@ export default defineConfig({
return "react-vendor";
}
if (id.includes("node_modules/@xyflow/")) return "xyflow";
// ELK is ~1.6MB raw — split into its own chunk so it doesn't
// bloat the main bundle. graphology is similarly large.
if (id.includes("node_modules/elkjs/")) return "elk";
if (id.includes("node_modules/graphology")) return "graphology";
if (
id.includes("node_modules/@dagrejs/") ||
id.includes("node_modules/d3-force/")