Skip to content

New label placement - #8

Merged
assaron merged 74 commits into
mainfrom
feat/label-placement
Jul 21, 2026
Merged

New label placement#8
assaron merged 74 commits into
mainfrom
feat/label-placement

Conversation

@assaron

@assaron assaron commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

No description provided.

assaron and others added 30 commits July 8, 2026 19:00
Capture conflict-free reference scores (COPD orig+r1/r2/r3 + gallery, 13 configs)
from the prototype pipeline as the regression anchor for packaging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
- Boost.Geometry rtree box-fit compiles via Rcpp+BH and is correct (~7.5s build).
- Label injection point is R/mark_label.R my_place_labels (already a local fork).
- polylabelr::poi confirmed for poles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Port the six pipeline kernels, reorganised/renamed into per-stage files:
geometry.h (BH rtree BoxFit + segcross/segbox), boxfit/assign/candidates/
refine/polish/foreign.cpp. hungarian, oneMoveSweep, twoMoveBnB, foreignLength
verified bit-identical to the prototype; radialCandidates + forcePolish now use
the exact polygon R-tree instead of the integral image (all cases still 0/0/0,
within ~1% of golden). Package now compiles with LinkingTo BH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
placeLabels() + prep glue (candidate pool + boundary-slot fallback, row/index
maps, GEO geometry columns) + min-cost seed (.reorderBase) + isotonic .place1d.
Pure given per-label box half-sizes; drives the six C++ kernels. Verified on 13
configs: all conflict-free, within 5% of golden reference. Golden used as a
reference gate (not exact) per updated policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Replace my_place_labels() (ggforce Minkowski placement) with the mascarade
boundary-seed placer. Runs in makeContent (draw stage) in the panel's mm space,
where box sizes are real text metrics and polygons are in mm; poles + box-fit
R-tree are recomputed per draw (~20ms). Re-places on resize automatically.
generateMask/fancyMask/mark_shape unchanged. Verified: example plot renders,
placement box-overlap-free, proportionally equivalent to the prototype.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
- tests/testthat/helper-score.R: layoutScore (bb/ll/lb/len), scoreBetter, .buildTestGeom.
- test-placement.R: conflict-free on example data, across box scales (view-change),
  single-label edge case, end-to-end fancyMask render.
- Harden my_place_labels: clean non-finite vertices, guarantee >=3-pt rings, pole
  centroid fallback, anchor/pole fallback on failure (draw-stage polygons can be
  cropped/dropped/degenerate). K==1 guard in placeLabels.
Full suite: 76 pass, 0 fail, 1 skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
.Rbuildignore excludes all dev scratch; NEWS 0.4.0 entry; version bump to 0.4.0.
R CMD check Status: OK (76 tests pass). Boundary-seed label placer fully
integrated across generateMask -> fancyMask -> geom_mark_shape draw stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
- Delete simplify_outer() + its test (unreachable after the placement replacement).
- placeLabels: name the do.call geometry args (bind by name, not position); drop the
  never-set hard_ll param (inline TRUE); name the radial/polish tuning constants.
- my_place_labels: drop the ignored ghosts/simp_ratio params.
- Deprecate+document the now-ignored public simp_ratio (fancyMask, geom_mark_shape).
- Fix false 'built once / reused across draws' comments (geometry.h, boxfit.cpp,
  placeLabels.R): the box-fit is rebuilt per draw. De-archaeologise twoMoveBnB comment.
