Skip to content

fix(map): every overlap is resolved, not negotiated - #586

Open
angel-manuel wants to merge 2 commits into
devfrom
map-overlap-resolved
Open

fix(map): every overlap is resolved, not negotiated#586
angel-manuel wants to merge 2 commits into
devfrom
map-overlap-resolved

Conversation

@angel-manuel

Copy link
Copy Markdown
Contributor

Asked for after #585 landed: make #584's constraint — a non-member does not sit
inside a container — work the way #585's does, and do the same in general when
nodes are moved towards other nodes.

#585 stopped asking where containers were concerned. separateBoxes runs after
computeBoxes and translates whole clusters until no two boxes are closer than
BOX_GAP, because a force settles wherever it balances whatever pulls the other
way and a contact constraint does not. Every other overlap on the map was still
a force, and lost the same argument.

What was still being negotiated

#584's constraint. STRAY_PUSH is 6; an org-level service is held to the
shared ring by the strongest target weight on the map. They meet with the
service parked partway inside a container and stop. And the ring it is held to
clears the targets of everyone's owned services by a radial constant that
knows nothing about box padding, caption width, or the band a name chip hangs
in — so a service landing on a container is the ordinary case, not the edge one.

Node against node. The repulsion reasons in radiusOf, a circle around the
ball, while a node is the ball and the caption under it, and the caption is
the wider of the two. Siblings on one ring, whose minimum is pad + 18,
routinely have their labels lying across each other.

A folded cluster's chip was in no constraint at all.

The pass is now general

Everything it separates reduces to a rectangle, so one contact rule serves a
container, the chip a folded container leaves behind, and a node that belongs to
no open container. A container's member is deliberately not one of them: its
cluster's rectangle already encloses it, so a member intruding on another
container means the two rectangles intersect — which is said by moving both
clusters whole rather than by shearing one ball out of the fleet it is drawn
with. That also drops two things the stray force did: pushing a node hidden
inside a folded container, and pushing one member of a cluster on its own.

separateNodes is the other half, and runs before computeBoxes: a container
is built to hug its members, so a correction made after the rectangle was
measured would leave draw stroking an outline a frame behind the cluster
inside it.

Why the springs had to change too

A positional pass that fights a spring is a map that twitches forever, and that
is what this did at first. On the fixture below, chi's cluster never stopped
moving: 2.5px of travel with 0.03px to show for it, which is the signature of a
correction being made and undone. Three changes make the forces want what the
pass enforces, so that by the time it runs there is nothing left to correct.

clearance — the distance along a given direction at which two nodes clear each
other — is now the floor under both fixed-distance springs and under the
repulsion's minimum. A spring asking for a distance at which its pair overlaps
is one the pass argues with every frame. This is #583's reasoning a level down:
a container has to be big enough for its own name, and a ring has to be wide
enough for the names on it.

restLength is the rest of it. A spoke can be packed tighter than its captions
fit — a user, an agent, a subagent and a service on radii chosen without
reference to text — and no per-pair floor fixes that, because the pair that
overlaps is not the pair the spring joins. So above the layout's number the
targets have the say: separateNodes already moves a corrected node's target
with it, and reading the rest length back off the targets is what stops the
correction being argued with. setGraph re-seeds them, so a new fleet starts
from the layout's numbers again.

Verification

dashboard/scripts/screenshot-live-map-crowd.mjs seeds the reported shape on
the real stack: clusters made of services rather than agents, one user owning
six, org-level instances on a ring that crosses the containers, two long agent
names on one ring, and a folded cluster whose chip is loose on the map.

It asserts rather than illustrates. Containers are drawn on the canvas, but
nodes and chips are DOM, so "nothing overlaps anything" is checkable — and so is
"it settles", which is the failure a positional constraint invites.

overlapping pairs motion over 5s
origin/dev 12 none
this branch none none

Positions, not pixels: the live indicator breathes and the canvas repaints
regardless, so comparing two screenshots would say nothing about the layout.

Before — origin/dev, same fixture

billing-sync's caption lies across notion; eventbrite's crosses
release-cutter; release-cutter.verifier's crosses hubspot; both of bruno's
services sit under their agents' labels.

before

After

after

One container folded

dagmar.olsen +9 is loose on the map and stays clear of everything.

folded

Full frames:
before ·
after

Also re-ran screenshot-live-map.mjs unchanged, for the traffic and collapse
shots this does not aim at.

🤖 Generated with Claude Code

https://claude.ai/code/session_018A3Bie3VNycPZC1bEU4jom

#585 stopped asking where containers were concerned: `separateBoxes` runs after
`computeBoxes` and translates whole clusters until no two boxes are closer than
`BOX_GAP`, because a force settles wherever it balances whatever pulls the other
way and a contact constraint does not. Every other overlap on the map was still
a force, and lost the same argument.

#584's constraint is the clearest case. `STRAY_PUSH` is 6; an org-level service
is held to the shared ring by the strongest target weight there is. They meet
with the service parked partway inside a container and stop. And the ring it is
held to clears the *targets* of everyone's owned services by a radial constant
that knows nothing about box padding, caption width or the band a name chip
hangs in — so a service landing on a container is the ordinary case.

The pass is now general. Everything it separates reduces to a rectangle, so one
contact rule serves a container, the chip a folded container leaves behind, and
a node that belongs to no open container. A container's *member* is deliberately
not one of them: its cluster's rectangle already encloses it, so a member
intruding on another container means the two rectangles intersect, which is said
by moving both clusters whole rather than by shearing one ball out of the fleet
it is drawn with. That also drops two things the stray force did: pushing a node
hidden inside a folded container, and pushing one member of a cluster on its own.

`separateNodes` is the other half — every visible pair, measured on what is
drawn. The repulsion reasons in `radiusOf`, a circle around the ball, while a
node is the ball *and* the caption under it, and the caption is the wider of the
two: siblings whose minimum is `pad + 18` routinely have their labels lying
across each other. It runs before `computeBoxes`, because a container is built
to hug its members and a correction made after the rectangle was measured would
leave `draw` stroking an outline a frame behind the cluster inside it.

## Why the springs had to change too

A positional pass that fights a spring is a map that twitches forever, and that
is what this did at first: measured on the fixture below, chi's cluster never
stopped moving — 2.5px of travel with 0.03px to show for it, the signature of a
correction being made and undone. Three changes make the forces want what the
pass enforces, so that by the time it runs there is nothing left to correct.

`clearance` is the distance along a given direction at which two nodes clear
each other, and it is now the floor under both fixed-distance springs and under
the repulsion's minimum. A spring that asks for a distance at which its pair
overlaps is one the pass argues with every frame. This is #583's reasoning a
level down: a container has to be big enough for its own name, and a ring has to
be wide enough for the names on it.

`restLength` is the rest of it. A spoke can be packed tighter than its captions
fit — a user, an agent, a subagent and a service on radii chosen without
reference to text — and no per-pair floor fixes that, because the pair that
overlaps is not the pair the spring joins. So above the layout's number the
*targets* have the say: `separateNodes` already moves a corrected node's target
with it, and reading the rest length back off the targets is what stops the
correction being argued with. `setGraph` re-seeds them, so a new fleet starts
from the layout's numbers again.

## Verification

`dashboard/scripts/screenshot-live-map-crowd.mjs`, a real-stack fixture of the
reported shape: clusters made of services rather than agents, one user owning
six, org-level instances on a ring that crosses the containers, two long agent
names on one ring, and a folded cluster whose chip is loose on the map.

It asserts rather than illustrates. Containers are drawn on the canvas, but
nodes and chips are DOM, so "nothing overlaps anything" is checkable — and so is
"it settles", which is the failure a positional constraint invites. On the same
fixture:

|  | overlapping pairs | motion over 5s |
|---|---|---|
| origin/dev | 12 | none |
| this branch | none | none |

Positions, not pixels: the live indicator breathes and the canvas repaints
regardless, so comparing two screenshots would say nothing about the layout.
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
overslash Ready Ready Preview Aug 28, 2026 5:07pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Code diff size

+415 / −87 across 1 file (net +328)

Area Files Added Removed Net
dashboard 1 +415 −87 +328

Source files under src/ only (.cjs, .go, .js, .jsx, .mjs, .py, .rs, .svelte, .ts, .tsx); test files and inline #[cfg(test)] modules excluded. 1 other changed file not counted.

Comment thread dashboard/scripts/screenshot-live-map-crowd.mjs
The check paired the two snapshots by index, and nothing said they had to be
the same length. A node leaving between them — a fade-out completing, a fold —
makes `after` the shorter array, and `before[i].x - undefined` is `NaN`, which
is not greater than 0.5, so the check passes by failing to compute. A settle
check that goes quiet exactly when the map is doing something is worse than no
check.

The two snapshots now have to describe the same shapes in the same order, and
say so out loud when they do not: a settled map does not change what it is
drawing, so that is a failure in its own right rather than a reason to skip the
comparison. Not keyed by label instead, because labels repeat — three users can
each own an instance called `notion`.

Raised by Seer on #586.
@angel-manuel

Copy link
Copy Markdown
Contributor Author

Seer's finding was right: the settle check paired the snapshots by index with nothing requiring equal length, so a node leaving between them made before[i].x - undefined a NaN that fails no threshold — the check would have gone quiet exactly when the map was doing something.

Fixed in b4bcb5a. Not keyed by label as suggested, because labels repeat here — three users each own an instance called notion, which is deliberately part of this fixture. Instead the two snapshots must describe the same shapes in the same order (.lm-node comes off a keyed {#each}, so they do unless something entered or left), and a mismatch throws: a settled map does not change what it is drawing, so that is a failure in its own right rather than a reason to skip the comparison.

Re-ran on the real stack: 26 shapes, no overlap, nothing moved over 5s.

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