Skip to content

Feat/cluster levels#15

Merged
ThHanke merged 35 commits into
mainfrom
feat/cluster-levels
Jun 12, 2026
Merged

Feat/cluster levels#15
ThHanke merged 35 commits into
mainfrom
feat/cluster-levels

Conversation

@ThHanke

@ThHanke ThHanke commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Summary

Hierarchical cluster-level folding with incremental layout and fixed-node support.

Cluster-level navigation

  • Three-level fold system: L1 (all entities) → L2 (structural groups) → L3 (algorithmic clusters)
  • Level pagination widget replaces fold buttons in the toolbar
  • Animated collapse/expand transitions between levels
  • Precomputed silent layout positions used on level transitions — no redundant layout re-runs

Incremental layout with fixed-node support

  • New bounding-box phantom strategy: replaces fixed nodes with a single phantom, runs the layout engine, then translates free nodes back relative to the original fixed-node bounding box
  • LayoutNode.fixed support across Dagre, ELK, and Cola engines
  • runSilentLayout pre-computes L1/L2 positions in the background on initial load

Structural group rerooting

  • rerootForCanvas(): when a structural group root (e.g. rdfs:Resource, owl:Thing) isn't on the canvas, walks the subclass tree to find on-canvas descendants as sub-group roots
  • computeStructuralGroups returns StructuralGroupResult with both groupMap and subclassParent for downstream rerooting

Console noise cleanup

  • All diagnostic console.log/debug/info messages prefixed with [VG_*] so they're gated behind config.debugAll
  • Covers clustering, layout, relay bridge, search, UI, settings, and workflow modules

Other fixes

  • Classify OWL axioms and property characteristics as TBox
  • Lazy-load p-plan and qudt ontologies on workflow drop
  • Upgrade rdf-reasoner-konclude to 0.3.2

Test plan

  • 332 unit tests pass (vitest)
  • Type check clean (tsc --noEmit)

Thomas Hanke added 30 commits June 11, 2026 09:16
Typed blank nodes (owl:Restriction, owl:Class) were always landing
in ABox because typeMap excluded urn:vg:bnode:* subjects. Untyped
blank nodes (RDF list nodes) also defaulted to ABox via matchesViewMode([]).

Two fixes in N3DataProvider:
- Remove SKOLEM_PREFIX exclusion from typeMap guard so typed blank
  nodes are classified by their actual rdf:type.
- Add fixed-point propagation after each addGraph call: walks the
  batch quads and assigns bNodeViewMap entries for untyped blank-node
  objects, inheriting view from their typed (or already-resolved) subject.
  Terminates in 2-3 passes for typical OWL list depths.

filterByViewMode now checks bNodeViewMap for untyped blank nodes
(fallback: tbox). bNodeViewMap cleared in clear().
Adds computeStructuralGroups() which builds a memberIri→groupRootIri map
from raw quads: walks rdfs:subClassOf chains to their transitive root and
maps every OWL-collection cons-cell to its owning class. Includes Vitest
suite (11 tests) covering empty input, simple/chained/cyclic subclass
hierarchies, collection cons-cells, and mixed scenarios.
- Memoize findRoot() with rootCache Map to avoid O(n²) complexity for deep subclass chains
- Cache cycle detection results (undefined) to prevent recalculation
- Remove unused StructuralGroupMap type import from test file
- Add TODO comment for potential rdfs:subPropertyOf extension

All 11 tests passing in structuralGroups.test.ts
Accumulates all RDF quads (including ontology graph) in allQuadsRef,
computes structural groups before L3 clustering runs so that subclass
chains and OWL collection cons-cells are folded on first render.
…, optimize quad accumulation

Fix 1: Add alreadyGrouped set in applyL2Fold to prevent attempting to group
an element that was already grouped in a prior iteration of the rootToMembers loop,
mirroring the pattern used in applyInitialGrouping.

Fix 2: Add TODO comment documenting Step 5 (wire isL2Folded to appConfigStore
so TopBar level badge can read it).

Fix 3: Replace quad accumulation concat() call with push(...quads) to avoid
creating unnecessary intermediate arrays.
Add `updateL2GroupsForNewElements` which runs `computeStructuralGroups`
against the accumulated quad store and reforms EntityGroups when newly
arrived IRIs belong to a structural root (subclass chain or OWL
collection). Only fires on incremental adds; the full-refresh path
continues to use `applyL2Fold`.
…-mode guard

