RS-23152: Stop the donut label collision resolver spinning forever at the 0 degree seam - #83
Conversation
|
The root cause analysis and the This is not a regression from this PR — the same configurations hang on Why the CC loop is still unboundedThe CC
So termination now depends entirely on a collision-free, in-bounds slot existing somewhere on the lap. Secondary effect of the same inert guard: Reproduction at default settingsA 300x300 donut, 40 equal-valued segments, every setting at its shipped default ( On this branch: 3,000,000 label movements without terminating. Label 0 ( Swept canvas size against
Two qualifications. At 600x600 the defaults are safe across 20-100 segments, so the risk concentrates in small tiles rather than full-size charts. And the harness gives every label the maximum wrapped width with equal segment values, which is the worst case for a given size and segment count — a real chart needs uniformly longish category names to hit these thresholds. Suggested fixCap the CC sweep at one revolution per label and treat exhaustion the same way the max-angle case is treated. Next to const maxMovesPerLabel = Math.ceil(360 / angleIncrement)Then in the CC loop: let movesRemaining = maxMovesPerLabel
while (
(wrappedLabelSet.findAllActiveCollisionsWithLesserLabels(label).length > 0 || !this.canvas.labelIsInBounds(label)) &&
!labelLineAngleExceededTooFarCounterClockWise(label) &&
movesRemaining > 0
) {
...
movesRemaining--
}
const exhaustedRevolution = movesRemaining === 0
if (labelLineAngleExceededTooFarCounterClockWise(label) || exhaustedRevolution) {
wrappedLabelSet.resetLabel(label)
recordHitMaxAngle(CC)
}
Measured with that applied:
Happy for this to be a follow-up ticket rather than a change to this PR, given it is pre-existing — but if it goes that way, it is worth a note here so the benchmark table is not read as covering it. Smaller points
|
theSrc/scripts is written with `import`, but nothing configured a transform, so `rhtml testSpecs` could only ever run against the handful of files that happen to be CommonJS. math.jest.test.js passed only because math.js is one of them. babel-jest is jest's default transform and picks this file up automatically, so no jest config is needed. The production bundle is built by esbuild, which does not read babel config, so this affects the spec tests only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RS-23152: applying a filter to a published dashboard froze the browser tab with
repeated "Page unresponsive" dialogs. A profile of the live document showed ~36s
of continuous main thread work inside the donut labeller's descending order
collision resolver.
The resolver moves a colliding label around the label ellipse one angleIncrement
(0.5 degrees) at a time, normalising the result back into [0, 360) after each
step. That normalisation was wrong for negative angles:
const boundedAngle = (angle) => (angle < 0) ? 360 - angle : angle % 360
For -0.5 it returns 360.5, not 359.5 -- i.e. it reflects the angle rather than
wrapping it. So a counter clockwise step from 0 lands on 360.5, and the next step
from 360.5 lands back on 0. Any label pushed counter clockwise past the seam falls
into that closed two-cycle, makes no angular progress, and the sweep's `while`
loop can never satisfy its exit condition. Nothing bounds the loop, so the tab
locks up.
It needs a label to actually reach the seam, which is why this reproduced on the
customer's document and one particular filter but not on a copy: with 60 equally
sized segments the largest label sits at 3 degrees and the counter clockwise sweep
walks it straight over 0. Under the harness that case ran past 5,000,000 label
placements without terminating; it now settles in 1,270 placements / ~11ms.
Replaced with math.normaliseAngle, which wraps in both directions. Placement is
unchanged for every layout that does not cross the seam -- of 97 chart shapes
exercised locally, 95 produce byte identical output, one previously hung, and one
(54 equal segments) now resolves differently because that is the case the bug was
corrupting.
Note the counter clockwise max-line-angle guard is still not symmetric with its
clockwise counterpart (the seam clause is commented out at line 279), so a label
that legitimately crosses the seam can exceed labelMaxLineAngle by a few degrees.
That is bounded and only shows up in degenerate layouts where nearly every label
is dropped anyway, so it is left alone here rather than bundled into a hang fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerated through CI (run 34068077340) rather than locally, since baselines are environment specific. All eight diffs are label placement shifts confined to the 9 o'clock region, which is where 0/360 degrees sits -- _computeAngleBetweenLabelLineAndRadialLine measures from `pieCenter.x - outerRadius`. That the only rendering to move is rendering that touches the seam is a good independent check on the fix: the other 123 snapshots in the suite are untouched, and a full file compare of the regenerated set against the committed one differs in exactly these eight of 356. Label ordering is preserved everywhere and nothing overlaps. decreasing_values_100_with_offsets is a strict improvement: the old baseline dropped labels 25 and 27 out of an otherwise contiguous run, and the new one keeps them, at the cost of dropping the smallest slice. That is go() picking a lower loss extraHeight variation now that placement is not being corrupted mid-solve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3df8fa8 to
9124c99
Compare
Fixing the seam wrap stopped the CC sweep cycling on the spot, but it did not bound it. labelLineAngleExceededTooFarCounterClockWise only fires while labelAngle < segmentAngleMidpoint, so once a label has wrapped past 0 the guard is inert for that revolution and every one after it. Termination then depended entirely on a collision free, in bounds slot existing somewhere on the lap, and when none exists the label walks laps forever. Cap the CC descent at one revolution and route exhaustion into the same resetLabel/recordHitMaxAngle path as the max angle case. movesRemaining === 0 is a safe exhaustion test: after 720 steps of 0.5 degrees the label is back at the angle it started from, which is known to be colliding, so there is no valid position to misclassify. Routing it through recordHitMaxAngle also lets keepSweeping() exit via lastTwoFrontiersAreSame() instead of grinding out all 18 sweeps, which it could not do before because CC never recorded a max angle hit for a seam crossing label. Verified against the four geometries computePieLayoutDimensions derives at or near shipped defaults, including the default labels.max.width of 0.3: all four walk laps without the cap and terminate with it. Layouts that already terminated are unchanged move for move (1987, 3805, 2914, 4633, 7373, 435, 6783 and 5984 placements before and after), so no visual baselines are expected to move. Also addresses review feedback on the neighbouring tests: - the revolution budget test allowed 20 revolutions cumulative while claiming one, and its only assertion passed even if nothing happened. Budget is now an actual revolution (720), which this layout clears with room to spare at 107 moves for its busiest label, and the assertions now pin the label count and the observed maximum. - the "uneven distribution" comment sat above uniform it.each cases; moved it to the long tail test it actually describes. - dropped the duplicate 60 segment entry from that it.each, which repeated the preceding test exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e R build @babel/preset-env was resolving only because rhtmlBuildUtils depends on it and npm hoists it to the root node_modules. If that hoisting ever changes, `rhtml testSpecs` fails with "Cannot find package" rather than a test failure. Declare it directly, along with babel-jest and the @babel/core it peer depends on. babel.config.js was added at the repo root but not to .Rbuildignore, unlike its peers there (eslint.config.js, package.json, package-lock.json). Both files are hand maintained rather than generated, so it needs adding by hand. Without it the file ships in the R source package and trips a "Non-standard file/directory found at top level" NOTE in R CMD check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seam fix and the revolution cap both live in theSrc, but the R package serves inst/htmlwidgets/rhtmlDonut.js, which still carried the reflecting boundedAngle. Rebuild it so the fixes actually reach R users, matching what both #80 and #81 did for their source changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Verified the structural argument and it holds. Taken into this PR rather than a follow up: the PR is titled "stop it spinning forever", so shipping it while it still hangs at a default Independent reproductionDrove the resolver headlessly with the geometry One difference worth recording: my per-configuration table is not the same as yours, and 300x300 / 40 segments at default max width terminated for me in 3,805 moves rather than hanging. The band is narrow and sensitive to label text length, which differs between our two harnesses — with
Note the third: 600x600 at the shipped default Two of your measurements reproduce to the digit, which is good corroboration that the harnesses agree where the geometry coincides: 600x600 / 0.4 / 40 terminates in 32,607 moves keeping 39 of 40 labels. Non regressionAll four hanging cases terminate with the cap. Every case that already terminated is unchanged move for move — 1987, 3805, 2914, 4633, 7373, 435, 6783 and 5984 placements, identical before and after — so no visual baselines are expected to move. All ten pre-existing spec tests also pass with the cap reverted, which is the same statement from the other direction. The four geometries are now Smaller points
One you did not raiseThe PR was changing
|
RS-23152
The bug
Applying a filter to a published dashboard froze the tab with repeated "Page
unresponsive" dialogs, and it never recovered. Chris Facer profiled the live
document and found ~36s of continuous main thread work inside this widget's
DescendingOrderCollisionResolver, withfindAllActiveCollisionsWithLesserLabels(the counter clockwise sweep's collision query) among the hottest frames.
Root cause
The resolver walks a colliding label around the label ellipse one
angleIncrement(0.5 degrees) at a time, normalising back into
[0, 360)after each step:For a negative angle this reflects rather than wraps:
-0.5returns360.5instead of
359.5. That makes counter clockwise stepping across the seam a closedtwo-cycle:
A label pushed counter clockwise past 0 therefore makes no angular progress, and
the sweep's
whileloop can never satisfy its exit condition. Nothing else boundsthat loop, so the tab locks up. It is a genuine infinite loop, not merely slow
work — which matches "even after clicking Wait, I couldn't get things to ever
update".
Instrumented trace of the largest label (
segmentAngleMidpoint = 3.0):This needs a label to actually reach the seam, which is why it reproduced on the
customer's document under one particular filter but not on a copy.
The fix
1. Wrap the angle correctly
Replaced
boundedAnglewith a newmath.normaliseAngle, which wrapscorrectly in both directions (
((angle % 360) + 360) % 360). It lives inmath.jsalongside the other angle helpers rather than staying private to the resolver.
Measured with a headless harness driving the resolver directly:
2. Bound the counter clockwise sweep to one revolution
Raised in review by @JustinCCYap, and verified: wrapping the angle correctly stops
the sweep cycling on the spot, but it does not bound it.
labelLineAngleExceededTooFarCounterClockWiseonly fires whilelabelAngle < label.segmentAngleMidpoint, so once a label has wrapped past 0 thatguard is inert for the rest of the revolution and every revolution after it.
Termination then rests entirely on a collision free, in bounds slot existing
somewhere on the lap, and when none does the label walks laps forever — a second,
independent unbounded loop reachable at stock default settings.
So the CC descent is now capped at one revolution per label, with exhaustion routed
into the same
resetLabel/recordHitMaxAngle(CC)path as the max angle case.movesRemaining === 0is a safe exhaustion test: after 720 steps of 0.5 degrees thelabel is back at the angle it started from, which is known to be colliding, so there
is no valid position to misclassify. Routing it through
recordHitMaxAnglealso letskeepSweeping()exit vialastTwoFrontiersAreSame()rather than grinding out all 18sweeps, which it could not do before because CC never recorded a max angle hit for a
seam crossing label.
Four geometries that
computePieLayoutDimensionsderives at or near shipped defaultswalk laps without this cap and terminate with it — including 600x600 at the default
labels.max.widthof 0.3:Rendering impact
Small and targeted — placement only differs where a label actually crosses the
seam. Across 97 chart shapes exercised locally, 95 produce byte identical label
placement, one previously hung, and one (54 equal segments) resolves differently
because that is the case the bug was corrupting. I'd expect very few visual
baselines to move; CI will show exactly which.
The revolution cap adds nothing to that. Every layout that already terminated does so
in exactly the same number of label movements with the cap in place (1,987 / 3,805 /
2,914 / 4,633 / 7,373 / 435 / 6,783 / 5,984 placements, identical before and after),
and all ten pre-existing spec tests pass with the cap reverted. It only ever changes
behaviour where the alternative was not terminating.
Tests
math.jest.test.js—normaliseAngleunit tests, including a case assertingthat counter clockwise stepping across the seam always makes progress. This is
the one that pins the root cause.
DescendingOrderCollisionResolver.jest.test.js(new) — drives the resolverheadlessly against a stub canvas, with a label movement budget so a regression
fails the run instead of hanging it. The 60/90/100 segment cases all hang without
the seam fix, and the four canvas geometries in
bounds the counter clockwise sweep to one revolution per labelall hang without the revolution cap (checked byreverting each fix in turn: the intended cases fail and no others do).
Also adds
babel.config.jsso jest can transform theimportstatements that mostof
theSrc/scriptsuses. Without it none of this code was unit testable — the oneexisting spec test passed only because
math.jshappens to be CommonJS. esbuildbuilds the bundle and does not read babel config, so this affects spec tests only.
npm run lintclean, 29/29 spec tests pass.Left alone deliberately
The counter clockwise max-line-angle guard is still not symmetric with its
clockwise counterpart — the seam clause is commented out with a "not sure why but I
cannot make this symmetric" TODO. A label that legitimately crosses the seam can
therefore exceed
labelMaxLineAngleby a few degrees. That is cosmetic and bounded,and it is no longer a hang risk now that the sweep is capped at one revolution, so
fixing the asymmetry properly is still worth raising separately.
Per Chris's note on the ticket,
ngviz-mono'sngviz-piecarries a TypeScript portof this same resolver and likely has the same defect — worth checking there too.
🤖 Generated with Claude Code
Reopened from #82, which was merged into
rhtmlBuildUtils-9prematurely and has since been unpicked by a force-push — GitHub cannot reopen a merged PR, hence a new one. Now rebased ontomaster(c873aec) since #81 landed as a squash merge, and retargeted; the three original commits are unchanged in content.