Skip to content

hierarchy: port d3-hierarchy layouts (tree, cluster, pack, partition, stratify) - #592

Draft
cedrickcooke wants to merge 7 commits into
mainfrom
cedrickc/cursor/expand-hierarchy-layouts-25a8
Draft

hierarchy: port d3-hierarchy layouts (tree, cluster, pack, partition, stratify)#592
cedrickcooke wants to merge 7 commits into
mainfrom
cedrickc/cursor/expand-hierarchy-layouts-25a8

Conversation

@cedrickcooke

Copy link
Copy Markdown
Collaborator

Summary

Expands the hierarchy module with the remaining d3-hierarchy layouts, following the existing generic-layout (Node<T, L>) style used by treemap. Only the hierarchy module is touched.

New capabilities:

  • tree (com.juul.krayon.hierarchy.tree) — Reingold–Tilford "tidy" tree via Buchheim et al.'s linear-time algorithm. Configurable size/nodeSize and separation; attaches a Point(x, y) layout.
  • cluster (…hierarchy.cluster) — dendrogram with leaves aligned at equal depth. size/nodeSize/separation; attaches Point.
  • pack (…hierarchy.pack) — circle packing (front-chain sibling packing + Welzl enclosing circle). radius/size/padding; attaches Circle(x, y, radius).
  • partition (…hierarchy.partition) — icicle/sunburst rectangles. size/padding/round; attaches Cell(x0, y0, x1, y1).
  • stratify (…hierarchy.stratify) — builds a hierarchy from a flat list via id/parentId accessors, with validation (single root, missing/ambiguous parents, cycles).
  • Node helpersleaves(), links() (+ Link), path(target), descendants(), copy(), added additively alongside existing traversals. A shared Point type is introduced for tree/cluster.

Layouts follow the treemap convention: a configurable layout class plus a Node<T, *>.layoutWith(...) extension that mutates and returns the same nodes with the layout attached.

Notes

  • Double for pack geometry: circle packing is ported using Double internally (public Circle remains Float). Float precision caused the Welzl enclose-basis search to fail (Unable to extend enclosing basis); Double also reproduces d3's exact coordinates.
  • ancestors() bug fix: the existing ancestors() advanced the chain with current = parent (always the receiver's parent) instead of current = current.parent, yielding an infinite sequence for depth > 1. path() depends on a correct ancestor walk, so this one-line bug is fixed.
  • count() semantics: Krayon's count() weights every node (not only leaves as d3 does), so partition/pack fixtures use sum { value } with explicit leaf weights, which is identical between Krayon and d3.

Testing

Common tests (kotlin.test) verify each layout against exact d3-hierarchy@3 values on small fixtures (tree/cluster coordinates, pack radii + containment, partition cells, stratify structure, node helpers).

  • ./gradlew :hierarchy:jvmTest — 24 tests pass.
  • ./gradlew :hierarchy:lintKotlin — clean.
  • ./gradlew :hierarchy:jvmApiCheck :hierarchy:klibApiCheck — pass; API dumps regenerated and committed.

The sandbox lacks the Android SDK, so Android host tests / android API dumps could not be run here. The android API dump was kept in sync with the (identical) JVM dump.

Open in Web Open in Cursor 

cursoragent and others added 7 commits July 13, 2026 23:05
Add d3-hierarchy Node accessors additively and introduce a shared Point
layout type. Also fixes an ancestors() bug where the parent chain was not
advanced (it always re-read the receiver's parent).

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Port d3-hierarchy's tree layout using Buchheim et al.'s linear-time
algorithm. Supports size/nodeSize and configurable separation, attaching a
Point layout to each node.

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Port d3-hierarchy's cluster layout, aligning leaves at equal depth with
size/nodeSize and configurable separation.

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Port d3-hierarchy's pack layout: front-chain sibling packing plus Welzl
enclosing-circle, with radius/size/padding. Geometry runs in Double to
match d3's precision and avoid enclose-basis failures.

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Port d3-hierarchy's partition layout, subdividing width by weight per depth
band with size/padding/round, attaching a Cell (x0/y0/x1/y1) layout.

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Build a hierarchy from a flat list via id/parentId accessors, validating
single-root, missing/ambiguous parents, and cycles.

Co-authored-by: Cedrick Cooke <development@cedrickc.net>
Co-authored-by: Cedrick Cooke <development@cedrickc.net>
@cursor cursor Bot added the minor Changes that should bump the MINOR version number label Jul 13, 2026

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f941dd3. Configure here.

val k = circleOf(typedRoot).r / min(width, height)
typedRoot.traversePostOrder().forEach { node -> packChildren(node, padding, k, random, ::circleOf) }
val translation = min(width, height) / (2.0 * circleOf(typedRoot).r)
typedRoot.traversePreOrder().forEach { node -> translateChild(node, translation, ::circleOf) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pack divides by zero radius

Medium Severity

In the default Pack.layout path, translation divides by 2.0 * circleOf(typedRoot).r. If every leaf weight is zero (or packing yields a zero root radius), that denominator is zero, producing infinite or NaN coordinates and radii on all nodes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f941dd3. Configure here.

while (end !== ancestor) {
nodes.add(split, end)
end = checkNotNull(end.parent)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Path crashes unrelated nodes

Low Severity

Node.path assumes target shares a hierarchy with the receiver. If leastCommonAncestor returns null, the upward walk still uses checkNotNull(start.parent) and throws once the parent chain ends, instead of returning a result or a clear error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f941dd3. Configure here.

@github-actions

Copy link
Copy Markdown

Test Results

103 tests  +24   103 ✅ +24   3s ⏱️ -1s
 21 suites + 6     0 💤 ± 0 
 21 files   + 6     0 ❌ ± 0 

Results for commit f941dd3. ± Comparison against base commit 1589cbc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

minor Changes that should bump the MINOR version number

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants