Skip to content

fix(RangeWithValue): correct the display of degenerate ranges and add a logarithmic scale - #335

Merged
simbig merged 14 commits into
masterfrom
range-with-value-label-centering
Sep 21, 2026
Merged

simbig merged 14 commits into
masterfrom
range-with-value-label-centering

Conversation

@simbig

@simbig simbig commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

RangeWithValue zeichnete bei degenerierten Eingaben ein irreführendes Bild und positionierte multiplikativ abgeleitete Bereiche auf einer linearen Skala. Der PR behebt die Darstellungsfehler, sichert sie mit Tests ab und ergänzt ein optionales scale-Prop.

Fünf Defekte:

  • expectedMin === expectedMax ließ jeden Wert auf 50 % sitzen, eine Verletzung war damit unsichtbar
  • derselbe Fall zeichnete Grenzlinie und Label doppelt übereinander; jetzt ein Element mit = -Prefix
  • expectedMax < expectedMin setzte das Max-Label auf -200 %; jetzt eine Inline-Fehlermeldung
  • toFixed(2) machte aus dem Mittelwert 0,095 die Anzeige 0,10, die dann neben ihrer eigenen Linie stand
  • ein NaN-Messwert erschien als in-range, weil jeder Vergleich mit NaN false ist

Neu ist scale?: 'linear' | 'logarithmic', Default linear — additiv, kein bestehender Aufruf ändert sich.

Warum eine Kontroll-Ratio eine logarithmische Skala braucht

Die Grenzen entstehen aus Mittelwert und Abweichungsfaktor: 0,076 ÷ 2 = 0,038 und 0,076 × 2 = 0,152. Linear sitzt der geometrische Mittelwert 0,076 bei einem Drittel der Breite — richtig beschriftet, aber optisch links der Mitte. Logarithmisch ist die Mitte der Linie der geometrische Mittelwert.

Mit der Skala wandern auch Puffer und Nähe-Warnung: bufferPercentage ist eine Distanz bei linear, ein Verhältnis bei logarithmic. Der Puffer wird im Log-Raum gerechnet, weil eine kleine untere Grenze sonst unter null rutscht und Math.log davon NaN ist. Bei unterer Grenze oder Messwert ≤ 0 lehnt ein Guard die logarithmische Skala ab.

isOutOfRange bleibt ein blanker Vergleich gegen die Grenzen, von der Skala unberührt.

Ticks tragen keine Zahlen

Zwei Grenzwerte allein zeigen nicht, ob die Achse linear oder logarithmisch ist — an den Ticks wird es ablesbar: gleichmäßig verteilt bei linear, zur unteren Grenze gedrängt bei logarithmic. Dafür braucht keiner von ihnen eine Beschriftung. Ein Bereich über den Faktor vier ist bei einer Kontroll-Ratio der Normalfall, und dort standen die Zahlen so dicht, dass die Zeile mit Min, Max und Mittelwert unlesbar wurde.

Sichtbare Verhaltensänderung: das Mittelwert-Label

Es zeigt nicht mehr immer zwei Nachkommastellen, sondern die Stellen, die der Mittelwert braucht: 0,095 statt 0,10, aber auch 55 statt 55,00. Es gibt eine bekannte Aufrufstelle, und nichts parst das Label. Ein geometrischer Mittelwert wird dabei auf die Stellen seiner Grenzen gerundet, sonst steht bei 0,038 und 0,153 die Zahl 0,0762495901628 — eine Wurzel ist selten als Dezimalbruch darstellbar.

Bewusst nicht behoben
  • Grenzen, die sich nur durch Float-Noise unterscheiden (0.1 + 0.2 gegen 0.3), lösen die Invalid-Bounds-Meldung aus
  • unter 1e-6 wechselt die Anzeige in Exponentialnotation
  • die Warnschwelle des konsumierenden Systems bleibt arithmetisch zentriert und trifft den geometrischen Marker daher nicht; das ist eine fachliche Entscheidung
Abdeckung: 22 Unit-Tests, 8 Stories

scale.test.ts prüft die Tick-Werte und den Mittelwert beider Skalen, index.test.tsx das Gerenderte: Zero-Width-Range, invertierte Grenzen, NaN-Guard, unbeschriftete Ticks, und bei identischen Grenzen 1–100 die Position des Messwerts 4 (30,1 % logarithmisch gegen 3,03 % linear) sowie die Nähe-Warnung bei Messwert 5 (linear gelb, logarithmisch grün).

