mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
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:
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.
|
||||
Generated
+106
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
+79
@@ -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 };
|
||||
}
|
||||
Binary file not shown.
@@ -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/")
|
||||
|
||||
Reference in New Issue
Block a user