Skip to content

fix(geometry): stop emitting NaN when start and end coincide - #210

Merged
Eliav2 merged 2 commits into
mainfrom
fix/nan-geometry
Aug 8, 2026
Merged

fix(geometry): stop emitting NaN when start and end coincide#210
Eliav2 merged 2 commits into
mainfrom
fix/nan-geometry

Conversation

@Eliav2

@Eliav2 Eliav2 commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Closes #139, closes #171, closes #192.

The bug

Math.atan(absDy / absDx) is NaN when both deltas are zero, and that NaN propagates into every downstream coordinate:

M 16 16 C 16 16, NaN NaN, NaN NaN

The head and tail transform attributes go with it, which is what #192 shows.

Both deltas are zero whenever start and end sit at the same point. Critically, that includes elements not yet measured on the first render, which is why #192 hits it with dynamically mounted arrows rather than with genuinely overlapping boxes.

The fix

Math.atan2(absDy, absDx).

Verified identical to Math.atan(absDy / absDx) across all 160k integer pairs from 0 to 400, max difference exactly 0, differing only at (0, 0) where it returns 0 instead of NaN. A zero-length arrow renders flat rather than disappearing.

The fix suggested in #139 is wrong

The reporter proposed:

let headAngel = absDx === 0 ? Math.atan(absDy) : Math.atan(absDy / absDx);

Dividing by zero yields Infinity, not NaN, and Math.atan(Infinity) is already the correct PI/2 for a vertical arrow, so the branch is not needed. Worse, Math.atan(absDy) is wrong:

absDy Math.atan(absDy) correct (PI/2)
1 0.785 1.571
10 1.471 1.571
100 1.561 1.571

It only approaches the right answer as absDy grows, so merging it would have introduced a subtly wrong angle for short vertical arrows. It "seemed to work" in the reporter test because their arrow was long.

Tests

Unskips the test already sitting in the suite waiting for this, and widens it into a sweep over curveness x path x head/tail, since the straight-path branch is only one way in and jsdom reports zero-sized rects for everything. 45 combinations, previously 1.

No change to measured arrows

The equivalence above is a proof over the input domain, but confirmed empirically too. With getBoundingClientRect stubbed to real geometry, the rendered d and transform are byte identical before and after for diagonal, pure horizontal, pure vertical and reverse diagonal cases. Pure vertical matters most, since it is the case that exercises atan(dy / 0).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue that could produce invalid NaN coordinates for arrows with coincident endpoints.
    • Improved arrowhead and tail positioning for straight paths.
    • Added coverage for varied curviness, path types, visibility settings, and animation scenarios.
  • Tests

    • Added interactive issue reproductions covering dynamic mounting, drawing animations, invalid repeat counts, and first-render behavior.

Closes #139, closes #171, closes #192.

atan(absDy / absDx) is NaN when both deltas are zero, and the NaN then
propagates into every coordinate, so the path renders as
  M 16 16 C 16 16, NaN NaN, NaN NaN
and the head and tail transforms go with it. Both deltas are zero whenever
start and end sit at the same point, which includes the very common case of
elements not yet measured on the first render. That is why #192 sees it with
dynamically mounted arrows.

Uses atan2(absDy, absDx). Verified identical to atan(absDy / absDx) across all
160k integer pairs from 0 to 400, differing only at (0, 0) where it returns 0
instead of NaN, so a zero-length arrow renders flat.

Note that the fix proposed in #139, absDx === 0 ? Math.atan(absDy) : ..., is
wrong. Division by zero yields Infinity, not NaN, and atan(Infinity) is already
the correct PI/2 for a vertical arrow. Math.atan(absDy) returns 0.785 for
absDy = 1 and only approaches PI/2 as absDy grows, so it would have introduced
a subtly wrong angle for short vertical arrows.

Unskips the waiting test and widens it to sweep curveness, path and
head/tail combinations, since the straight-path branch is only one way in.

Confirmed measured arrows are untouched: rendered d and transform are byte
identical before and after for diagonal, pure horizontal, pure vertical and
reverse diagonal geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codesandbox

codesandbox Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web EditorVS CodeInsiders

Open Preview

@netlify

netlify Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploy Preview for react-xarrows ready!

Name Link
🔨 Latest commit f2cbb97
🔍 Latest deploy log https://app.netlify.com/projects/react-xarrows/deploys/6a778829d649ba00088fd562
😎 Deploy Preview https://deploy-preview-210--react-xarrows.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 8, 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: 69061bc6-8d72-455a-9d62-6382618d8b2b

📥 Commits

Reviewing files that changed from the base of the PR and between a12128c and f2cbb97.

📒 Files selected for processing (3)
  • __test__/xarrow.test.tsx
  • examples/src/stories/XarrowIssueRepros.stories.tsx
  • src/Xarrow/utils/GetPosition.tsx

📝 Walkthrough

Walkthrough

The change replaces straight-path angle division with Math.atan2, expands NaN regression coverage, and adds six Storybook issue reproductions covering dynamic mounting and animation behavior.

Changes

NaN handling and issue reproductions

Layer / File(s) Summary
Straight-path handling and regression coverage
src/Xarrow/utils/GetPosition.tsx, __test__/xarrow.test.tsx, examples/src/stories/XarrowIssueRepros.stories.tsx
Straight-path head and tail angles now use Math.atan2. Tests and issue reproductions validate SVG output without NaN values across coincident, straight, curved, and endpoint-visibility cases.
Dynamic mounting and animation reproductions
examples/src/stories/XarrowIssueRepros.stories.tsx
Storybook reproductions cover dynamically mounted arrows, headless drawing animation, invalid repeatCount, and first-render or remount animation behavior.

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

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The new Storybook reproductions for #105, #106, and #193 are unrelated to the three linked geometry and initial-render NaN issues. Move the unrelated issue reproductions to separate pull requests, or link their issues and document why they are required here.
✅ Passed checks (4 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 primary geometry fix for coincident arrow endpoints.
Linked Issues check ✅ Passed The geometry fix and regression coverage address coincident straight arrows, valid SVG values, and initial-render NaN cases from [#139], [#171], and [#192].
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/nan-geometry

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

One story per reported bug, reproducing the condition from the report, so the
fixes stay verifiable by eye and by assertion rather than only by unit test.

Four carry a live verdict that reads PASS or FAIL on screen. Each was checked
both ways, by reverting the fix and confirming it flips:

  #139, #171, #192  FAIL with Math.atan restored, PASS with atan2
  #193              FAIL before the animation fix, showing opacity:repeatCount=0

The first attempt at these was not a reproduction at all. The boxes sat at
distinct measured positions, so both deltas were never zero and every story
passed even with the bug present. Re-reading #139 settled it: the screenshot
shows the boxes overlaying one another. Coincident anchors are the trigger, not
a zero absDx, which divides to Infinity and gives a correct PI/2. The stories
now stack the elements, which reproduces exactly what #192 reported:

  d         = M 16 16 C 16 16, NaN NaN, NaN NaN
  transform = translate(NaN,NaN) rotate(NaN) scale(24)

#105 and #106 are visual only, on purpose. Their symptoms depend on a SMIL
endEvent, and a hidden document does not tick SMIL reliably, so an automated
verdict would report PASS whether or not the bug was present. #106 in
particular still read PASS against the pre-fix code, which is why its verdict
was removed rather than kept as false assurance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Eliav2
Eliav2 merged commit 166ab1e into main Aug 8, 2026
6 checks passed
@Eliav2
Eliav2 deleted the fix/nan-geometry branch August 8, 2026 20:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant