Skip to content

Stop funnel labels overlapping on short vertical charts - #481

Open
Alek99 wants to merge 2 commits into
mainfrom
alek/funnel-label-collision
Open

Stop funnel labels overlapping on short vertical charts#481
Alek99 wants to merge 2 commits into
mainfrom
alek/funnel-label-collision

Conversation

@Alek99

@Alek99 Alek99 commented Aug 9, 2026

Copy link
Copy Markdown
Member

The bug

Funnel value labels and drop-off labels overlap on short vertical charts. An
8-stage funnel at 260px draws all fifteen labels on top of one another; a
5-stage funnel in a 260px dashboard tile collides its last value label with the
drop-off above it.

Two causes, both in the build-time label ladder.

The ladders interleave at half the pitch. Value labels sit at the stage
centers and drop-off labels at the boundaries between them, so consecutive
rows in the outside lane are half a stage pitch apart. Both were gated on
_fits(line_px, pitch_px) — a whole pitch — so every pitch between one and two
line heights admitted labels that then collided, while each ladder believed it
had room. With the 12px default that is a stage pitch of 17–34px.

The plot-size estimate was optimistic. The ladder cannot see the real plot
rectangle (only the renderers lay it out) so it estimates from the configured
chart size, and it scaled height by 0.85. But the title band, tick labels and
margins cost a roughly fixed ~90px rather than a fixed fraction: at height 180
the estimate claimed 153px of a real 92px plot, 65% too much, and placed labels
that could not fit. Measured against the SVG exporter, height - 88 tracks the
real plot box from 180px through 640px.

The fix

A drop-off label keeps its row only where neither neighbouring value label
reaches into the span it starts at. The value label wins that tie — it is the
datum, the drop-off is arithmetic derived from it, and every number stays in the
tooltip and the event payload either way. So a cramped funnel now drops the
derived percentage instead of printing it over the stage value.

Horizontal funnels are exempt and untouched: their value labels ride above
their own segment rather than in the boundary lane, which is why only the
vertical ladder could be contested.

The estimate now subtracts the chrome band instead of scaling. That is
pessimistic on a tall chart, which is the safe direction — over-stating the room
prints text over text, while under-stating it only hides a label the tooltip
still carries. The reasoning is written down next to the constant, and the rule
is now normative in spec/api/chart-kind-contract.md (§28 requires decimation
decisions be recorded).

Verification

Ground truth rather than the ladder's own model: parse the rendered SVGs and
intersect every label's text box. Across a 60-case matrix — 8/6/5 stages, both
orientations, both geometries, heights 180 through 520 — 11 cases carried real
overlaps before and none do now
, with label counts unchanged wherever there
was already room (the docs' 440px examples place identically).

The 11 previously-broken cases, with the worst measured overlap in each:

case before after
dense8_v180dense8_v260 (5 heights) 11 overlapping pairs each, up to 31×6px clean
wide6_v180wide6_v220 (3 heights) 10 overlapping pairs each, up to 38×4px clean
basic5_v180, basic5_v200, basic5_bar180 1 overlapping pair each, 31×3px clean

Three new regression tests, each confirmed to fail on main and pass here:

  • test_dropoff_labels_share_the_outside_lane_at_half_the_stage_pitch — sweeps
    plot heights and asserts no two labels sharing the lane are closer than a line
  • test_a_wide_inside_label_hides_the_dropoff_it_would_reach_into
  • test_plot_box_estimate_subtracts_a_fixed_chrome_band

plus test_a_roomy_funnel_still_draws_every_dropoff_label and
test_horizontal_dropoff_labels_keep_their_own_row guarding against
over-correction.

Gates: tests/test_funnel.py 112 passed; full suite 4114 passed / 5 skipped
with one pre-existing unrelated failure
(tests/pyplot/test_pdsh_gap_features.py::test_pandas_datetime_series_plot_completes,
a matplotlib ticker.py NotImplementedError that fails identically on
unmodified main); ruff check, ruff format --check and the pre-commit ruff
hooks pass. Also confirmed in a live browser that the client honours the same
decisions as the static exporters.

Summary by CodeRabbit

  • Bug Fixes

    • Improved funnel chart label positioning to reduce overlap and improve readability.
    • Value labels now take priority when space is limited.
    • Drop-off percentages may be hidden in cramped charts while value labels remain visible.
    • Preserved numeric values in tooltips and chart events.
    • Improved label placement across vertical and horizontal funnel layouts.
  • Documentation

    • Added guidance on label collision handling and adjustments for displaying omitted labels.

Value labels sit at the stage centers and drop-off labels at the boundaries
between them, so the two ladders interleave at HALF the stage pitch. Both were
budgeted against a whole one, so every pitch between one and two line heights
admitted labels that then piled onto each other — an eight-stage funnel at
260px drew all fifteen on top of one another while each ladder believed it had
room.

A drop-off label now keeps its row only where neither neighbouring value label
reaches into the span it starts at. The value label wins that tie: it is the
datum, the drop-off is arithmetic derived from it, and every number stays in
the tooltip and the event payload either way. Horizontal funnels are exempt —
their value labels ride above their own segment rather than in the boundary
lane — and a roomy chart is unchanged.

The ladder's plot-size estimate was optimistic in the same direction. It
scaled the chart height by 0.85, but the title band, tick labels and margins
cost a roughly fixed ~90px rather than a fixed fraction, so at height 180 it
claimed 153px of a real 92px plot and placed labels that could not fit. It now
subtracts the band, which is pessimistic on a tall chart — the safe direction,
since over-stating the room prints text over text while under-stating it only
hides a label the tooltip still carries.

Verified by parsing the rendered SVGs and intersecting every label box across
a 60-case matrix (8/6/5 stages, both orientations, both geometries, heights
180-520): 11 cases carried real overlaps before, none do now, and label counts
are untouched wherever there was already room.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8a1350a0-37ae-4030-b3b2-6e858bd310c3

📥 Commits

Reviewing files that changed from the base of the PR and between 8f850ce and f0f2757.

📒 Files selected for processing (1)
  • docs/charts/funnel-chart.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/charts/funnel-chart.md

📝 Walkthrough

Walkthrough

The funnel chart now estimates usable plot space, interleaves value and drop-off labels, and hides colliding drop-off labels when required. The contract, implementation, tests, documentation, and changelog describe the updated behavior.

Changes

Funnel label layout

Layer / File(s) Summary
Plot-space contract and integration
python/xy/_funnel.py, python/xy/marks.py, spec/api/chart-kind-contract.md
Funnel label placement uses pessimistic plot-size estimation and classifies labels as inside, outside, or hidden.
Collision-aware label placement
python/xy/_funnel.py
Vertical value labels track their occupied reach. Drop-off labels use interleaved spacing and yield to value labels when they would overlap.
Layout validation and documentation
tests/test_funnel.py, docs/charts/funnel-chart.md, CHANGELOG.md
Tests cover spacing, suppression, horizontal placement, and plot estimation. Documentation and the changelog describe the updated behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Chart
  participant marks
  participant FunnelLayout
  participant TooltipEvents
  Chart->>marks: pass chart dimensions
  marks->>FunnelLayout: estimate plot size and place labels
  FunnelLayout->>FunnelLayout: interleave label rows and suppress collisions
  FunnelLayout-->>Chart: render visible labels
  FunnelLayout-->>TooltipEvents: retain numeric label data
Loading

Possibly related PRs

  • reflex-dev/xy#474: Introduced the funnel label-layout implementation and related documentation, contracts, and tests refined by this PR.
  • reflex-dev/xy#477: Updated release documentation for related funnel chart changes.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing overlapping labels in short vertical funnel charts.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/funnel-label-collision

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/xy/marks.py`:
- Line 1021: Update the dimension handling in the surrounding _svg.layout() flow
so configured float width and height values are accepted as concrete chart
dimensions, not replaced by the default 640×400 values. Ensure the
_funnel.estimate_plot_px(plot_w, plot_h) call receives the same effective
dimensions later used by the renderer, while preserving existing behavior for
unsupported or absent dimensions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bc432bf0-b911-477f-8db1-e29fb6f42918

📥 Commits

Reviewing files that changed from the base of the PR and between 659888c and 8f850ce.

📒 Files selected for processing (6)
  • CHANGELOG.md
  • docs/charts/funnel-chart.md
  • python/xy/_funnel.py
  • python/xy/marks.py
  • spec/api/chart-kind-contract.md
  • tests/test_funnel.py

Comment thread python/xy/marks.py

@reflex-reviewer reflex-reviewer 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.

💬 Comments

Reviewed 8f850cede8

This PR revises funnel label budgeting so vertical value/drop-off ladders account for half-stage spacing, adds a conservative fixed chrome estimate, and documents/tests the decimation rule. The targeted funnel suite passes. I also rendered the same deterministic 8-stage, 260px titled funnel at merge base and head: the base has 15 labels with a 10.49px minimum outside-lane gap, while the head keeps 9 labels with a 20.99px minimum gap. The matched screenshots show the intended short-chart improvement. One narrow boundary remains where the estimate can still admit overlapping labels.

Visual evidence

Before — merge base 659888c — titled 8-stage vertical funnel at 600×260, with value/drop-off labels overlapping.

After — PR head 8f850ce — identical titled 8-stage vertical funnel at 600×260, with cramped drop-off labels removed.

What was executed
  • uv run pytest tests/test_funnel.py -q ✅ — 112 passed.
  • uv run python <SVG outside-lane gap probe for 8 stages, height=260, merge base> ❌ exit 1 — Reproduced old behavior: 15 labels, minimum outside-lane baseline gap 10.49px vs 16.80px required.
  • uv run python <SVG outside-lane gap probe for 8 stages, height=260, PR head> ✅ — Verified claimed short-chart fix: 9 labels, minimum outside-lane baseline gap 20.99px.
  • uv run python <SVG outside-lane gap probe for 8 stages, height=359, PR head> ❌ exit 1 — Found remaining threshold overlap: 15 labels, minimum outside-lane baseline gap 16.33px vs 16.80px required.

Outside the diff

  • 🟡 Keep the plot-height estimate conservative at the admission thresholdpython/xy/_funnel.py:65
    The fixed 90px subtraction is still slightly optimistic for the titled SVG layout at some heights. For the documented 8-stage/default-12px scenario at chart height 359, estimate_plot_px gives 269px, so line 485 admits all drop-offs (269 / 8 / 2 = 16.8125px >= 16.8px). The actual SVG plot is only 277px high after the funnel’s own stage padding, yielding adjacent outside-label baselines just 16.33px apart. The targeted SVG probe therefore fails (outside_min_gap=16.33px required=16.80px, pair -69% / 9,100 1%). Because the estimate is explicitly required to be pessimistic, increase the chrome allowance (or derive a stricter effective plot budget including renderer padding) so this threshold case hides the drop-offs rather than retaining overlap. A regression test should exercise the composed/rendered chart near this transition, not only decide_labels with its estimated plot_px.

/review re-run · /review skip mute this PR · label review-deep for a deeper model

@reflex-reviewer

reflex-reviewer Bot commented Aug 9, 2026

Copy link
Copy Markdown

Reflex Reviewer — ✅ Looks good at f0f2757547 (0 inline, 0 other findings).

@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 109 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing alek/funnel-label-collision (f0f2757) with main (659888c)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All reported issues were addressed across 6 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread docs/charts/funnel-chart.md Outdated
…re them

The label-ladder note listed show_dropoff=False alongside more height and a
smaller label_size as ways to get the drop-off labels back, but it is the
opt-out, not a way to recover the room.

@reflex-reviewer reflex-reviewer 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.

✅ Looks good

Reviewed f0f2757547

This PR makes vertical funnel label placement account for the half-stage spacing between value and drop-off rows, and replaces the optimistic proportional plot-height estimate with a conservative fixed chrome allowance. It also documents the decimation rule and adds focused regression coverage. I ran the five new targeted tests successfully and reproduced the dense 8-stage, 260px case on merge base 659888c versus head f0f2757: the base retained all drop-off rows and visibly crowded the value ladder, while the head hides those derived labels and leaves the values separated. The matched native PNG exports are attached.

Visual evidence

Before — merge base 659888c — dense 8-stage vertical funnel at 520×260; value and drop-off rows crowd together.

Before — merge base 659888c — dense 8-stage vertical funnel at 520×260; value and drop-off rows crowd together.

After — PR head f0f2757 — identical funnel; derived drop-off rows are hidden and value labels remain separated.

After — PR head f0f2757 — identical funnel; derived drop-off rows are hidden and value labels remain separated.

What was executed
  • .venv/bin/pytest tests/test_funnel.py -q -k 'dropoff_labels_share or roomy_funnel or wide_inside or horizontal_dropoff or plot_box_estimate' ❌ exit 127 — Initial targeted-test attempt; the checkout had no pre-existing virtual environment.
  • uv run pytest tests/test_funnel.py -q -k 'dropoff_labels_share or roomy_funnel or wide_inside or horizontal_dropoff or plot_box_estimate' ✅ — All five focused regression tests passed (107 unrelated tests deselected).
  • uv run python -c '<render dense 8-stage 520x260 funnel to PNG>' ✅ — Run in separate merge-base worktree 659888c; generated the before screenshot.
  • uv run python -c '<render dense 8-stage 520x260 funnel to PNG>' ✅ — Run at PR head f0f2757 with identical data and dimensions; generated the after screenshot.
  • uv run python -c '<assert dense-case drop-off placement states>' ✅ — On merge base, confirmed at least one (in fact all seven) drop-off labels remained outside; on head, confirmed all seven are hidden for this cramped case.

/review re-run · /review skip mute this PR · label review-deep for a deeper model

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