- Add transitively-relied-on includes (<algorithm>, <utility>).
- Wire the golden as a real no-worse regression gate on the example data (was unused).
Tests: 68 pass, 0 fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
The R-tree box-fit and foreignLength walk every polygon edge, and mask rings
have 50-800 vertices -> placement was ~6.8s on COPD. Restore simplify_outer and
apply it (simp_ratio) to the polygons used for placement inside my_place_labels:
COPD placement 6.8s -> 1.9s (3.7x), layout equal-or-better, still conflict-free
(simplified ring encloses the original, so box-fit stays conservative). Drawn
connectors still use the full-resolution polygons. simp_ratio un-deprecated and
re-documented for its new role. Restore test-simplify_outer.R. 77 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
The placer optimizes each leader as box-edge -> cluster pole, but the drawing
projected onto the nearest polygon-boundary point instead -- a different endpoint
that could look inconsistent with the optimized layout. my_place_labels now
returns the poles (attr 'poles'); my_make_label targets them, and straight()
clips at the box. Added a note that supporting elbow leaders would require
modelling the elbow geometry inside the placer (a future parameter). 77 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Root cause: my_make_label buffered all cluster polygons in a single
polyoffset() call, which reorders (and can merge) its output. But
my_place_labels pairs polygon i with rects[i]/anchors[i] and returns results
indexed to line up with labels/con.gp, so the reordering silently mismatched
each label's text/colour with a different cluster's placement and pole. The old
projection-based leader hid this by re-projecting onto the correctly-indexed
original polygon; targeting the pole (previous commit) exposed it.

- Buffer each polygon individually so p_big stays 1:1 and in label order.
- straight() now attaches the leader at the box exit of the centre->pole ray
  (matching .leaderEdges, exactly what the placer scores) instead of a corner.
- fancyMask draws con.border='none' so the leader is a single straight segment
  (no borderline overhang / 'elbow'), consistent with the placement model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
The min-cost seed placed its two fallback columns just inside the plot limits
(xlim +/- max half-width), so at the default expansion wide labels were squeezed
against or over the panel edge. Anchor them to the cluster cloud instead: left
column at (min polygon x - column max half-width), right column mirror-image, so
the boxes clear the clusters regardless of the plot limits. Columns may overflow
the panel in x and y is never clamped (labels overflow rather than pile up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Replaces the centre-exit leader with an anchor leader whose start point on the
label box is decoupled from the box centre, exposed via con.type:
  cl  corners + horizontal ledge (single sign-quadrant corner)
  cm  corners + edge-midpoints, no ledge (ggforce get_end_points rule: corner
      only when the pole is fully to the side in x AND y)
  none  no connector
New con.padding = minimum visible leader length (distance outside the mask) +
the boundary-seed offset; NULL scales with label height.

- placeLabels: .anchorPoint + .firstHit; .geoCols sets ex,ey from the anchor;
  the boundary seed hangs boxes off min/max(poly x) -/+ con.padding with a
  bottom-corner (cl) / edge-midpoint anchor; radial candidates are filtered by
  visible length >= con.padding; forcePolish (centre-exit only) is dropped.
- Leaders are drawn anchor -> first mask boundary (visible part), + ledge for cl.
- Removed the now-dead straight/elbow/with_borderline/get_end_points helpers and
  the unused ggforce_points_to_path.R.
- Tests check cl and cm are conflict-free; golden regenerated for cl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
- my_make_label now applies con.cap (gap at the cluster end of each leader);
  previously the parameter was silently ignored.
- Remove the now-dead forcePolish C++ kernel (src/polish.cpp) and its exports,
  and the unused con_border/anchor_mod args from my_make_label.
- Boundary-seed leader now starts at the box's true near edge (not the padded
  line) so the cl ledge meets it; still conflict-free (verified on example+pbmc,
  both styles) since column boxes are separated in y by gap > pad.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Restores the continuous force-directed polish dropped during the anchor-leader
integration. The only change from the original kernel is the leader-start rule:
polish.cpp's lead() now mirrors R's .anchorPoint() (con_type 0 = cl corner, else
cm/none 8-point rule) instead of the centre-exit leader, so its hard conflict
guard matches the drawn/scored geometry. placeLabels runs it after the two-move
and re-derives the anchors from the polished centres; feasibility is preserved
(the discrete solution is kept and only conflict-free moves are accepted).

Cuts total leader length markedly (example cl golden 26.5 -> 19.8); still 0/0/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
con.padding (minimum visible leader length) is removed. The keep-out gap between
labels and clusters is now the pre-existing label.buffer, which dilates each
cluster polygon; fancyMask defaults it to 2 mm (was 0). Leaders are drawn to the
TRUE cluster outline, not the dilated one:

- my_place_labels takes both the true and dilated polygon sets. Box-fit keep-out
  (rtree) is built from the dilated set; poles, leader ends (.firstHit) and
  foreign-routing use the true set. Seed columns hang off the dilated extent.
- Dropped the visibility filter and con.padding seed offset from placeLabels.

Golden regenerated; cl/cm still conflict-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
…ective

The optimizer's length objective now also penalizes how far a label box extends
past the viewport (summed over x and y, 1:1 with distance), so the discrete stage
prefers in-panel candidates over off-panel boundary-seed fallbacks -- cutting the
label clipping on dense plots (e.g. COPD, 43 clusters).

The foreignLength kernel is renamed effectiveLength and now returns the whole
effective length (base leader length + foreign-cluster crossing + overflow)
instead of just the foreign component; src/foreign.cpp -> src/effective.cpp,
exports regenerated. Where a label has no feasible in-panel slot it can still
overflow (preferred over a conflict). Golden unchanged (example fits in-bounds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
forcePolish previously rejected any move whose box left the viewport, which
trapped off-panel boundary-seed labels: every intermediate step back toward the
panel was still partly outside and got rejected. Drop the hard bounds check and
add the same 1:1 viewport-overflow term to the energy. Now the pole distance and
the overflow term both pull an off-panel label inward (the polish can walk it in
step by step until a cluster/box blocks it), and a label may cross the edge only
when doing so lowers total energy. Conflict-freeness is still hard-guarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
radialCandidates required each box fully in-bounds, so a crowded wide label
(e.g. T_senescence-associated against the T-cell blob) had no conflict-free
in-panel slot and fell back to the distant boundary seed. Drop the in-bounds
gate: candidates now only need to be cluster-free, and the effective-length
overflow term ranks them, so such a label takes a minimally-clipped slot next to
its own cluster instead of the far margin column. (xlo..yhi are now unused in the
kernel but kept in the signature.) Golden unchanged (example fits in-bounds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Bump the overflow penalty from 1:1 to 3x in both the effective-length ranking
(effective.cpp) and the polish energy (polish.cpp), pulling crowded edge labels
further in-panel. Golden unchanged (example fits in-bounds).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Measure viewport overflow against xhi - label.buffer (and xlo + label.buffer,
etc.), so labels keep the same keep-out gap from the panel edge that they keep
from clusters. Done by shrinking the xlim/ylim my_place_labels passes to
placeLabels (those bounds now feed only the overflow terms), so no kernel change.
Pulls near-edge labels inward; genuinely cluster-blocked labels (e.g. FCGR3A+
Mono, whose cluster hugs the right edge) still overflow and need limits.expand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Same algorithm, reformatted: split the point-in-polygon and leader-inside-polygon
math into named helper functions, one statement per line, a named OVERFLOW_WEIGHT
constant, and clearer variable names. Behavior identical (golden unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
…tyle rule

- lengthInsidePolygon (hand-rolled edge-split + midpoint point-in-polygon) is
  replaced by bg::intersection(leader, polygon) + bg::length -- the leader-inside-
  cluster arc via standard Boost.Geometry primitives. Cluster polygons are built
  once as bg polygons (bg::correct) with a cached bbox broad-phase reject. Behavior
  identical (golden unchanged).
- OVERFLOW_WEIGHT moved to src/constants.h, included by effective.cpp and polish.cpp
  (was a literal 10.0 duplicated in both).
- CLAUDE.md: production code should be readable/follow conventions; always brace
  if/for/while bodies and put the body on its own line (R and C++).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
twoMoveBnB assumed a conflict-free start: its length branch-and-bound prunes the
length-increasing tail, so a conflict resolvable only by a LONGER candidate was
never reached and input conflicts survived. Now, per label:
  * conflict-free label -> unchanged length B&B (blocker partner + length bound);
  * conflicted label    -> drop the pruning and search all pairs (this label's c1
    x every other label's c2, with the current mutual conflict subtracted from the
    delta), driving input conflicts out. Only conflicted labels pay the extra cost.

Adds an artificial test: two labels overlap and the only fix is a longer, hence
previously length-pruned, candidate -- fails on the old kernel, passes now.
Example placement unaffected (conflict-free into two-move; golden unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
oneMoveSweep reformatted to match twoMoveBnB: braces on every if/for/while and
the body on its own line. Cosmetic only -- identical tokens/logic (example golden
and the two-move conflict test unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Readability pass (no behavior change): rename the cryptic locals -- bbf/lbf/llf/
conf -> boxesOverlap/leaderBoxConflict/leadersCross/anyConflict, lv -> lengthCost,
cnt3 -> countConflicts, byl -> candidatesFor, ord -> order, the b/x/l conflict
counts -> boxBox/leaderLeader/leaderBox, and the bBB/bXX/... best-move trackers ->
bestDBox/bestDCross/... ; break the multi-clause predicate lambdas across lines,
put one statement per line (incl. the best-move assignment), and keep lines <=100
cols. Example golden and the two-move conflict test unchanged; full suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Convert the comment doc-strings on the placeLabels.R helpers (.pavaDec, .place1d,
.anchorPoint, .firstHit, .geoCols, .reorderBase, placeLabels) and the mark_label.R
placement functions (simplify_outer, my_place_labels, my_make_label) to roxygen:
title, @PARAM, @return, and @Keywords internal + @nord (internal, no man page).
Cross-references to @nord helpers use code spans, not [foo()] auto-links, so
roxygenise resolves cleanly. Documentation only -- example golden unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Add roxygen (//') doc-strings -- title, @PARAM, @return, @Keywords internal -- to
every [[Rcpp::export]] kernel (buildBoxFit, hungarian, radialCandidates,
oneMoveSweep, twoMoveBnB, effectiveLength, forcePolish); compileAttributes copies
them into RcppExports.R. Remove @nord from the R placement internals so their docs
(and the C++ ones) now generate real internal man pages -- letting R CMD check
enforce documented arguments rather than suppressing it. checkDocFiles reports no
undocumented arguments; full test suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Removes the significant duplication (TODO) between oneMoveSweep and twoMoveBnB: the
box/leader conflict predicates (boxesOverlap, leaderBoxConflict, leadersCross,
anyConflict) and the per-label countConflicts, previously copy-pasted as lambdas in
both, now live once in a file-local CandidateSet struct. lengthCost stays local to
twoMoveBnB (it alone squares the length). Pure extraction -- example golden and the
two-move conflict test unchanged. Also clarifies the two-move doc-string: the
partner is whatever label sits in the target slot; it need not itself be in conflict.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
assaron and others added 29 commits July 17, 2026 22:09
- "box" places the label like "direct" (same anchor rule) and additionally
  outlines the label's bounding box at draw time (four edges drawn with the
  connector gp). Added to geom_mark_shape()/fancyMask() match.arg and the
  internal con_type paths; placement is identical to "direct" so only the
  draw-time grob building in mark_label.R changes.
- Moved the per-value descriptions of con.type out of the @PARAM entry into
  a geom_mark_shape() @details section (ledge/direct/box/none); the @PARAM
  now just lists the options and points to Details. fancyMask() references it.
- Test: leader-style loop now covers ledge/direct/box (title de-staled from
  the old cl/cm names).

Full suite green (105/105); "box" renders via fancyMask and geom_mark_shape
and draws the box edges (verified by grob inspection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
labelboxGrob laid out a label box as top-margin / text / sep / description /
bottom-margin. grobHeight(text) is the ascent (cap) box and excludes the
descent, so for a label with no description the code parked a full font
descent in the sep row to make room for descenders -- but on top of the
bottom margin, so a label without descenders (most cluster names) got ~1mm of
dead space underneath and looked bottom-heavy. Most visible now that
con.type = "box" outlines the box.

Reserve only the descent the bottom margin does not already cover
(max(0, font_descent - bottom_margin)): with the default 2mm margin a no-
descender label is symmetric (2mm top / 2mm bottom around the cap box),
descenders still sit within the margin, and a zero-margin label still reserves
the full descent so nothing clips.

grobHeight equals the cap height exactly for the default font across devices
(pdf/cairo/ragg/svglite), so the label grob's own box is already the
cap-height box -- no separate box computation or text repositioning needed;
con.type = "box" and placement just read grobHeight/grobWidth.

Full suite green (105/105).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
The placer minimises viewport overflow but will spill a label box off-panel
(where it gets clipped) when a crowded layout leaves no room. Detect that at
draw time -- any label box extending beyond the panel bounds -- and emit a
cli warning suggesting the two fixes the user controls: a smaller
label.fontsize, or wider plot limits (e.g. ggplot2::expansion() on a scale).

Fires only on real overflow (verified: no warning on the normal example
renders; fires with correct singular/plural wording when labels are forced
off-panel). Full suite green (105/105).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFyDJ2r7jeLmUuZgQKejon
Rename the "direct" leader/label-mark style to "line" throughout the geom,
placement kernels, and documentation. Also refresh the geom_mark_shape
examples (add a description example) and give the geom a `description`
default aesthetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBwyxAC7kSfr21WExAnHQE
Introduce `balance_wrap()`, a soft-target minimum-raggedness word wrapper,
and expose it as a new `label.maxwidth` parameter on `geom_mark_shape()` and
`fancyMask()`. When `label.width` (a strict fixed width) is unset, the label
and description are wrapped to this soft target so line widths are even and
close to it, avoiding a short dangling final line and orphaned words. A line
may slightly exceed the target to prevent an orphan, and an over-long single
word is never broken (the effective target grows to the widest word). The
target also lowers the effective `label.minwidth` so the box never exceeds it.

Break points match `wrap_text()` (spaces and after-hyphens, never before).
fancyMask uses a positive line height when wrapping is active so multi-line
labels do not overlap. Adds tests for the wrapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBwyxAC7kSfr21WExAnHQE
Follow-up to the label.maxwidth review:
- balance_wrap() now trims the single-token / empty early return, matching
  wrap_text() so a label with surrounding whitespace is not mis-sized.
- Replace three idiomatic inline if/else expressions with braced statements
  (per CLAUDE.md brace style) in balance_wrap() and fancyMask().

No behavioural change beyond the trim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBwyxAC7kSfr21WExAnHQE
Merge the strict-width and soft-target code paths in labelboxGrob() into a
single `width` argument that behaves as the old `max.width` did: balance_wrap
the text to a soft target, lower the effective min.width, and size the box to
the wrapped content rather than to a fixed dimension. Drop the now-unused
greedy `wrap_text()`.

Remove `label.maxwidth` from the geom and fancyMask() entirely, keeping
`label.width` as the sole public parameter (breaking rename), and override the
inherited ggforce doc that still described it as a strict fixed width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
Extract three named internal helpers from my_place_labels() -- prepPolygons()
(ring cleaning + simplification), polesOfInaccessibility() (pole solve with
centroid fallback), and warnOnOverflow() (off-panel spill warning) -- and
rewrite the body with descriptive names, one statement per line, braces on
every branch, and no <<- closure mutation. Behavior is unchanged (verified by
the geom_mark_shape draw-path tests and a matching render).

Fix the "did not full fit" -> "did not fully fit" typo in the overflow warning,
and document the two 1e-3 (1 um) constants: the non-collinear seed triangle for
degenerate rings and the floating-point tolerance on the overflow check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
Extract five named internal helpers from my_make_label() -- expandSinglePoints(),
computeAnchors(), ringArea(), dilatePolygons(), and buildConnectorGrob() -- and
rewrite the body as a short top-to-bottom pipeline with descriptive names, one
statement per line, and braces on every branch. The dense connector loop (parallel
xs/ys/gi arrays with a manual counter) becomes a per-label segmentsFor() that
returns {x, y, label} segments flattened into one polylineGrob. Behavior is
unchanged (verified by the geom_mark_shape draw-path tests and matching renders
across ledge/line/box connector styles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
Detect degenerate cluster rings -- a point, a line, or a >=3-point ring enclosing
zero area (collinear / repeated-vertex slivers), including NA-cropped rings -- in
makeContent.shape_enc() via the new degenerateRing(), and drop them entirely: no
outline, no label, with a cli warning. generateMask() never emits these, but a real
cluster can collapse this way after axis-limit cropping or a negative expand.

Fold the drop into the existing ggforce-drop prune so a single keep-set is applied
once to every positionally-indexed structure (polygons, labels, dims, anchors,
mark rows via the new pruneMark(), mark$gp, con.gp), which is where colour-alignment
bugs lived before. Bind `surviving` to sorted id order so polygons[[k]] stays aligned
with surviving[k]. The prune stays guarded, so the happy path is untouched.

This removes the need to expand single-point clusters into jittered clouds
downstream: delete expandSinglePoints() and rehome its stats::runif import to
generateMask.R (its other user). prepPolygons()'s eps-triangle guard is downgraded
to a defensive net. Adds a unit test for degenerateRing and an integration test
asserting a dropped cluster leaves survivor colours aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
…lots

Convert bare noun-phrase roxygen titles to action titles across the label-placement
helpers (e.g. computeAnchors, ringArea, polesOfInaccessibility, my_place_labels, and
the placeLabels.R sweeps / polish / layout accessors), and trim a few over-detailed
bodies.

Rename the two slot generators for a consistent vocabulary: .seedSlots -> .sideSlots
and .seedColumn -> .sideColumn (the left/right boundary columns), and
.radialPlacements -> .radialSlots. seedLayout() stays -- it now reads as the initial
layout built from side slots, pairing with addRadialCandidates() adding radial slots.
Also reword the .sideSlots doc: these are the starting placement, not a "fallback".
All three are internal (@nord); no API, test, or NAMESPACE impact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
…ract

The label-placement layer used "ring" and "polygon" interchangeably for the same
single-loop list(x, y) object. Standardize on "polygon" (matching the dominant
identifier vocabulary and the public generateMask() "polygon table" API): rename
ringArea -> polygonArea and degenerateRing -> degeneratePolygon, and switch the
prose/comments in mark_label.R, placeLabels.R, and mark_shape.R to "polygon".

Add a boundary note in my_place_labels() recording the fact this glosses over: each
input is a single ring; any mask holes are resolved upstream in generateMask(). The
generated RcppExports.R / src C++ contract ("one ring per cluster") is intentionally
left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
Now that makeContent.shape_enc() drops degenerate polygons up front
(degeneratePolygon()), the downstream defensive handling for them is dead. Turn it
into assertions that fail loudly if the invariant is ever violated:

- prepPolygons(): the eps-triangle rebuilt for sub-3-vertex polygons -> stopifnot(
  length(x) >= 3). The non-finite filter stays (a valid polygon can still carry NA
  vertices from axis cropping).
- my_place_labels(): the poles/bounds finiteness half of the anchor-fallback trigger
  -> stopifnot(all(is.finite(poles)), all(is.finite(bounds))). Poles are finite by
  construction and a panel's npc->mm size is always finite; only a collapsed
  (zero-size) panel stays a real guard. The assert also keeps the `<= 0` comparison
  NA-safe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
When placement cannot run -- a zero-size panel or the C++ solver failing --
my_place_labels() now warns once and draws no labels (skipPlacement) instead of
dropping every label onto a computed "anchor" point that is invisible anyway. That
retires the entire anchor machinery, whose only consumer was that fallback:

- Delete placeOnAnchors(), computeAnchors(), the `anchors` parameter, and the
  anchor_x/anchor_y plumbing through my_make_label() and makeContent.shape_enc().
- Remove the x0/y0 aesthetics and the anchor.x/anchor.y grob parameters from
  geom_mark_shape / shapeEncGrob, plus the `data$xmin <- data$x0` transform hack.

These were package-local additions (not ggforce), shipped in v0.3.5; real placement
uses poles of inaccessibility, never anchors. NEWS entry to follow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
…ductions

label.buffer is a padding, so it must be non-negative -- assert delta >= 0 in
my_make_label() before dilating. With that guaranteed, dilatePolygons() can drop its
empty-offset and split-offset branches: dilating a single connected polygon by a
non-negative offset always yields exactly one piece, now enforced by
stopifnot(length(dilated) == 1).

Remove the poi guards in polesOfInaccessibility() -- the tryCatch and centroid
fallback. Polygons reaching it are non-degenerate, so poi() is expected to succeed
and return a finite point; if it doesn't that's a bug and should surface (a
non-finite result is still caught by the caller's stopifnot(all(is.finite(poles)))).

Mark the degenerate-coordinate reductions in makeContent.shape_enc() as LEGACY
(inherited from ggforce's mark_hull): they are largely redundant now that
degeneratePolygon() drops such parts, but are kept because they also feed the drawn
mark and predate the degeneracy drop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
label.width (soft wrap cap) and label.minwidth (box-width floor) are orthogonal
bounds. The `min.width <- min(min.width, width)` line only existed to stop ggforce's
50mm default min.width from bloating boxes when a small width is set. Default
label.minwidth to 0 instead -- which fancyMask already forced everywhere -- so the
knobs no longer interact: remove the hack, and drop the now-redundant
label.minwidth = 0 overrides in fancyMask.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MYxn2tp7jUnQnYfbMrLhjQ
The NA form is soft-deprecated in favor of the explicit expect_no_error();
bump the testthat minimum to 3.1.5, which introduced it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHu5JKSSK5G9sCN62LXLdJ
Drop the K==1 short-circuit in my_place_labels() that dropped a lone
label on its cluster's pole with an invisible leader. A single cluster
now flows through placeLabels() like every other case, producing an
external label with a real leader. The pipeline already handled K==1
(it is per-label throughout), and test-placement.R already asserted this
behavior via a direct placeLabels() call.

Also refresh now-stale comments: the zero-length-leader note cited the
removed single-label / anchor fallbacks, and the placeLabels docstring
framed K==0/K==1 as special cases rather than the natural empty/one-row
result of the one-row-per-cluster contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sTksGqUQMWYD7ddMrxwGR
It asserted conflict-free placement across three char_frac values, but on
the example data every scale takes the identical path: the Hungarian seed
branch, a conflict-free seed, and the sweeps as no-ops. It flipped no
size-dependent branch (the crowded packLen path needs capacity < m, which
never happens here; the conflicted two-move branch is covered by the
synthetic kernel test), so it added no coverage over the default-scale test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sTksGqUQMWYD7ddMrxwGR
The constant had only one user (effectiveLengthImpl in effective.h), so a
separate header just added an include. Move the definition next to its use
and remove constants.h.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sTksGqUQMWYD7ddMrxwGR
@assaron
assaron merged commit 457a22b into main Jul 21, 2026
6 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