Move structural group computation out of ReactodiaCanvas (removing allQuadsRef)
into N3DataProvider.getStructuralGroups() with a lazily-invalidated cache. Widen
computeStructuralGroups() to accept a duck-typed RdfQuadLike interface so the
provider's raw dataset quads flow in without conversion. Guard applyL2Fold and
updateL2GroupsForNewElements against unpersisted authoring-state nodes (entityAdd)
so draft canvas elements are never folded into structural groups before they reach
the RDF store.
- Add structuralGroupCache invalidation in N3DataProvider.clear()
- Remove unnecessary (ctx as any) cast in getUnpersistedIris, rely on WorkspaceContext.editor type
- Use newIriSet correctly in updateL2GroupsForNewElements instead of dead suppression
…karound

Worker stderr now routed via postMessage in the published dist.
Bump worker cache-buster to v=20.
Add a non-interactive level badge (L3/L2/∅) and Fold/Unfold L2 button
to the clustering group, plus a dedicated Fold/Unfold L1 button group
for annotation-property expansion. Wire handleFoldL2, handleUnfoldL2,
handleFoldL1, handleUnfoldL1 handlers in ReactodiaCanvas.
Standalone loop missed grouped elements. Walk EntityGroup.items too.
- Track isL3Clustered separately from isClustered so badge shows L2
  after structural fold (not L3 which fired via syncClustered)
- Pass isL3Clustered to TopBar badge; isClustered kept for button states
- Remove spurious setIsClustered from handleFoldL2
- Set isL2Folded in incremental path when groupMap has entries
- Persist isL3Clustered + isL2Folded in SavedClusterState for view-mode switch restore
Add L1/L2/L3 fold level description and renumber toolbar items.
…widget

[◄] [L2] [►] replaces Cluster/ExpandAll/FoldL2/FoldL1 buttons.
Levels are cumulative: L1=annotations, L2=structural groups, L3=clusters.
L3 only reachable after clustering ran. ► at L2 triggers clustering.
Adds isL1Folded state and applies L1 fold at initial canvas load.
- Badge shows n/max style (1/2, 2/2) instead of L2; uses inline-flex for height match
- Default fold = L1 on small graphs, L3 on big graphs (no auto-L2)
- TBox first-time init sets isL1Folded=true to match Reactodia default collapsed state
- Pass maxFoldLevel to TopBar (3 after L3 ran, 2 otherwise)
- Snapshot positions before L2 fold (preL2Positions); restore on L2→L1 level-down
- Restore preCluster positions on L3→L2 level-down before re-applying L2 fold
- Clear preL2Positions on view-mode switch and clearData
- Badge: use reactodia-btn reactodia-btn-default disabled (no glass-btn), matches toolbar pattern
- updateL2GroupsForNewElements returns group count; setIsL2Folded only when groups actually formed
- Fixes 2/2 badge on graphs with no structural hierarchy
…vel persists on level-down

isL3Clustered is now a session flag (set once, never cleared on level-down) that
keeps maxFoldLevel=3 after clustering has run. isL3Active tracks whether L3 groups
are currently on canvas and drives currentLevel. Level-down L3→L2 clears only
isL3Active, leaving maxFoldLevel=3 intact.

Also add swrl:body / swrl:head to OWL collection predicates so SWRL rule atom
lists fold into structural groups.
Remove p-plan and qudt from the default auto-loaded ontology set.
Instead, ensureWorkflowOntologies() checks and loads them on demand
immediately before workflow template instantiation (both drag-drop and
touch-drop paths), so they are only fetched when actually needed.
…, save-before-ungroup

- Extract `initializeCanvas()` — single entry point for small-graph
  (schedule layout) and large-graph (L3 cluster + layout + silent worker)
  paths, covering both initial ABox load and first TBox switch.
- Fix silent layout worker: now fires on initial ABox load (was only
  triggered on TBox switch). Positions computed for L1 entities and L2
  structural groups after L3 init layout completes.
- ClusterLevelManager: add `saveCurrentSetup()`, `setPrecomputedPositions()`,
  `_l2Setup` storage; `levelDown()` saves current canvas group state before
  ungrouping so manual cluster edits survive round-trips.
- `levelDown()` now triggers `performLayout` on the resulting element set,
  mirroring the `levelUp()` pattern.
- Remove all position-snapshot machinery (`_levelPositions`, scattered
  snapshot/restore calls).
…xpand