Stories: Default, EqualBounds, EqualBoundsMet, InvalidBounds, LogarithmicScale, LogarithmicScaleSpanningDecades, LogarithmicScaleWithZeroBound, MissingMeasurement.

Bleibt Draft bis das Review-Feedback da ist. Ein Pre-release steht auf dem alpha-Tag.

🤖 Generated with Claude Code

… wrong

Three cases where the component silently rendered a misleading picture.

percentage() short-circuited to 50% whenever expectedMin equalled
expectedMax, and it did so for every value including actualValue. A value
outside such a range therefore sat directly on top of the boundary it
violated. The scale is only truly degenerate when the value coincides with
the boundary as well — otherwise getBufferedRange already spans boundary to
value — so narrow the guard to that case via isScaleCollapsed.

A zero-width range also drew both boundary labels and both range lines on
top of each other, which rendered blurry. Draw the single boundary once,
prefix it with "=" to say it is one permitted value rather than a span, and
skip the mean, which repeats the same number.

An expectedMax below expectedMin placed the max label at -200%, outside the
container, while the rest of the scale looked healthy. No value can satisfy
such a range, so report it in place instead of charting it.

Labels are now centered with transform: translateX(-50%) rather than a fixed
calc(% - 14px) offset, so centering follows the rendered text width instead
of assuming a 28px label. The story's numeric controls change from range
sliders with step 1 to number inputs, which could not represent the decimal
values these cases are about.

🤖 Generated with Claude Code
…ition

isRangeZero was evaluated twice per section, once for the boundary and
once for the mean.

🤖 Generated with Claude Code
The test only pinned the boundary label. Asserting the value point too
proves the scale actually spans, rather than that one element moved.

🤖 Generated with Claude Code
The mean was rendered with toFixed(2). For bounds of 0.04 and 0.15 it
showed 0.10 while sitting at 0.095, so a value of 0.1 appeared next to
the line instead of on it. The average of two two-decimal bounds needs
three decimals, so two can never be right here.

🤖 Generated with Claude Code
Every comparison with NaN is false, so `actualValue < expectedMin ||
actualValue > expectedMax` reported false for NaN and the value rendered
as a green in-range pill labelled NaN. An infinite bound produced NaN%
positions for the same reason.

Both cases now share the invalid-bounds message instead.

🤖 Generated with Claude Code
@simbig simbig changed the title fix(RangeWithValue): handle degenerate ranges instead of drawing them wrong fix(RangeWithValue): handle degenerate ranges, NaN, and mean decimal mismatch Sep 16, 2026
A range derived multiplicatively from a configured mean and a deviation
factor has that mean as its geometric centre, not its arithmetic one.
Bounds 0.038 and 0.152 come from a mean of 0.076, but (min + max) / 2
labels them 0.095 — a number that appears nowhere in the configuration.

meanType defaults to arithmetic, so existing callers are unaffected.
A geometric mean is undefined for a lower bound of zero or below, which
now reports through the same inline message as the other invalid inputs
instead of silently rendering zero.

🤖 Generated with Claude Code
@simbig simbig changed the title fix(RangeWithValue): handle degenerate ranges, NaN, and mean decimal mismatch feat(RangeWithValue): handle degenerate ranges and let the caller choose the mean type Sep 17, 2026
A multiplicatively derived range cannot be drawn centred on a linear
scale: between 0.038 and 0.152 the geometric mean 0.076 sits at a third
of the width. Labelling it correctly was not enough, it still read as
left of centre.

Replaces meanType with scale, because both the mean and the positioning
follow from it. On a logarithmic scale the midpoint of the drawn line is
the geometric mean, so the mean stops being a second concept.

The buffer moves into log space as well. Subtracting it in value space
drops a small lower bound below zero, and Math.log of a negative number
is NaN.

Proximity colouring stays on the arithmetic range, so it keeps matching
the NeMo evaluation.

🤖 Generated with Claude Code
@simbig simbig changed the title feat(RangeWithValue): handle degenerate ranges and let the caller choose the mean type feat(RangeWithValue): handle degenerate ranges and support a logarithmic scale Sep 17, 2026
The near-bound band was a fraction of the arithmetic span even on a
logarithmic scale. Between 1 and 100 that made 5 a near-miss of the lower
bound, four times the bound itself, while the same fraction at the upper
end covered 90 to 100.

The band is now a fraction of the span in scale space, so it is a
distance on a linear scale and a ratio on a logarithmic one. Linear
behaviour is unchanged: the projection is the identity and the span is
the range it was before.

Moves the scale into its own module. utils.ts had collected colouring,
label width, float noise and scale arithmetic; with two strategies the
arithmetic earns its own file, and it no longer has to import a type
back out of index.tsx.

🤖 Generated with Claude Code
…unds

A geometric mean is rarely representable as a decimal fraction, so
withoutFloatingPointNoise had nothing to cut and labelled the mean of
0.038 and 0.153 as 0.0762495901628.

Rounding happens in meanOfScale rather than in the label, because the
mean also positions its own line.

🤖 Generated with Claude Code
Two bounds alone do not show whether the axis is linear or logarithmic.
Ticks make it visible by comparison: evenly spaced when linear, crowding
towards the lower bound when logarithmic.

Which ticks carry a label follows a rule rather than the positions that
happen to be free: every tick on a linear scale, and the first, second
and fifth subdivision of each logarithmic decade. Only a collision with
a bound, mean or measurement label suppresses one.

🤖 Generated with Claude Code
A tick label sits below the bar, the measurement above it, so the two do
not compete for space and the measurement no longer suppresses a label.

Which subdivisions carry a label depends on the span, because the gaps
within a decade are fixed: 1 to 2 is 30% of it, 8 to 9 only 5%. A scale
showing less than 1.5 decades stretches those gaps and fits 4 and 6 as
well.

A zero-width range gets no ticks at all — for the window 0.029 to 0.03
it produced six of them, labelled 0.0292 and the like.

🤖 Generated with Claude Code
A range spanning a factor of four, as a qPCR control ratio often does,
got labels at 0.02, 0.03 and 0.04 between bounds of 0.012 and 0.048 --
three numbers crowding the row that already carries the bounds and the
mean. Below one decade only the strokes remain, and they may sit denser
because nothing has to fit between them.

Above a decade every stroke now carries a label, so ticks no longer need
a per-tick flag: whether a scale is labelled is a property of its span.

🤖 Generated with Claude Code
@simbig simbig changed the title feat(RangeWithValue): handle degenerate ranges and support a logarithmic scale fix(RangeWithValue): correct the display of degenerate ranges and add a logarithmic scale Sep 17, 2026
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 20.8.0-alpha.5 🎉

The release is available on:

Your semantic-release bot 📦🚀

The bounds and the mean already carry the numbers. Tick labels competed with
them for the same row, which is what the collision filter and the two
subdivision sets existed for. Without labels the strokes may always sit dense.

🤖 Generated with Claude Code
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 20.8.0-alpha.6 🎉

The release is available on:

Your semantic-release bot 📦🚀

Drop the min/max parameters of getBufferedRange — the caller passed values
the function derives itself. Let meanOfScale return a display-ready number
for both scales so withoutFloatingPointNoise stays internal: an arithmetic
mean keeps the decimals it needs, a geometric one follows its bounds.

Cover meanOfScale directly, including bounds in exponential notation, where
decimalsOf would otherwise report no decimals and round the label to zero.

Sort out two stories that the argTypes controls already reach, and drop the
duplicate assertion that unlabelled ticks stay unlabelled.

🤖 Generated with Claude Code
@simbig
simbig requested review from mic-web and a lite review from Copilot and removed request for Copilot September 18, 2026 09:36
@simbig
simbig marked this pull request as ready for review September 21, 2026 07:11
@simbig
simbig merged commit 15ceb0e into master Sep 21, 2026
8 checks passed
@simbig
simbig deleted the range-with-value-label-centering branch September 21, 2026 07:12
github-actions Bot pushed a commit that referenced this pull request Sep 21, 2026
## [20.7.1](v20.7.0...v20.7.1) (2026-09-21)

### Bug Fixes

* **RangeWithValue:** correct the display of degenerate ranges and add a logarithmic scale ([#335](#335)) ([15ceb0e](15ceb0e))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 20.7.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

github-actions Bot pushed a commit that referenced this pull request Sep 21, 2026
# [20.8.0-alpha.7](v20.8.0-alpha.6...v20.8.0-alpha.7) (2026-09-21)

### Bug Fixes

* **RangeWithValue:** correct the display of degenerate ranges and add a logarithmic scale ([#335](#335)) ([15ceb0e](15ceb0e))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 20.8.0-alpha.7 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants