Skip to content

fix: keep getColorFromString finite for long strings (#651) - #717

Open
DPS0340 wants to merge 2 commits into
tscircuit:mainfrom
DPS0340:fix/color-from-string-nan
Open

fix: keep getColorFromString finite for long strings (#651)#717
DPS0340 wants to merge 2 commits into
tscircuit:mainfrom
DPS0340:fix/color-from-string-nan

Conversation

@DPS0340

@DPS0340 DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown

Fixes #651.

Problem

getColorFromString accumulates acc * 31 + charCodeAt(0) on a plain JS number. That grows without bound:

  • it leaves exact-integer range at 11 characters (Number.isSafeInteger false from there on)
  • it overflows to Infinity at 207 characters, and Infinity % 360 is NaN

so the function returns hsl(NaN, 100%, 50%, 1), which is not a parseable colour.

This is reachable, not theoretical. Net ids here are selector paths — the longest in the existing fixtures is group > capacitor.C101 > port.pin1 to U100.VOUT (47 chars, example21.json). Nesting multiplies that:

depth=20  len=199  ok
depth=22  len=215  *** NaN ***

About 22 levels of group nesting is enough to produce an invalid colour in LabelMergingSolver, TraceLabelOverlapAvoidanceSolver and Example28Solver's visualizations.

Fix

Keep the accumulator in signed 32-bit range with | 0, then normalise the hue into [0, 360). That's the standard string-hash formulation and is finite and exact for any input length.

Verified over lengths 1–3000: no NaN, every hue in range, output still deterministic, alpha still honoured.

About the snapshot churn — please read before judging the diff size

I tried to avoid this and could not, honestly. My first attempt folded % 360 into each step, reasoning that (a * 31 + c) % 360 ≡ (a % 360 * 31 + c) % 360 and so no existing hue would change. I tested that claim instead of trusting it, and it was wrong: 18,974 of 20,000 random inputs changed colour.

The reason is the bug itself. That congruence holds for exact integers, but the old accumulator stops being an exact integer at 11 characters — it's already a lossy float. So the "old" hues for anything longer than 10 characters were derived from accumulated floating-point error, and there is no fix that preserves them. Any correct implementation changes those colours.

So the 102 regenerated snapshots are unavoidable. To make them reviewable, I verified programmatically what actually changed:

  • 103 snapshots changed. 99 differ only inside hsl(...) values — strip every hsl(...) and the files are byte-identical.
  • The remaining 4 differ only in element ordering (example40, bug-report-20260707T141025Z — same multiset of elements, just reordered) or trailing whitespace inside the embedded <script> (repro-example35-..., repro-rp2040-gamepad-...).
  • No coordinate, path, or geometry value changed anywhere.

Method, if you want to re-run it: strip hsl\([^)]*\) and collapse runs of whitespace in both the old and new snapshot, then compare.

Full suite: 144 pass / 0 fail / 4 skip. Biome clean.

If you'd rather not take the churn

Reasonable position, and I'd understand it. Two alternatives, happy to switch:

  1. Clamp instead of rehash — leave the hash alone and guard with Number.isFinite(hash) ? hash % 360 : 0. Zero snapshot changes, but colours stay derived from float error and every string past ~207 chars collapses to the same hue.
  2. Close this and treat getColorFromString returns hsl(NaN, ...) for very long strings #651 as won't-fix if the visualization colours aren't worth the diff.

Diff is 1 source file plus mechanical snapshot regeneration.

@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
schematic-trace-solver Ready Ready Preview, Comment Jul 26, 2026 3:52am

Request Review

@DPS0340

DPS0340 commented Jul 25, 2026

Copy link
Copy Markdown
Author

Re-verified the snapshot churn on this PR independently, since 103 changed files is a lot to take on trust and I'd rather a reviewer not have to derive this themselves.

Automated check — strip every hsl(...) value and collapse whitespace, then compare each regenerated snapshot against main:

snapshots changed:                      102
differing beyond hsl()/whitespace:        4

The four:

bug-report-20260707T141025Z.snap.svg
example40.snap.svg
repro-example35-minimize-trace-crossing.snap.svg
repro-rp2040-gamepad-trace-alignment.snap.svg

All four are ordering or trailing-whitespace only. Sorting the elements of each file and comparing gives an identical multiset:

bug-report-20260707T141025Z.snap.svg: same element multiset = True
example40.snap.svg:                   same element multiset = True

The diff on the first is 6 lines, all of the form "this <polyline> moved earlier in the document" — same data-points, same coordinates. The other two differ only in trailing spaces inside the embedded <script>.

No coordinate, path, or geometry value changes anywhere in the 102 files.

On why ordering shifts at all: getColorFromString is used exclusively for strokeColor / fill in the visualize() methods — LabelMergingSolver.ts:172, TraceLabelOverlapAvoidanceSolver.ts:155, SchematicTraceSingleLineSolver.ts:325, etc. It never feeds a sort comparator, a Record key, or any solver decision, so the routing is untouched; only the order in which the renderer emits some groups moves.

Reproducible if you want to confirm: strip hsl\([^)]*\), collapse runs of whitespace, compare; then for any file still differing, sort the elements and compare as a multiset.

DPS0340 added 2 commits July 26, 2026 12:52
The hash accumulator grows without bound: acc * 31 + code leaves exact
integer range at 11 characters and overflows to Infinity past ~200, at
which point Infinity % 360 is NaN and the function returns an unparseable
hsl(NaN, 100%, 50%, 1).

Net ids are selector paths like 'group > capacitor.C101 > port.pin1 to
U100.VOUT', so nested groups reach that length in practice — about 22
levels of nesting is enough.

Keep the accumulator in signed 32-bit range, which stays finite and exact
for any input. Hues change for inputs longer than 10 characters, so
snapshots are regenerated.
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.

getColorFromString returns hsl(NaN, ...) for very long strings

1 participant