Snapshot entity/group positions before the collapse animation moves them.
On expand (level-down), animate back to those positions via animateGraph
when autoApplyLayout is disabled — avoids nodes piling up at group center.

- _savedL1Positions: snapshotted in _animateCollapseL1toL2 before entities move
- _savedL2Positions: snapshotted in _animateCollapseL2toL3 before groups move
- _animateToLevel1/2: use saved positions first, precomputed as fallback
- animateExpandPositions(): public method called from handleLevelDown
- Both fields cleared in reset()
…coped L2 roots

Three bugs in scheduleSilentLayoutWorker caused L2 nodes to lay out in a
vertical column instead of matching the 2D spatial spread of L3 clusters:

1. getStructuralGroups() covers the full TBox ontology (317 roots for
   pmdco-full.owl) but only ~111 are actual canvas entities. Including all
   317 phantom class IRIs flooded the layout with unanchored nodes.

2. Anchor lookup used entityToAnchorIri.get(rootIri) but root IRIs are
   ontology class IRIs, not entity IRIs in clusterEntries — returning
   undefined for almost every L2 root.

3. getL3Seed(rootIri) also returned undefined for class IRIs since both
   standaloneL3Pos and entityToClusterPos are keyed by entity IRIs.

Fix: filter structural group map to canvas-entity-relevant roots only
(entityMemberToRoot / canvasRootIrisSet), build rootToAnchorIri via
member entities, seed L2 roots from member entity cluster positions.
…ve them on collapse

_animateToLevel2 only positioned EntityGroup elements (structural group roots),
leaving standalone EntityElements that emerged from L3 ungrouping at their
ungrouped positions (L3 group centroid).

Also _savedL2Positions only captured EntityGroup positions, so L2→L3→L2
round-trips lost standalone entity positions.

Fixes:
- setPrecomputedPositions now receives l2AllPositions (group roots + standalones)
- _animateToLevel2 handles both EntityGroup and EntityElement instances
- _animateCollapseL2toL3 snapshot captures standalone EntityElements too
…oots as L2 standalones

A blank node (or any entity) that is a structural group MEMBER in the RDF
ontology but whose ROOT class is not on the current canvas was incorrectly
excluded from both l2GroupRootIris and l2StandaloneIris — making it
invisible to the L2 layout computation entirely.

applyL2Fold skips groups whose root element is not found on canvas
(rootEl === undefined), so those members surface as standalone EntityElements
at L2. The layout worker must treat them as L2 standalones: fixed at their
L3 position (if they were standalone at L3) or free near their cluster anchor
(if they were inside an L3 group).

Fixes:
- canvasRootIrisSet now only includes roots that are actual canvas entities
- l2StandaloneIris filter extended: also includes members whose root is not
  on canvas (identified via allEntityIriSet membership check)
…n not own L3 position

A structural group root that was also a standalone EntityElement at L3
was getting fixed in the L2 silent layout at one of its member entities'
positions rather than its own. This happened because l2RootSeeds (derived
from member entity positions) took priority over getL3Seed when building
l2Seeds. Reverse the priority so an entity's own L3 position always wins
over the member-derived fallback.
… run

LayoutNode.fixed is declared in Reactodia's type but ignored by the
built-in layout engine at runtime. After runSilentLayout returns, manually
restore fixed nodes to their seed positions so they actually stay put.
Also fix the readonly index signature error when building LayoutGraph.nodes.
…ositions

Nodes that were inside L3 EntityGroups (not standalone EntityElements) had
no entry in standaloneL3Pos, so they were treated as free nodes in the L2
silent layout. The layout engine scattered them to a vertical column far from
their cluster region.

Fix: also add nodes to l2Fixed when entityToClusterPos has their IRI — they
were inside an L3 cluster and should stay at that cluster's position during
L2 layout.

Also removes debug logging added during investigation.
… and anchor nodes

Root cause: entityToAnchorIri was keyed by entity IRIs but l2VisibleIris iterated
structural group root IRIs (ontology class IRIs) — anchor lookup always returned
undefined. Group root seeds used getL3Seed(rootIri) which also returned undefined
for class IRIs. Both bugs caused all L2 group roots to start at (0,0) and Dagre
placed them in a vertical column.

Fix:
- rootToAnchorIri now built via member entities (entity→root→anchor chain)
- l2RootSeeds built from member entities' L3 positions (two-pass: standalone-L3
  members preferred over cluster centroids)
- All l2VisibleIris with known seeds are marked fixed, so seed positions are
  preserved regardless of the active layout engine (Dagre ignores seeds for free
  nodes; other engines like ELK force/stress use seeds as starting positions)
- runSilentLayout(layoutFn, ...) is always called so the user's selected engine
  (Dagre, ELK, Cola) is respected
- L1 layout: l2AllPositions become fixed anchors; member entities are free with
  anchor edges to their roots — force-directed engines distribute them spatially
- Removed debug logging from _animateToLevel2
…ne actually lays out

Two bugs made every node fixed, so runSilentLayout called the engine but its output
was completely discarded — all positions came from seeds.

L2: l2Fixed.add(iri) was called for all l2VisibleIris with seeds (i.e. everything).
Only standalones have exact L3 positions worth fixing; group roots should be free
so the engine positions them relative to phantom cluster anchors.

L1: l1Fixed was built from l2AllPositions.keys() which includes all entity members
(added by the fallback loop). Should only contain l2VisibleIris (roots + standalones)
so member entities remain free for the engine to distribute around fixed roots.
Thomas Hanke added 5 commits June 12, 2026 12:54
…ocess override

LayoutNode.fixed is ignored at runtime by all engines (Dagre, ELK, Cola). The
phantom-anchor approach failed because engines repositioned "fixed" anchor nodes
freely, making free-node positions relative to wrong anchor positions.

New approach — incremental layout without fixed nodes:

L2: skip engine entirely. Seeds (cluster centroids / L3 positions) ARE the correct
L2 positions. No phantom anchor nodes needed.

L1: run engine with all entities + seeds + anchor edges (member→root_entity).
Root entities (e.g. pmdco:Material) are actual canvas EntityElements — they started
the group in applyL2Fold — so they are valid anchor targets in allEntityIris.
Post-process: override roots/standalones with their seeds; use engine result for
member entities. Force-directed engines (ELK force/stress, Cola) distribute members
around root-entity anchors via spring edges. Dagre still columns members (its
limitation), but roots are correctly positioned by the seed override.
Fixed nodes are now excluded from the engine entirely — they return their seed
positions directly. Free nodes are passed to the engine with seeds as initial
bounds. This works with any layout engine (Dagre, ELK, Cola) without needing
native fixed-node support, phantom anchors, post-processing, or anchor edges.

L1 silent layout: roots/standalones are fixed at their L2 (cluster centroid)
positions; member entities are free, seeded at their root's cluster centroid.
Force-directed engines spread members from there; Dagre lays them out as a
subgraph of the ontology relation edges (its column behaviour is a known limit).
Engine wrappers (dagre, ELK, cola) now handle fixed nodes via
bounding-box phantom substitution — free nodes are laid out around a
single phantom representing all fixed nodes, then translated back to
the correct spatial relationship.

- Extract dagreCore.ts pure layout function from worker
- Add fixedPhantom.ts: substituteFixedWithPhantom + restoreFixed
- Wrap createDagreLayout/createElkLayout with phantom for fixed nodes
- Simplify runSilentLayout to single-pass (all nodes + fixed flag)
- Simplify scheduleSilentLayoutWorker from ~190 to ~70 lines
- levelDown prefers precomputed positions over fresh performLayout
- Add overlap diagnostics to level transitions
- Add withFixedNodes wrapper + fixedIris param to MCP layout tool
- 47 layout tests covering dagre/ELK/cola fixed-node scenarios
ALL_TBOX_TYPES was missing 12 types that SCHEMA_TBOX_CLASS_IRIS already
covered, plus owl:Axiom. Entities with these types (and their related
bnodes) fell through classifyEntityView() default into ABox.
…root off-canvas groups

Prefix all diagnostic console.log/debug/info messages with [VG_*] so
they are suppressed unless config.debugAll is enabled. Covers clustering,
layout, relay bridge, search, UI, settings, and workflow modules.

Add rerootForCanvas() to structuralGroups — when a structural group root
(e.g. rdfs:Resource) isn't on canvas, walk the subclass tree to find
on-canvas descendants as sub-group roots.

Update handleLevelDown to prefer precomputed positions over performLayout.
Fix diagOverlaps to defer via requestAnimationFrame and skip unsized elements.
@ThHanke ThHanke force-pushed the feat/cluster-levels branch from 4ef15a8 to 78207b4 Compare June 12, 2026 13:49
@ThHanke ThHanke merged commit 1c990e9 into main Jun 12, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant