You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GraphCanvas — opt-in nested bubble-packing layout: layout="force-clustered"
groups nodes into clusters (and clusters of clusters) derived purely from
edge structure via deterministic Louvain modularity clustering, packs
them with d3-hierarchy, then runs the existing spring simulation within
each bubble. Trades a larger canvas for a less even, more legible node
distribution. Renders an additional .graph-cluster-boundary circle per
top-level cluster (see the new showClusterBoundaries prop below). New exports: clusteredForceLayout, ClusteredLayoutOptions, ClusteredLayoutResult, louvainCluster, ClusterEdge, ClusterTreeNode, LouvainOptions, packClusters, PackedCircle, PackOptions. New dependency: d3-hierarchy.
GraphCanvas — onBackgroundClick prop fires the typical "click empty
space to deselect" gesture: a press-and-release within a few px of each
other on genuine canvas background (not a node, edge, or opted-out
element). A real pan drag never fires it, and it works independently of usePanZoom's own drag tracking so it still works while pan/zoom is locked.
DetailDrawer — an auto-hiding overlay panel: floats over its nearest position: relative ancestor (a GraphCanvas, for instance) instead of
reserving dedicated layout space, expanding only once open and its
children are truthy. Translucent/blurred background, no border by default,
and a left-edge resize handle that highlights in the accent color on hover
so users can tell it's resizable without a visible seam at rest. New
exports: DetailDrawer, DetailDrawerProps.
GraphToolbar — a built-in zoom/pan/lock control cluster for GraphCanvas, shown by default (showToolbar, default true) in any of
the 4 corners or 4 edge-centers (toolbarPosition, default 'bottom-right'): zoom in, zoom out, zoom to fit, and lock pan/zoom (backed
by usePanZoom's new locked option, which gates wheel/drag/keyboard
input but not imperative setZoom/setPan/zoomToFit calls). New
exports: GraphToolbar, GraphToolbarProps, GraphToolbarPosition.
galaxyLayout — opt-in nodeMargin option, and GraphCanvas's
existing nodeMargin prop (previously layout="force" only) now applies
to layout="galaxy" and layout="force-clustered" too. Padding is applied
only during galaxy's settle-cycle separation passes, never its final
cleanup pass, so the "zero literal overlap" guarantee still holds
regardless of the margin value. Defaults to 0 (unpadded) for "galaxy"
specifically — unlike "force"/"force-clustered", which default to each
node's own rendered width — since galaxy's radial placement already spaces
most layouts generously and defaulting padding on would shift every
existing galaxy layout's node positions for what's a narrow, opt-in-worthy
edge case (verified on a real 57-node/4-level hierarchy with heavy size
variance: the tightest sibling pairs went from under 1px apart to 8px+
with nodeMargin={12}, zero overlaps either way).
GraphCanvas — showClusterBoundaries prop (default true) and
boundary circles now also render under layout="galaxy", not just "force-clustered" — one per independent root subtree. A root with
children delegates its group-headship down to each of its own children
rather than drawing one circle around its entire subtree, so a single deep
hierarchy (e.g. one root with a dozen direct branches) still reads as
several legible groups instead of one all-encompassing circle; a childless
root (an orphan, or a node reachable only via a relational edge) keeps its
own single-node boundary. Both engines share the same underlying helper,
newly exported as boundingCirclesByGroup.
GraphCanvas — non-structural edges (per isStructuralEdge) are now
hidden entirely by default instead of dimmed to a low opacity — line,
arrow marker, and label all disappear, and the edge isn't clickable while
hidden. Hovering/selecting a touching node, or showAllRelations, still
reveals them at full opacity, same as before. Removes the internal NON_STRUCTURAL_DIM_OPACITY constant this replaces.
GraphToolbar — a fifth button toggles the canvas container in and out
of the Fullscreen API. Tracks the platform's own fullscreen state (not
just its own clicks), so Esc or any other exit path updates the icon too.
Entering fullscreen also zooms/pans to fit the current nodes once the
container has actually resized to its new (larger) fullscreen dimensions
— a previously zoomed-in view no longer stays zoomed into a random corner
when the canvas suddenly has much more room. Skipped while pan/zoom is
locked, same as every other automatic viewport change in GraphCanvas.
galaxyLayout — top-level group boundary circles (the same ones showClusterBoundaries renders) no longer overlap each other, on by
default. After the existing per-node settle cycles, a macro separation
pass treats each group's boundary circle as a single pseudo-node, pushes
apart any that overlap, then rigidly shifts each group's members by the
resulting delta — preserving each group's already-correct internal
arrangement. Computed unconditionally, independent of whether boundaries
are actually rendered. New GalaxyLayoutOptions.separateGroups option
(default true) opts back out. New export: galaxyGroupHeads (from utils/graphHierarchy) — the "a root with children delegates
group-headship to each of its own children" rule, now shared between this
pass and GraphCanvas's own boundary rendering so the two always agree on
what a "group" is.
GraphCanvas — opt-in live simulation for layout="galaxy": instead
of a one-shot computed-then-frozen layout, run a continuous elastic
simulation. Drag any node ("sun") and its descendants follow in real time,
spring-pulled toward their algorithmic home position with collision
detection keeping siblings apart throughout; releasing a node leaves it
exactly where it was dropped, permanently pinned, same as a static-mode
drag. A sixth GraphToolbar button (galaxy layout only) toggles it —
purely internal state, no controlling prop. The simulation self-idles
(stops scheduling animation frames) once movement drops below a small
threshold for about half a second, and wakes on the next drag. New
exports: galaxySimulationStep (from utils/galaxyLayout, the
single-step primitive galaxyLayout itself now loops over), useGalaxySimulation (new hook, hooks/useGalaxySimulation), and a new orbit icon. GraphCanvasContextValue gains layout (read-only), liveSimulation, and setLiveSimulation.
New exports on GraphCanvasContextValue: isFullscreen, toggleFullscreen. New icons: fullscreen, fullscreenExit.
galaxyLayout — aspect-ratio-aware placement: a new GalaxyLayoutOptions.aspectRatio option (container width/height) warps
every orbital ring from a circle into an ellipse so the layout's overall
shape leans toward the container's own proportions, instead of always
producing a footprint that a wide-short or tall-narrow container then has
to letterbox — the established technique radial tree layouts (e.g.
Perforce's IlvTree, the d3-radial library) use for the same problem.
The correction is computed relative to the tree's own natural, unwarped
shape (measured with a first placement pass) rather than relative to a
hypothetical circle — a tree with a long single-child chain, in
particular, is often already far from circular on its own, and correcting
against the wrong baseline can misjudge the needed warp badly enough to
move the result in the wrong direction entirely. Both the target ratio and
the resulting correction factor are clamped to [1/8, 8]; the correction
is then bounded, not by a fixed damping constant, but by a binary search
over how strong a correction the graph can actually take — separationPass's
collision floor resists compression but not expansion, so an
undamped correction doesn't trade width for height the way it looks like
it should, it mostly just adds width, and how much depends heavily on the
graph's own structure (a single fixed damping constant measurably
under-corrected some real graphs while over-inflating others). The search
finds the strongest correction whose actual rendered area — measured via
one real settle step per trial, not just the raw elliptical placement,
which meaningfully overestimates distortion for graphs with several small
sibling groups — stays within roughly double the natural (unwarped) area.
New export resolveAspectRatioScale performs this resolution and is what galaxyLayout calls once per invocation (rather than once per settle
cycle) and what a live-simulation caller driving galaxySimulationStep
directly should call once per structural/target-ratio change (via its own
new resolvedAspectScale option) rather than paying the search's cost on
every animation frame. Omitted aspectRatio (or exactly 1) reproduces
prior circular placement bit-for-bit. GraphCanvas feeds its own real
container aspect ratio in automatically for layout="galaxy" (rounded to
the nearest 5% so sub-pixel resize noise doesn't churn it) and pre-resolves
it the same way for its own live-simulation loop, and redraws the layout —
respecting a currently-pinned/dropped node's position exactly — whenever
that ratio changes meaningfully and pan/zoom isn't locked; live-simulation
mode eases toward a new shape smoothly via its existing spring mechanism
instead of snapping. No new GraphCanvas prop — the existing lock toggle
is the complete opt-out. Known limitation: even the strongest correction
the search allows has a practical ceiling — separationPass refuses to
compress nodes past their own rendered size — so a layout dominated by one
long chain won't ever fully reach an extreme target ratio; the area-growth
bound means the drawn result deliberately stops short of that ceiling
too, in exchange for not ballooning the layout's absolute size to get
there.
Fixed
GraphCanvas: the viewport's visual center didn't survive a container
resize — most noticeably, toggling Fullscreen would leave pan/zoom at the
same raw values even though the container had just jumped to a completely
different size, snapping whatever was centered on screen off to one side.
A resize (Fullscreen, window resize, a host layout change) now re-anchors
pan so the same point stays centered.
galaxyLayout/boundingCirclesByGroup: a group's boundary circle
(showClusterBoundaries) was centered on the plain centroid of its
members' positions, not on the group's own "sun" node — fine for a
symmetric subtree, but a deep, lopsided one (a long one-directional chain)
could drift the centroid well away from the sun itself, drawing the circle
over empty space and letting members spill past its edge. New boundingCirclesByGroup(..., anchorToHead) parameter (GraphCanvas's
galaxy branch passes true) centers each circle on the group head's own
position instead; force-clustered's Louvain clusters, which have no single
natural anchor, keep the centroid.
GraphCanvas live-simulation mode: showClusterBoundaries's rendered
circles were computed once by the static one-shot layout effect and then
frozen — while live mode ran, dragging a "sun" moved its own boundary
circle not at all, even though its descendants correctly followed in real
time. The live tick now recomputes cluster boundaries too (skipped
entirely when boundaries aren't shown, to avoid the extra per-frame work).
galaxySimulationStep: group separation eroded within the first few
animation frames of live mode, even with zero user interaction — every
tick's homeStrength nudge pulled each unpinned node straight back toward
a raw, un-separated home position, since only galaxyLayout's one-shot
final pass applied separateGroups as a single rigid shift on top of
already-settled positions; nothing kept correcting for it tick after tick. galaxySimulationStep now group-separates its own per-tick home
positions too (the group-boundary macro pass only — the individual-node
safety-net cleanup galaxyLayout's own final pass also runs stays a
one-shot-only cost), so live mode holds the same non-overlapping bubbles
its seed layout started with instead of drifting back to full overlap
within about a second of idling.
galaxySimulationStep: dragging a "sun" close enough to another
group's territory left that OTHER group's real nodes sitting exactly
where they were, while the dragged group's honestly-reaching boundary
circle simply grew to enclose them — reading as "I dragged a node into
another group" even though membership never changed (the fix two commits
ago kept the algorithmic target layout separated, but nothing reacted
to a group's actual, currently-dragged footprint growing into a
neighbor's actual space). Live ticks now push the other group's real
positions out of the way instead, the same collision-avoidance reaction
individual nodes already get — whichever group contains a currently
pinned/dragged node stays exactly where the user put it; every other
group yields.
usePanZoom: wheel-zoom, drag-to-pan, and keyboard pan/zoom shared a
single requestAnimationFrame ref — if two of those gestures' handlers
both fired before the frame committed (plausible any time a trackpad
pinch and a stray pointer event land close together), whichever ran last
would cancel the others' already-scheduled update before it committed,
silently dropping that gesture's change for the frame. Each now gets its
own rAF ref so one input source can no longer clobber another's pending
update.
GraphCanvas: mouse-wheel zoom could occasionally jump to an unrelated
pan/zoom mid-gesture — usePanZoom's wheel listener calls preventDefault() to fully own zoom/pan, but that only stops the
browser's own native handling if JS wins the race for that event; under
any main-thread load the browser can occasionally act on a wheel event
first, firing its own native page zoom/scroll for that one event. Adds touch-action: none to .graph-canvas, opting it out of the browser's
native gesture handling entirely instead of just trying to out-race it —
the standard fix for a custom-zoom surface (same guidance d3-zoom and
Mapbox GL give for their own interactive elements).
galaxyLayout: root-ring placement sized a root's distance from the
shared hub off only its own (tiny) radius, with no regard for how far its
own subtree would go on to spread — a lone orphan and the root of a deep,
many-node chain started at essentially the same tiny distance. In
practice this read as a large subtree's own root landing in the shared
hub's crowded middle, visually disconnected from (and often overlapping)
its own far-flung descendants, and sitting inside whatever unrelated
neighboring group happened to also be near that hub — the GALAXY_DEMO_NODES
dataset's "organism" (parent of an entire 9-node biology tree) was the
case that surfaced it. Root-ring seeding now adds each root's own
subtree reach (the same recursive orbital-distance math place() already
uses, run once bottom-up first) on top of its existing own-radius term —
a childless root's placement is unchanged; a root with descendants now
seeds proportionally farther out, landing on the same radial line as the
rest of its subtree instead of near the hub.