From 259ec2dc68afbe0f83b181d8e264115d120730d6 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 16 Sep 2026 10:12:22 +0200 Subject: [PATCH 01/14] fix(RangeWithValue): handle degenerate ranges instead of drawing them wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/components.ts | 9 +++- src/RangeWithValue/index.stories.tsx | 42 ++++++++++++++++-- src/RangeWithValue/index.test.tsx | 65 ++++++++++++++++++++++++++++ src/RangeWithValue/index.tsx | 38 ++++++++++------ 4 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 src/RangeWithValue/index.test.tsx diff --git a/src/RangeWithValue/components.ts b/src/RangeWithValue/components.ts index 9d28e93d..e3772975 100644 --- a/src/RangeWithValue/components.ts +++ b/src/RangeWithValue/components.ts @@ -59,11 +59,16 @@ export const LabelWrapper = styled.div` height: 18px; `; +export const InvalidRange = styled.span` + color: ${(props) => props.theme.errorColor}; + font-size: 12px; +`; + export const Label = styled.span<{ left: string }>` position: absolute; left: ${({ left }) => left}; top: 9px; - min-width: 28px; - text-align: center; + transform: translateX(-50%); + white-space: nowrap; font-size: 12px; `; diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index dc2f1f7b..befc2d25 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -8,13 +8,13 @@ export default { component: RangeWithValue, argTypes: { expectedMin: { - control: { type: 'range', min: 0, max: 200, step: 1 }, + control: { type: 'number', step: 0.001 }, }, expectedMax: { - control: { type: 'range', min: 0, max: 200, step: 1 }, + control: { type: 'number', step: 0.001 }, }, actualValue: { - control: { type: 'range', min: 0, max: 200, step: 1 }, + control: { type: 'number', step: 0.001 }, }, rangeType: { control: { type: 'select', options: ['closed', 'open-ended'] }, @@ -47,3 +47,39 @@ Default.args = { rangeType: 'closed', showMean: false, }; + +export const ThreeDecimals = Template.bind({}); +ThreeDecimals.args = { + expectedMin: 0, + expectedMax: 0.029, + actualValue: 0.03, + rangeType: 'open-ended', + showMean: false, +}; + +export const EqualBounds = Template.bind({}); +EqualBounds.args = { + expectedMin: 0.029, + expectedMax: 0.029, + actualValue: 0.03, + rangeType: 'open-ended', + showMean: false, +}; + +export const InvalidBounds = Template.bind({}); +InvalidBounds.args = { + expectedMin: 0.029, + expectedMax: 0.024, + actualValue: 0.031, + rangeType: 'open-ended', + showMean: false, +}; + +export const EqualBoundsMet = Template.bind({}); +EqualBoundsMet.args = { + expectedMin: 0, + expectedMax: 0, + actualValue: 0, + rangeType: 'open-ended', + showMean: false, +}; diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx new file mode 100644 index 00000000..18d73974 --- /dev/null +++ b/src/RangeWithValue/index.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { RangeWithValue } from './index'; + +describe('RangeWithValue', () => { + it('labels a zero-width range once', () => { + render( + , + ); + + expect(screen.getAllByText('= 0.029')).toHaveLength(1); + }); + + it('separates the value from a zero-width range it lies outside of', () => { + render( + , + ); + + expect(screen.getByText('= 0.029')).toHaveStyle({ left: '0%' }); + }); + + it('centers a range that collapses onto the value', () => { + const { container } = render( + , + ); + + expect(container.innerHTML).not.toContain('NaN'); + }); + + it('reports a maximum below the minimum instead of drawing a scale', () => { + render( + , + ); + + expect( + screen.getByText('Ungültige Grenzwerte: 0.029 ist größer als 0.024', { + exact: false, + }), + ).toBeVisible(); + expect(screen.queryByText('0.031')).not.toBeInTheDocument(); + }); +}); diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index c8f9abb0..a867f7b7 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -7,6 +7,7 @@ import { useTheme } from '../theme'; import { Container, DownwardLine, + InvalidRange, Label, LabelWrapper, RangeLine, @@ -36,6 +37,17 @@ export function RangeWithValue({ }: RangeWithValueProps) { const theme = useTheme(); + if (expectedMax < expectedMin) { + return ( + + + Ungültige Grenzwerte: {expectedMin} ist + größer als {expectedMax} + + + ); + } + const rangeValues = getBufferedRange({ max: Math.max(expectedMax, actualValue), min: Math.min(expectedMin, actualValue), @@ -49,10 +61,11 @@ export function RangeWithValue({ const isNearMin = !isRangeZero && actualValue <= expectedMin + warnThreshold; const isNearMax = !isRangeZero && actualValue >= expectedMax - warnThreshold; const isOutOfRange = actualValue < expectedMin || actualValue > expectedMax; + const isScaleCollapsed = rangeValues.bufferedMax === rangeValues.bufferedMin; const percentage = (val: number) => { - if (isRangeZero) { - return 50; // Special case: range 0 -> always centered + if (isScaleCollapsed) { + return 50; } return ( @@ -72,14 +85,13 @@ export function RangeWithValue({ }); const meanValue = (expectedMin + expectedMax) / 2; - const meanLabelWidth = 18; return ( - - {showMean && ( + {!isRangeZero && } + {showMean && !isRangeZero && ( )} - From 151cc17d3d901a9058e05c120e8f96f609256d7a Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 16 Sep 2026 10:59:51 +0200 Subject: [PATCH 03/14] test(RangeWithValue): assert both ends of a separated zero-width range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 18d73974..3ef404eb 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -30,6 +30,9 @@ describe('RangeWithValue', () => { ); expect(screen.getByText('= 0.029')).toHaveStyle({ left: '0%' }); + expect(screen.getByText('0.03')).toHaveStyle({ + left: 'calc(100% - 17.5px)', + }); }); it('centers a range that collapses onto the value', () => { From 4ab3d44f40cff9cfae030bc64b8b60a53df64168 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 16 Sep 2026 11:32:38 +0200 Subject: [PATCH 04/14] fix(RangeWithValue): label the mean with the decimals it actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 9 +++++++++ src/RangeWithValue/index.test.tsx | 14 ++++++++++++++ src/RangeWithValue/index.tsx | 9 +++++++-- src/RangeWithValue/utils.ts | 5 +++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index befc2d25..0db20993 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -75,6 +75,15 @@ InvalidBounds.args = { showMean: false, }; +export const MeanNeedsMoreDecimals = Template.bind({}); +MeanNeedsMoreDecimals.args = { + expectedMin: 0.04, + expectedMax: 0.15, + actualValue: 0.1, + rangeType: 'closed', + showMean: true, +}; + export const EqualBoundsMet = Template.bind({}); EqualBoundsMet.args = { expectedMin: 0, diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 3ef404eb..28e19e49 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -35,6 +35,20 @@ describe('RangeWithValue', () => { }); }); + it('labels a mean that needs more decimals than its bounds', () => { + render( + , + ); + + expect(screen.getByText('0.095')).toBeVisible(); + }); + it('centers a range that collapses onto the value', () => { const { container } = render( {expectedMax} {showMean && ( )} diff --git a/src/RangeWithValue/utils.ts b/src/RangeWithValue/utils.ts index f2496074..72f03e77 100644 --- a/src/RangeWithValue/utils.ts +++ b/src/RangeWithValue/utils.ts @@ -28,6 +28,11 @@ export function colorByRange({ return theme.successColor; } +/** Averaging 0.04 and 0.15 yields 0.09500000000000001. */ +export function withoutFloatingPointNoise(value: number): number { + return Number(value.toPrecision(12)); +} + export function widthOfValuePoint(value: number): number { const { length } = value.toString(); const minWidth = 20; From 9bc698ae53239f35294f920430087bb2b0431524 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Wed, 16 Sep 2026 14:01:51 +0200 Subject: [PATCH 05/14] fix(RangeWithValue): stop showing a missing measurement as in range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 9 +++++++++ src/RangeWithValue/index.test.tsx | 21 +++++++++++++++++++-- src/RangeWithValue/index.tsx | 16 +++++++++++++--- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index 0db20993..4ad6d42b 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -84,6 +84,15 @@ MeanNeedsMoreDecimals.args = { showMean: true, }; +export const MissingMeasurement = Template.bind({}); +MissingMeasurement.args = { + expectedMin: 0.04, + expectedMax: 0.15, + actualValue: NaN, + rangeType: 'closed', + showMean: false, +}; + export const EqualBoundsMet = Template.bind({}); EqualBoundsMet.args = { expectedMin: 0, diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 28e19e49..bffdfbdc 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -50,7 +50,7 @@ describe('RangeWithValue', () => { }); it('centers a range that collapses onto the value', () => { - const { container } = render( + render( { />, ); - expect(container.innerHTML).not.toContain('NaN'); + expect(screen.getByText('= 0.029')).toHaveStyle({ left: '50%' }); + }); + + it('refuses to call a missing measurement in range', () => { + render( + , + ); + + expect( + screen.getByText('Keine gültigen Zahlenwerte: 0.04 / 0.15 / NaN', { + exact: false, + }), + ).toBeVisible(); }); it('reports a maximum below the minimum instead of drawing a scale', () => { diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index ed749bff..186e285c 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -42,12 +42,22 @@ export function RangeWithValue({ }: RangeWithValueProps) { const theme = useTheme(); - if (expectedMax < expectedMin) { + const invalidInput = (() => { + if (![expectedMin, expectedMax, actualValue].every(Number.isFinite)) { + return `Keine gültigen Zahlenwerte: ${expectedMin} / ${expectedMax} / ${actualValue}`; + } + if (expectedMax < expectedMin) { + return `Ungültige Grenzwerte: ${expectedMin} ist größer als ${expectedMax}`; + } + + return null; + })(); + + if (invalidInput) { return ( - Ungültige Grenzwerte: {expectedMin} ist - größer als {expectedMax} + {invalidInput} ); From f400e622511afda5ab00ceeda45ad06cd1a5c78a Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 10:30:37 +0200 Subject: [PATCH 06/14] feat(RangeWithValue): let the caller choose the mean type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 14 ++++++++++ src/RangeWithValue/index.test.tsx | 38 ++++++++++++++++++++++++++++ src/RangeWithValue/index.tsx | 12 ++++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index 4ad6d42b..257edf08 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -22,6 +22,9 @@ export default { showMean: { control: { type: 'boolean' }, }, + meanType: { + control: { type: 'select', options: ['arithmetic', 'geometric'] }, + }, }, }; @@ -31,6 +34,7 @@ const Template: StoryFn<{ actualValue: number; rangeType: 'closed' | 'open-ended'; showMean: boolean; + meanType: 'arithmetic' | 'geometric'; }> = function Template(args) { return (
@@ -84,6 +88,16 @@ MeanNeedsMoreDecimals.args = { showMean: true, }; +export const GeometricMean = Template.bind({}); +GeometricMean.args = { + expectedMin: 0.038, + expectedMax: 0.152, + actualValue: 0.1, + rangeType: 'closed', + showMean: true, + meanType: 'geometric', +}; + export const MissingMeasurement = Template.bind({}); MissingMeasurement.args = { expectedMin: 0.04, diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index bffdfbdc..816da4f7 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -49,6 +49,44 @@ describe('RangeWithValue', () => { expect(screen.getByText('0.095')).toBeVisible(); }); + it('labels the geometric mean of a multiplicatively derived range', () => { + render( + , + ); + + expect(screen.getByText('0.076')).toBeVisible(); + expect(screen.queryByText('0.095')).not.toBeInTheDocument(); + }); + + it('reports that a lower bound of zero has no geometric mean', () => { + render( + , + ); + + expect( + screen.getByText( + 'Kein geometrischer Mittelwert für eine untere Grenze von 0', + { + exact: false, + }, + ), + ).toBeVisible(); + }); + it('centers a range that collapses onto the value', () => { render( From 7510025c8eec01f39ae1c3734d240f9386ae2f50 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 11:14:00 +0200 Subject: [PATCH 07/14] feat(RangeWithValue): let the caller choose a logarithmic scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 22 ++++++++--- src/RangeWithValue/index.test.tsx | 57 ++++++++++++++++++++++++++-- src/RangeWithValue/index.tsx | 36 +++++++++--------- src/RangeWithValue/utils.ts | 37 +++++++++++++++++- 4 files changed, 123 insertions(+), 29 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index 257edf08..f73deb60 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -22,8 +22,8 @@ export default { showMean: { control: { type: 'boolean' }, }, - meanType: { - control: { type: 'select', options: ['arithmetic', 'geometric'] }, + scale: { + control: { type: 'select', options: ['linear', 'logarithmic'] }, }, }, }; @@ -34,7 +34,7 @@ const Template: StoryFn<{ actualValue: number; rangeType: 'closed' | 'open-ended'; showMean: boolean; - meanType: 'arithmetic' | 'geometric'; + scale: 'linear' | 'logarithmic'; }> = function Template(args) { return (
@@ -88,14 +88,24 @@ MeanNeedsMoreDecimals.args = { showMean: true, }; -export const GeometricMean = Template.bind({}); -GeometricMean.args = { +export const LogarithmicScale = Template.bind({}); +LogarithmicScale.args = { expectedMin: 0.038, expectedMax: 0.152, actualValue: 0.1, rangeType: 'closed', showMean: true, - meanType: 'geometric', + scale: 'logarithmic', +}; + +export const LogarithmicScaleWithZeroBound = Template.bind({}); +LogarithmicScaleWithZeroBound.args = { + expectedMin: 0, + expectedMax: 0.002, + actualValue: 0.001, + rangeType: 'open-ended', + showMean: true, + scale: 'logarithmic', }; export const MissingMeasurement = Template.bind({}); diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 816da4f7..bedbacb7 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -57,7 +57,7 @@ describe('RangeWithValue', () => { actualValue={0.1} rangeType="closed" showMean - meanType="geometric" + scale="logarithmic" />, ); @@ -65,7 +65,56 @@ describe('RangeWithValue', () => { expect(screen.queryByText('0.095')).not.toBeInTheDocument(); }); - it('reports that a lower bound of zero has no geometric mean', () => { + it('centers the mean of a logarithmic scale', () => { + render( + , + ); + + expect(screen.getByText('10')).toHaveStyle({ left: '50%' }); + }); + + it('places a value by its ratio to the bounds on a logarithmic scale', () => { + render( + , + ); + + expect(screen.getByText('10')).toHaveStyle({ + left: 'calc(50% - 12.5px)', + }); + }); + + it('places a value by its distance to the bounds on a linear scale', () => { + render( + , + ); + + expect(screen.getByText('10')).toHaveStyle({ + left: 'calc(9.090909090909092% - 12.5px)', + }); + }); + + it('refuses a logarithmic scale for a lower bound of zero', () => { render( { actualValue={0.001} rangeType="closed" showMean - meanType="geometric" + scale="logarithmic" />, ); expect( screen.getByText( - 'Kein geometrischer Mittelwert für eine untere Grenze von 0', + 'Keine logarithmische Skala für Werte kleiner oder gleich null: 0', { exact: false, }, diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index 79ff3ff4..65a83222 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -17,13 +17,14 @@ import { import { colorByRange, getBufferedRange, + positionOnScale, widthOfValuePoint, withoutFloatingPointNoise, } from './utils'; export type RangeWithValueType = 'closed' | 'open-ended'; -export type RangeWithValueMeanType = 'arithmetic' | 'geometric'; +export type RangeWithValueScale = 'linear' | 'logarithmic'; export type RangeWithValueProps = { expectedMin: number; @@ -32,7 +33,7 @@ export type RangeWithValueProps = { rangeType: RangeWithValueType; bufferPercentage?: number; showMean?: boolean; - meanType?: RangeWithValueMeanType; + scale?: RangeWithValueScale; }; export function RangeWithValue({ @@ -42,7 +43,7 @@ export function RangeWithValue({ rangeType, bufferPercentage = 0.1, showMean, - meanType = 'arithmetic', + scale = 'linear', }: RangeWithValueProps) { const theme = useTheme(); @@ -53,8 +54,11 @@ export function RangeWithValue({ if (expectedMax < expectedMin) { return `Ungültige Grenzwerte: ${expectedMin} ist größer als ${expectedMax}`; } - if (showMean && meanType === 'geometric' && expectedMin <= 0) { - return `Kein geometrischer Mittelwert für eine untere Grenze von ${expectedMin}`; + if (scale === 'logarithmic' && Math.min(expectedMin, actualValue) <= 0) { + return `Keine logarithmische Skala für Werte kleiner oder gleich null: ${Math.min( + expectedMin, + actualValue, + )}`; } return null; @@ -77,25 +81,21 @@ export function RangeWithValue({ expectedMin, expectedMax, bufferPercentage, + scale, }); const warnThreshold = rangeValues.range * bufferPercentage; const isRangeZero = expectedMin === expectedMax; const isNearMin = !isRangeZero && actualValue <= expectedMin + warnThreshold; const isNearMax = !isRangeZero && actualValue >= expectedMax - warnThreshold; const isOutOfRange = actualValue < expectedMin || actualValue > expectedMax; - const isScaleCollapsed = rangeValues.bufferedMax === rangeValues.bufferedMin; - const percentage = (val: number) => { - if (isScaleCollapsed) { - return 50; - } - - return ( - ((val - rangeValues.bufferedMin) / - (rangeValues.bufferedMax - rangeValues.bufferedMin)) * - 100 - ); - }; + const percentage = (value: number) => + positionOnScale({ + value, + bufferedMin: rangeValues.bufferedMin, + bufferedMax: rangeValues.bufferedMax, + scale, + }); const valuePointWidth = widthOfValuePoint(actualValue); const valueColor = colorByRange({ @@ -107,7 +107,7 @@ export function RangeWithValue({ }); const meanValue = - meanType === 'geometric' + scale === 'logarithmic' ? Math.sqrt(expectedMin * expectedMax) : (expectedMin + expectedMax) / 2; diff --git a/src/RangeWithValue/utils.ts b/src/RangeWithValue/utils.ts index 72f03e77..a07f15dc 100644 --- a/src/RangeWithValue/utils.ts +++ b/src/RangeWithValue/utils.ts @@ -1,6 +1,6 @@ import { Theme } from '../theme'; -import { RangeWithValueType } from './index'; +import { RangeWithValueScale, RangeWithValueType } from './index'; export function colorByRange({ isOutOfRange, @@ -49,6 +49,7 @@ export function getBufferedRange({ expectedMin, expectedMax, bufferPercentage, + scale, }: { max: number; min: number; @@ -56,6 +57,7 @@ export function getBufferedRange({ expectedMin: number; expectedMax: number; bufferPercentage: number; + scale: RangeWithValueScale; }): { bufferedMin: number; bufferedMax: number; @@ -71,6 +73,18 @@ export function getBufferedRange({ maxValue = actualValue; } const range = maxValue - minValue; + + if (scale === 'logarithmic') { + const logBuffer = + (Math.log(maxValue) - Math.log(minValue)) * bufferPercentage; + + return { + bufferedMin: Math.exp(Math.log(minValue) - logBuffer), + bufferedMax: Math.exp(Math.log(maxValue) + logBuffer), + range, + }; + } + const buffer = range * bufferPercentage; return { @@ -79,3 +93,24 @@ export function getBufferedRange({ range, }; } + +export function positionOnScale({ + value, + bufferedMin, + bufferedMax, + scale, +}: { + value: number; + bufferedMin: number; + bufferedMax: number; + scale: RangeWithValueScale; +}): number { + if (bufferedMax === bufferedMin) { + return 50; + } + + const project = scale === 'logarithmic' ? Math.log : (raw: number) => raw; + const from = project(bufferedMin); + + return ((project(value) - from) / (project(bufferedMax) - from)) * 100; +} From be424194742adf81531ff8a5baeaad96c0ef0dc7 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 11:20:22 +0200 Subject: [PATCH 08/14] refactor(RangeWithValue): make the proximity warning follow the scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.test.tsx | 33 +++++++++++ src/RangeWithValue/index.tsx | 25 +++++--- src/RangeWithValue/scale.ts | 99 +++++++++++++++++++++++++++++++ src/RangeWithValue/utils.ts | 76 +----------------------- 4 files changed, 149 insertions(+), 84 deletions(-) create mode 100644 src/RangeWithValue/scale.ts diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index bedbacb7..810e451e 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -1,6 +1,8 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; +import { THEME } from '../theme'; + import { RangeWithValue } from './index'; describe('RangeWithValue', () => { @@ -114,6 +116,37 @@ describe('RangeWithValue', () => { }); }); + it('warns about a value within a tenth of the span of a linear bound', () => { + render( + , + ); + + expect(screen.getByText('5')).toHaveStyle({ + background: THEME.warningColor, + }); + }); + + it('warns about a value within a tenth of the ratio of a logarithmic bound', () => { + render( + , + ); + + expect(screen.getByText('5')).toHaveStyle({ + background: THEME.successColor, + }); + }); + it('refuses a logarithmic scale for a lower bound of zero', () => { render( = expectedMax - warnThreshold; + const isNearMin = + !isRangeZero && + projectedValue <= projectOntoScale(expectedMin, scale) + warnThreshold; + const isNearMax = + !isRangeZero && + projectedValue >= projectOntoScale(expectedMax, scale) - warnThreshold; const isOutOfRange = actualValue < expectedMin || actualValue > expectedMax; const percentage = (value: number) => @@ -106,10 +116,7 @@ export function RangeWithValue({ theme, }); - const meanValue = - scale === 'logarithmic' - ? Math.sqrt(expectedMin * expectedMax) - : (expectedMin + expectedMax) / 2; + const meanValue = meanOfScale({ expectedMin, expectedMax, scale }); return ( diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts new file mode 100644 index 00000000..b5a4ed99 --- /dev/null +++ b/src/RangeWithValue/scale.ts @@ -0,0 +1,99 @@ +export type RangeWithValueScale = 'linear' | 'logarithmic'; + +/** On a logarithmic scale, distances are ratios and the buffer is a factor. */ +export function projectOntoScale( + value: number, + scale: RangeWithValueScale, +): number { + return scale === 'logarithmic' ? Math.log(value) : value; +} + +/** Always ensure the value is visible and add 10% padding to the range. */ +export function getBufferedRange({ + max, + min, + actualValue, + expectedMin, + expectedMax, + bufferPercentage, + scale, +}: { + max: number; + min: number; + actualValue: number; + expectedMin: number; + expectedMax: number; + bufferPercentage: number; + scale: RangeWithValueScale; +}): { + bufferedMin: number; + bufferedMax: number; + scaleSpan: number; +} { + let minValue = min; + let maxValue = max; + if (actualValue < expectedMin) { + minValue = actualValue; + maxValue = expectedMax; + } else if (actualValue > expectedMax) { + minValue = expectedMin; + maxValue = actualValue; + } + + const projectedMin = projectOntoScale(minValue, scale); + const projectedMax = projectOntoScale(maxValue, scale); + const scaleSpan = projectedMax - projectedMin; + const buffer = scaleSpan * bufferPercentage; + + if (scale === 'logarithmic') { + return { + bufferedMin: Math.exp(projectedMin - buffer), + bufferedMax: Math.exp(projectedMax + buffer), + scaleSpan, + }; + } + + return { + bufferedMin: projectedMin - buffer, + bufferedMax: projectedMax + buffer, + scaleSpan, + }; +} + +export function positionOnScale({ + value, + bufferedMin, + bufferedMax, + scale, +}: { + value: number; + bufferedMin: number; + bufferedMax: number; + scale: RangeWithValueScale; +}): number { + if (bufferedMax === bufferedMin) { + return 50; + } + + const from = projectOntoScale(bufferedMin, scale); + + return ( + ((projectOntoScale(value, scale) - from) / + (projectOntoScale(bufferedMax, scale) - from)) * + 100 + ); +} + +export function meanOfScale({ + expectedMin, + expectedMax, + scale, +}: { + expectedMin: number; + expectedMax: number; + scale: RangeWithValueScale; +}): number { + return scale === 'logarithmic' + ? Math.sqrt(expectedMin * expectedMax) + : (expectedMin + expectedMax) / 2; +} diff --git a/src/RangeWithValue/utils.ts b/src/RangeWithValue/utils.ts index a07f15dc..b5ccdc52 100644 --- a/src/RangeWithValue/utils.ts +++ b/src/RangeWithValue/utils.ts @@ -1,6 +1,6 @@ import { Theme } from '../theme'; -import { RangeWithValueScale, RangeWithValueType } from './index'; +import { RangeWithValueType } from './index'; export function colorByRange({ isOutOfRange, @@ -40,77 +40,3 @@ export function widthOfValuePoint(value: number): number { return Math.round(minWidth + (length - 1) * widthPerChar); } - -/** Always ensure the value is visible and add 10% padding to the range. */ -export function getBufferedRange({ - max, - min, - actualValue, - expectedMin, - expectedMax, - bufferPercentage, - scale, -}: { - max: number; - min: number; - actualValue: number; - expectedMin: number; - expectedMax: number; - bufferPercentage: number; - scale: RangeWithValueScale; -}): { - bufferedMin: number; - bufferedMax: number; - range: number; -} { - let minValue = min; - let maxValue = max; - if (actualValue < expectedMin) { - minValue = actualValue; - maxValue = expectedMax; - } else if (actualValue > expectedMax) { - minValue = expectedMin; - maxValue = actualValue; - } - const range = maxValue - minValue; - - if (scale === 'logarithmic') { - const logBuffer = - (Math.log(maxValue) - Math.log(minValue)) * bufferPercentage; - - return { - bufferedMin: Math.exp(Math.log(minValue) - logBuffer), - bufferedMax: Math.exp(Math.log(maxValue) + logBuffer), - range, - }; - } - - const buffer = range * bufferPercentage; - - return { - bufferedMin: minValue - buffer, - bufferedMax: maxValue + buffer, - range, - }; -} - -export function positionOnScale({ - value, - bufferedMin, - bufferedMax, - scale, -}: { - value: number; - bufferedMin: number; - bufferedMax: number; - scale: RangeWithValueScale; -}): number { - if (bufferedMax === bufferedMin) { - return 50; - } - - const project = scale === 'logarithmic' ? Math.log : (raw: number) => raw; - const from = project(bufferedMin); - - return ((project(value) - from) / (project(bufferedMax) - from)) * 100; -} From 47d989a9016831749057b7b10f52305e6c05a21e Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 11:34:17 +0200 Subject: [PATCH 09/14] fix(RangeWithValue): round a geometric mean to the decimals of its bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 2 +- src/RangeWithValue/index.test.tsx | 5 +++-- src/RangeWithValue/scale.ts | 27 ++++++++++++++++++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index f73deb60..96161ab7 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -91,7 +91,7 @@ MeanNeedsMoreDecimals.args = { export const LogarithmicScale = Template.bind({}); LogarithmicScale.args = { expectedMin: 0.038, - expectedMax: 0.152, + expectedMax: 0.153, actualValue: 0.1, rangeType: 'closed', showMean: true, diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 810e451e..f1458eae 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -55,7 +55,7 @@ describe('RangeWithValue', () => { render( { ); expect(screen.getByText('0.076')).toBeVisible(); - expect(screen.queryByText('0.095')).not.toBeInTheDocument(); + expect(screen.queryByText('0.0955')).not.toBeInTheDocument(); + expect(screen.queryByText('0.0762495901628')).not.toBeInTheDocument(); }); it('centers the mean of a logarithmic scale', () => { diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index b5a4ed99..c8cec363 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -84,6 +84,7 @@ export function positionOnScale({ ); } +/** A geometric mean is rarely representable, so it follows the decimals of its bounds. */ export function meanOfScale({ expectedMin, expectedMax, @@ -93,7 +94,27 @@ export function meanOfScale({ expectedMax: number; scale: RangeWithValueScale; }): number { - return scale === 'logarithmic' - ? Math.sqrt(expectedMin * expectedMax) - : (expectedMin + expectedMax) / 2; + if (scale !== 'logarithmic') { + return (expectedMin + expectedMax) / 2; + } + + const decimals = Math.max( + decimalsOf(expectedMin), + decimalsOf(expectedMax), + MINIMUM_MEAN_DECIMALS, + ); + + return Number(Math.sqrt(expectedMin * expectedMax).toFixed(decimals)); +} + +const MINIMUM_MEAN_DECIMALS = 2; +const EXPONENTIAL_MEAN_DECIMALS = 12; + +function decimalsOf(value: number): number { + const text = value.toString(); + if (text.includes('e')) { + return EXPONENTIAL_MEAN_DECIMALS; + } + + return text.split('.')[1]?.length ?? 0; } From 56bce52516c5eb44e94c16b4c970868a2d8079c4 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 11:45:22 +0200 Subject: [PATCH 10/14] feat(RangeWithValue): give the scale ticks and label them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/components.ts | 14 +++++ src/RangeWithValue/index.test.tsx | 28 ++++++++++ src/RangeWithValue/index.tsx | 41 ++++++++++++-- src/RangeWithValue/scale.test.ts | 59 ++++++++++++++++++++ src/RangeWithValue/scale.ts | 91 +++++++++++++++++++++++++++++++ src/RangeWithValue/utils.ts | 5 -- 6 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/RangeWithValue/scale.test.ts diff --git a/src/RangeWithValue/components.ts b/src/RangeWithValue/components.ts index e3772975..2b750f62 100644 --- a/src/RangeWithValue/components.ts +++ b/src/RangeWithValue/components.ts @@ -23,6 +23,15 @@ export const RangeLine = styled.div<{ left: string }>` background: ${(props) => props.theme.borderColor}; `; +export const ScaleTick = styled.div<{ left: string }>` + position: absolute; + left: ${(props) => props.left}; + top: 100%; + width: 1px; + height: 4px; + background: ${PALETTE.gray5}; +`; + export const ValuePoint = styled.div<{ left: string; width: number; @@ -72,3 +81,8 @@ export const Label = styled.span<{ left: string }>` white-space: nowrap; font-size: 12px; `; + +export const TickLabel = styled(Label)` + font-size: 10px; + color: ${PALETTE.gray6}; +`; diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index f1458eae..1d9c1924 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -68,6 +68,34 @@ describe('RangeWithValue', () => { expect(screen.queryByText('0.0762495901628')).not.toBeInTheDocument(); }); + it('labels the ticks of a logarithmic scale by decade', () => { + render( + , + ); + + expect(screen.getByText('0.1')).toBeVisible(); + expect(screen.queryByText('0.12')).not.toBeInTheDocument(); + }); + + it('labels the ticks of a linear scale in even steps', () => { + render( + , + ); + + expect(screen.getByText('0.12')).toBeVisible(); + }); + it('centers the mean of a logarithmic scale', () => { render( isLabelled) + .map(({ value }) => value) + .filter((value) => + labelledValues.every( + (position) => + Math.abs(percentage(value) - position) > MINIMUM_LABEL_GAP_PERCENT, + ), + ); + return ( + {ticks.map(({ value }) => ( + + ))} {!isRangeZero && ( <> @@ -166,6 +192,11 @@ export function RangeWithValue({ + {ticksToLabel.map((value) => ( + + {value} + + ))} diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts new file mode 100644 index 00000000..8cff81d1 --- /dev/null +++ b/src/RangeWithValue/scale.test.ts @@ -0,0 +1,59 @@ +import { ticksOfScale } from './scale'; + +describe('ticksOfScale', () => { + it('labels the first, second and fifth subdivision of a logarithmic decade', () => { + expect( + ticksOfScale({ bufferedMin: 1, bufferedMax: 20, scale: 'logarithmic' }), + ).toEqual([ + { value: 1, isLabelled: true }, + { value: 2, isLabelled: true }, + { value: 3, isLabelled: false }, + { value: 4, isLabelled: false }, + { value: 5, isLabelled: true }, + { value: 6, isLabelled: false }, + { value: 7, isLabelled: false }, + { value: 8, isLabelled: false }, + { value: 9, isLabelled: false }, + { value: 10, isLabelled: true }, + { value: 20, isLabelled: true }, + ]); + }); + + it('drops the subdivisions of a scale spanning many decades', () => { + expect( + ticksOfScale({ + bufferedMin: 0.001, + bufferedMax: 100, + scale: 'logarithmic', + }), + ).toEqual([ + { value: 0.001, isLabelled: true }, + { value: 0.01, isLabelled: true }, + { value: 0.1, isLabelled: true }, + { value: 1, isLabelled: true }, + { value: 10, isLabelled: true }, + { value: 100, isLabelled: true }, + ]); + }); + + it('spaces a linear scale evenly', () => { + expect( + ticksOfScale({ bufferedMin: 1, bufferedMax: 20, scale: 'linear' }), + ).toEqual([ + { value: 5, isLabelled: true }, + { value: 10, isLabelled: true }, + { value: 15, isLabelled: true }, + { value: 20, isLabelled: true }, + ]); + }); + + it('rounds the step of a linear scale to a readable number', () => { + expect( + ticksOfScale({ + bufferedMin: 0.027, + bufferedMax: 0.164, + scale: 'linear', + }).map(({ value }) => value), + ).toEqual([0.04, 0.06, 0.08, 0.1, 0.12, 0.14, 0.16]); + }); +}); diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index c8cec363..e53ddb53 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -1,5 +1,10 @@ export type RangeWithValueScale = 'linear' | 'logarithmic'; +/** Averaging 0.04 and 0.15 yields 0.09500000000000001. */ +export function withoutFloatingPointNoise(value: number): number { + return Number(value.toPrecision(12)); +} + /** On a logarithmic scale, distances are ratios and the buffer is a factor. */ export function projectOntoScale( value: number, @@ -84,6 +89,92 @@ export function positionOnScale({ ); } +const DECADES_WITHOUT_SUBDIVISION = 4; +const TARGET_LINEAR_TICKS = 6; + +/** + * Ticks make the scale readable and its type visible: evenly spaced when + * linear, crowding towards the lower bound when logarithmic. + */ +export function ticksOfScale({ + bufferedMin, + bufferedMax, + scale, +}: { + bufferedMin: number; + bufferedMax: number; + scale: RangeWithValueScale; +}): Array { + const ticks = + scale === 'logarithmic' + ? logarithmicTicks(bufferedMin, bufferedMax) + : linearTicks(bufferedMin, bufferedMax).map((value) => ({ + value, + isLabelled: true, + })); + + return ticks + .filter(({ value }) => value >= bufferedMin && value <= bufferedMax) + .map(({ value, isLabelled }) => ({ + value: withoutFloatingPointNoise(value), + isLabelled, + })); +} + +export type ScaleTickValue = { value: number; isLabelled: boolean }; + +const LABELLED_SUBDIVISIONS = [1, 2, 5]; + +function logarithmicTicks( + bufferedMin: number, + bufferedMax: number, +): Array { + const firstDecade = Math.floor(Math.log10(bufferedMin)); + const decadeCount = Math.ceil(Math.log10(bufferedMax)) - firstDecade + 1; + const subdivisions = + decadeCount > DECADES_WITHOUT_SUBDIVISION + ? [1] + : [1, 2, 3, 4, 5, 6, 7, 8, 9]; + + return Array.from( + { length: decadeCount }, + (_, index) => firstDecade + index, + ).flatMap((decade) => + subdivisions.map((multiple) => ({ + value: multiple * 10 ** decade, + isLabelled: LABELLED_SUBDIVISIONS.includes(multiple), + })), + ); +} + +function linearTicks(bufferedMin: number, bufferedMax: number): Array { + const step = roundedStep((bufferedMax - bufferedMin) / TARGET_LINEAR_TICKS); + const firstTick = Math.ceil(bufferedMin / step); + const tickCount = Math.floor(bufferedMax / step) - firstTick + 1; + + return Array.from( + { length: Math.max(tickCount, 0) }, + (_, index) => (firstTick + index) * step, + ); +} + +/** Nobody reads a tick at 0.0574, so steps are 1, 2 or 5 times a power of ten. */ +function roundedStep(roughStep: number): number { + const magnitude = 10 ** Math.floor(Math.log10(roughStep)); + const fraction = roughStep / magnitude; + if (fraction < 1.5) { + return magnitude; + } + if (fraction < 3) { + return 2 * magnitude; + } + if (fraction < 7) { + return 5 * magnitude; + } + + return 10 * magnitude; +} + /** A geometric mean is rarely representable, so it follows the decimals of its bounds. */ export function meanOfScale({ expectedMin, diff --git a/src/RangeWithValue/utils.ts b/src/RangeWithValue/utils.ts index b5ccdc52..43b72047 100644 --- a/src/RangeWithValue/utils.ts +++ b/src/RangeWithValue/utils.ts @@ -28,11 +28,6 @@ export function colorByRange({ return theme.successColor; } -/** Averaging 0.04 and 0.15 yields 0.09500000000000001. */ -export function withoutFloatingPointNoise(value: number): number { - return Number(value.toPrecision(12)); -} - export function widthOfValuePoint(value: number): number { const { length } = value.toString(); const minWidth = 20; From 2030f2f327b5e22bd4136ba6adb5645e394c8ebe Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 12:04:02 +0200 Subject: [PATCH 11/14] feat(RangeWithValue): label more of the logarithmic ticks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.test.tsx | 12 ++++----- src/RangeWithValue/index.tsx | 15 ++++++----- src/RangeWithValue/scale.test.ts | 43 ++++++++++++++++++++++++------- src/RangeWithValue/scale.ts | 10 +++++-- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 1d9c1924..df27ca63 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -117,15 +117,15 @@ describe('RangeWithValue', () => { , ); - expect(screen.getByText('10')).toHaveStyle({ - left: 'calc(50% - 12.5px)', + expect(screen.getByText('4')).toHaveStyle({ + left: 'calc(30.10299956639812% - 10px)', }); }); @@ -134,14 +134,14 @@ describe('RangeWithValue', () => { , ); - expect(screen.getByText('10')).toHaveStyle({ - left: 'calc(9.090909090909092% - 12.5px)', + expect(screen.getByText('4')).toHaveStyle({ + left: 'calc(3.0303030303030303% - 10px)', }); }); diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index dc4854a4..fd6b816f 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -27,7 +27,7 @@ import { } from './scale'; import { colorByRange, widthOfValuePoint } from './utils'; -const MINIMUM_LABEL_GAP_PERCENT = 10; +const MINIMUM_LABEL_GAP_PERCENT = 7; export type RangeWithValueType = 'closed' | 'open-ended'; @@ -120,13 +120,14 @@ export function RangeWithValue({ const meanValue = meanOfScale({ expectedMin, expectedMax, scale }); - const ticks = ticksOfScale({ - bufferedMin: rangeValues.bufferedMin, - bufferedMax: rangeValues.bufferedMax, - scale, - }); + const ticks = isRangeZero + ? [] + : ticksOfScale({ + bufferedMin: rangeValues.bufferedMin, + bufferedMax: rangeValues.bufferedMax, + scale, + }); const labelledValues = [ - actualValue, expectedMin, ...(isRangeZero ? [] : [expectedMax]), ...(showMean && !isRangeZero ? [meanValue] : []), diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts index 8cff81d1..2255a952 100644 --- a/src/RangeWithValue/scale.test.ts +++ b/src/RangeWithValue/scale.test.ts @@ -1,21 +1,44 @@ import { ticksOfScale } from './scale'; describe('ticksOfScale', () => { - it('labels the first, second and fifth subdivision of a logarithmic decade', () => { - expect( - ticksOfScale({ bufferedMin: 1, bufferedMax: 20, scale: 'logarithmic' }), - ).toEqual([ + it('labels the subdivisions spread across a logarithmic decade', () => { + const ticks = ticksOfScale({ + bufferedMin: 1, + bufferedMax: 100, + scale: 'logarithmic', + }); + + expect(ticks.filter(({ isLabelled }) => isLabelled)).toEqual([ { value: 1, isLabelled: true }, { value: 2, isLabelled: true }, - { value: 3, isLabelled: false }, - { value: 4, isLabelled: false }, + { value: 3, isLabelled: true }, { value: 5, isLabelled: true }, - { value: 6, isLabelled: false }, - { value: 7, isLabelled: false }, - { value: 8, isLabelled: false }, - { value: 9, isLabelled: false }, + { value: 7, isLabelled: true }, { value: 10, isLabelled: true }, { value: 20, isLabelled: true }, + { value: 30, isLabelled: true }, + { value: 50, isLabelled: true }, + { value: 70, isLabelled: true }, + { value: 100, isLabelled: true }, + ]); + expect(ticks).toHaveLength(19); + }); + + it('labels more subdivisions of a decade stretched over the whole scale', () => { + expect( + ticksOfScale({ + bufferedMin: 0.033, + bufferedMax: 0.176, + scale: 'logarithmic', + }), + ).toEqual([ + { value: 0.04, isLabelled: true }, + { value: 0.05, isLabelled: true }, + { value: 0.06, isLabelled: true }, + { value: 0.07, isLabelled: true }, + { value: 0.08, isLabelled: false }, + { value: 0.09, isLabelled: false }, + { value: 0.1, isLabelled: true }, ]); }); diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index e53ddb53..1882b7c3 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -123,7 +123,9 @@ export function ticksOfScale({ export type ScaleTickValue = { value: number; isLabelled: boolean }; -const LABELLED_SUBDIVISIONS = [1, 2, 5]; +const LABELLED_SUBDIVISIONS = [1, 2, 3, 5, 7]; +const LABELLED_SUBDIVISIONS_OF_A_STRETCHED_DECADE = [1, 2, 3, 4, 5, 6, 7]; +const STRETCHED_DECADE_SPAN = 1.5; function logarithmicTicks( bufferedMin: number, @@ -135,6 +137,10 @@ function logarithmicTicks( decadeCount > DECADES_WITHOUT_SUBDIVISION ? [1] : [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const labelledSubdivisions = + Math.log10(bufferedMax / bufferedMin) < STRETCHED_DECADE_SPAN + ? LABELLED_SUBDIVISIONS_OF_A_STRETCHED_DECADE + : LABELLED_SUBDIVISIONS; return Array.from( { length: decadeCount }, @@ -142,7 +148,7 @@ function logarithmicTicks( ).flatMap((decade) => subdivisions.map((multiple) => ({ value: multiple * 10 ** decade, - isLabelled: LABELLED_SUBDIVISIONS.includes(multiple), + isLabelled: labelledSubdivisions.includes(multiple), })), ); } From 747bb74f92039e7d75c46752fa32fe2cec3b0559 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 13:17:14 +0200 Subject: [PATCH 12/14] fix(RangeWithValue): drop the tick labels of a narrow logarithmic scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 10 +++ src/RangeWithValue/index.test.tsx | 20 +++++- src/RangeWithValue/index.tsx | 24 ++++--- src/RangeWithValue/scale.test.ts | 93 ++++++++++++++-------------- src/RangeWithValue/scale.ts | 73 ++++++++++++++-------- 5 files changed, 134 insertions(+), 86 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index 96161ab7..0a8b68f0 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -98,6 +98,16 @@ LogarithmicScale.args = { scale: 'logarithmic', }; +export const LogarithmicScaleSpanningDecades = Template.bind({}); +LogarithmicScaleSpanningDecades.args = { + expectedMin: 0.01, + expectedMax: 10, + actualValue: 1.5, + rangeType: 'closed', + showMean: true, + scale: 'logarithmic', +}; + export const LogarithmicScaleWithZeroBound = Template.bind({}); LogarithmicScaleWithZeroBound.args = { expectedMin: 0, diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index df27ca63..3d7964e1 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -68,7 +68,7 @@ describe('RangeWithValue', () => { expect(screen.queryByText('0.0762495901628')).not.toBeInTheDocument(); }); - it('labels the ticks of a logarithmic scale by decade', () => { + it('leaves the ticks unlabelled when a logarithmic scale spans less than a decade', () => { render( { />, ); + expect(screen.getByText('0.038')).toBeVisible(); + expect(screen.getByText('0.153')).toBeVisible(); + expect(screen.queryByText('0.1')).not.toBeInTheDocument(); + expect(screen.queryByText('0.04')).not.toBeInTheDocument(); + }); + + it('labels the ticks of a logarithmic scale spanning decades', () => { + render( + , + ); + expect(screen.getByText('0.1')).toBeVisible(); - expect(screen.queryByText('0.12')).not.toBeInTheDocument(); }); it('labels the ticks of a linear scale in even steps', () => { diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index fd6b816f..65d0ea13 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -22,6 +22,7 @@ import { positionOnScale, projectOntoScale, RangeWithValueScale, + scaleHasTickLabels, ticksOfScale, withoutFloatingPointNoise, } from './scale'; @@ -132,20 +133,23 @@ export function RangeWithValue({ ...(isRangeZero ? [] : [expectedMax]), ...(showMean && !isRangeZero ? [meanValue] : []), ].map(percentage); - const ticksToLabel = ticks - .filter(({ isLabelled }) => isLabelled) - .map(({ value }) => value) - .filter((value) => - labelledValues.every( - (position) => - Math.abs(percentage(value) - position) > MINIMUM_LABEL_GAP_PERCENT, - ), - ); + const ticksToLabel = scaleHasTickLabels({ + bufferedMin: rangeValues.bufferedMin, + bufferedMax: rangeValues.bufferedMax, + scale, + }) + ? ticks.filter((value) => + labelledValues.every( + (position) => + Math.abs(percentage(value) - position) > MINIMUM_LABEL_GAP_PERCENT, + ), + ) + : []; return ( - {ticks.map(({ value }) => ( + {ticks.map((value) => ( ))} diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts index 2255a952..1f4c9e7a 100644 --- a/src/RangeWithValue/scale.test.ts +++ b/src/RangeWithValue/scale.test.ts @@ -1,45 +1,24 @@ -import { ticksOfScale } from './scale'; +import { scaleHasTickLabels, ticksOfScale } from './scale'; describe('ticksOfScale', () => { - it('labels the subdivisions spread across a logarithmic decade', () => { - const ticks = ticksOfScale({ - bufferedMin: 1, - bufferedMax: 100, - scale: 'logarithmic', - }); - - expect(ticks.filter(({ isLabelled }) => isLabelled)).toEqual([ - { value: 1, isLabelled: true }, - { value: 2, isLabelled: true }, - { value: 3, isLabelled: true }, - { value: 5, isLabelled: true }, - { value: 7, isLabelled: true }, - { value: 10, isLabelled: true }, - { value: 20, isLabelled: true }, - { value: 30, isLabelled: true }, - { value: 50, isLabelled: true }, - { value: 70, isLabelled: true }, - { value: 100, isLabelled: true }, - ]); - expect(ticks).toHaveLength(19); + it('subdivides the decades of a logarithmic scale', () => { + expect( + ticksOfScale({ + bufferedMin: 1, + bufferedMax: 100, + scale: 'logarithmic', + }), + ).toEqual([1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100]); }); - it('labels more subdivisions of a decade stretched over the whole scale', () => { + it('subdivides a logarithmic scale spanning less than a decade more densely', () => { expect( ticksOfScale({ bufferedMin: 0.033, bufferedMax: 0.176, scale: 'logarithmic', }), - ).toEqual([ - { value: 0.04, isLabelled: true }, - { value: 0.05, isLabelled: true }, - { value: 0.06, isLabelled: true }, - { value: 0.07, isLabelled: true }, - { value: 0.08, isLabelled: false }, - { value: 0.09, isLabelled: false }, - { value: 0.1, isLabelled: true }, - ]); + ).toEqual([0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1]); }); it('drops the subdivisions of a scale spanning many decades', () => { @@ -49,25 +28,13 @@ describe('ticksOfScale', () => { bufferedMax: 100, scale: 'logarithmic', }), - ).toEqual([ - { value: 0.001, isLabelled: true }, - { value: 0.01, isLabelled: true }, - { value: 0.1, isLabelled: true }, - { value: 1, isLabelled: true }, - { value: 10, isLabelled: true }, - { value: 100, isLabelled: true }, - ]); + ).toEqual([0.001, 0.01, 0.1, 1, 10, 100]); }); it('spaces a linear scale evenly', () => { expect( ticksOfScale({ bufferedMin: 1, bufferedMax: 20, scale: 'linear' }), - ).toEqual([ - { value: 5, isLabelled: true }, - { value: 10, isLabelled: true }, - { value: 15, isLabelled: true }, - { value: 20, isLabelled: true }, - ]); + ).toEqual([5, 10, 15, 20]); }); it('rounds the step of a linear scale to a readable number', () => { @@ -76,7 +43,39 @@ describe('ticksOfScale', () => { bufferedMin: 0.027, bufferedMax: 0.164, scale: 'linear', - }).map(({ value }) => value), + }), ).toEqual([0.04, 0.06, 0.08, 0.1, 0.12, 0.14, 0.16]); }); }); + +describe('scaleHasTickLabels', () => { + it('labels a linear scale of any width', () => { + expect( + scaleHasTickLabels({ + bufferedMin: 0.012, + bufferedMax: 0.048, + scale: 'linear', + }), + ).toBe(true); + }); + + it('labels a logarithmic scale spanning a decade', () => { + expect( + scaleHasTickLabels({ + bufferedMin: 0.01, + bufferedMax: 0.1, + scale: 'logarithmic', + }), + ).toBe(true); + }); + + it('leaves a logarithmic scale spanning less than a decade unlabelled', () => { + expect( + scaleHasTickLabels({ + bufferedMin: 0.012, + bufferedMax: 0.048, + scale: 'logarithmic', + }), + ).toBe(false); + }); +}); diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index 1882b7c3..40748f90 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -104,55 +104,74 @@ export function ticksOfScale({ bufferedMin: number; bufferedMax: number; scale: RangeWithValueScale; -}): Array { +}): Array { const ticks = scale === 'logarithmic' ? logarithmicTicks(bufferedMin, bufferedMax) - : linearTicks(bufferedMin, bufferedMax).map((value) => ({ - value, - isLabelled: true, - })); + : linearTicks(bufferedMin, bufferedMax); return ticks - .filter(({ value }) => value >= bufferedMin && value <= bufferedMax) - .map(({ value, isLabelled }) => ({ - value: withoutFloatingPointNoise(value), - isLabelled, - })); + .filter((value) => value >= bufferedMin && value <= bufferedMax) + .map(withoutFloatingPointNoise); } -export type ScaleTickValue = { value: number; isLabelled: boolean }; +const LABELLED_SPAN_IN_DECADES = 1; -const LABELLED_SUBDIVISIONS = [1, 2, 3, 5, 7]; -const LABELLED_SUBDIVISIONS_OF_A_STRETCHED_DECADE = [1, 2, 3, 4, 5, 6, 7]; -const STRETCHED_DECADE_SPAN = 1.5; +/** Below one decade the bounds and the mean already fill the label row. */ +export function scaleHasTickLabels({ + bufferedMin, + bufferedMax, + scale, +}: { + bufferedMin: number; + bufferedMax: number; + scale: RangeWithValueScale; +}): boolean { + return ( + scale !== 'logarithmic' || + Math.log10(bufferedMax / bufferedMin) >= LABELLED_SPAN_IN_DECADES + ); +} + +const SUBDIVISIONS = [1, 2, 3, 5, 7]; +const SUBDIVISIONS_OF_A_NARROW_WINDOW = [1, 2, 3, 4, 5, 6, 7, 8, 9]; function logarithmicTicks( bufferedMin: number, bufferedMax: number, -): Array { +): Array { const firstDecade = Math.floor(Math.log10(bufferedMin)); const decadeCount = Math.ceil(Math.log10(bufferedMax)) - firstDecade + 1; - const subdivisions = - decadeCount > DECADES_WITHOUT_SUBDIVISION - ? [1] - : [1, 2, 3, 4, 5, 6, 7, 8, 9]; - const labelledSubdivisions = - Math.log10(bufferedMax / bufferedMin) < STRETCHED_DECADE_SPAN - ? LABELLED_SUBDIVISIONS_OF_A_STRETCHED_DECADE - : LABELLED_SUBDIVISIONS; return Array.from( { length: decadeCount }, (_, index) => firstDecade + index, ).flatMap((decade) => - subdivisions.map((multiple) => ({ - value: multiple * 10 ** decade, - isLabelled: labelledSubdivisions.includes(multiple), - })), + logarithmicSubdivisions(bufferedMin, bufferedMax, decadeCount).map( + (multiple) => multiple * 10 ** decade, + ), ); } +/** Unlabelled strokes may sit denser, and a narrow window needs them to read as a scale. */ +function logarithmicSubdivisions( + bufferedMin: number, + bufferedMax: number, + decadeCount: number, +): Array { + if (decadeCount > DECADES_WITHOUT_SUBDIVISION) { + return [1]; + } + + return scaleHasTickLabels({ + bufferedMin, + bufferedMax, + scale: 'logarithmic', + }) + ? SUBDIVISIONS + : SUBDIVISIONS_OF_A_NARROW_WINDOW; +} + function linearTicks(bufferedMin: number, bufferedMax: number): Array { const step = roundedStep((bufferedMax - bufferedMin) / TARGET_LINEAR_TICKS); const firstTick = Math.ceil(bufferedMin / step); From 59c0c8a177caf94c6e2321236144921d5c5faea2 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 13:47:22 +0200 Subject: [PATCH 13/14] fix(RangeWithValue): drop the tick labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/components.ts | 5 ---- src/RangeWithValue/index.test.tsx | 29 ++++++------------- src/RangeWithValue/index.tsx | 26 ----------------- src/RangeWithValue/scale.test.ts | 40 ++++----------------------- src/RangeWithValue/scale.ts | 46 +++---------------------------- 5 files changed, 17 insertions(+), 129 deletions(-) diff --git a/src/RangeWithValue/components.ts b/src/RangeWithValue/components.ts index 2b750f62..2641abf8 100644 --- a/src/RangeWithValue/components.ts +++ b/src/RangeWithValue/components.ts @@ -81,8 +81,3 @@ export const Label = styled.span<{ left: string }>` white-space: nowrap; font-size: 12px; `; - -export const TickLabel = styled(Label)` - font-size: 10px; - color: ${PALETTE.gray6}; -`; diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index 3d7964e1..f4fc4a79 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -68,24 +68,7 @@ describe('RangeWithValue', () => { expect(screen.queryByText('0.0762495901628')).not.toBeInTheDocument(); }); - it('leaves the ticks unlabelled when a logarithmic scale spans less than a decade', () => { - render( - , - ); - - expect(screen.getByText('0.038')).toBeVisible(); - expect(screen.getByText('0.153')).toBeVisible(); - expect(screen.queryByText('0.1')).not.toBeInTheDocument(); - expect(screen.queryByText('0.04')).not.toBeInTheDocument(); - }); - - it('labels the ticks of a logarithmic scale spanning decades', () => { + it('leaves the ticks of a logarithmic scale unlabelled', () => { render( { />, ); - expect(screen.getByText('0.1')).toBeVisible(); + expect(screen.getByText('0.01')).toBeVisible(); + expect(screen.getByText('10')).toBeVisible(); + expect(screen.queryByText('0.1')).not.toBeInTheDocument(); }); - it('labels the ticks of a linear scale in even steps', () => { + it('leaves the ticks of a linear scale unlabelled', () => { render( { />, ); - expect(screen.getByText('0.12')).toBeVisible(); + expect(screen.getByText('0.038')).toBeVisible(); + expect(screen.getByText('0.153')).toBeVisible(); + expect(screen.queryByText('0.12')).not.toBeInTheDocument(); }); it('centers the mean of a logarithmic scale', () => { diff --git a/src/RangeWithValue/index.tsx b/src/RangeWithValue/index.tsx index 65d0ea13..a5c743f2 100644 --- a/src/RangeWithValue/index.tsx +++ b/src/RangeWithValue/index.tsx @@ -13,7 +13,6 @@ import { RangeLine, Scale, ScaleTick, - TickLabel, ValuePoint, } from './components'; import { @@ -22,14 +21,11 @@ import { positionOnScale, projectOntoScale, RangeWithValueScale, - scaleHasTickLabels, ticksOfScale, withoutFloatingPointNoise, } from './scale'; import { colorByRange, widthOfValuePoint } from './utils'; -const MINIMUM_LABEL_GAP_PERCENT = 7; - export type RangeWithValueType = 'closed' | 'open-ended'; export type { RangeWithValueScale }; @@ -128,23 +124,6 @@ export function RangeWithValue({ bufferedMax: rangeValues.bufferedMax, scale, }); - const labelledValues = [ - expectedMin, - ...(isRangeZero ? [] : [expectedMax]), - ...(showMean && !isRangeZero ? [meanValue] : []), - ].map(percentage); - const ticksToLabel = scaleHasTickLabels({ - bufferedMin: rangeValues.bufferedMin, - bufferedMax: rangeValues.bufferedMax, - scale, - }) - ? ticks.filter((value) => - labelledValues.every( - (position) => - Math.abs(percentage(value) - position) > MINIMUM_LABEL_GAP_PERCENT, - ), - ) - : []; return ( @@ -197,11 +176,6 @@ export function RangeWithValue({ - {ticksToLabel.map((value) => ( - - {value} - - ))} diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts index 1f4c9e7a..37c2a8e5 100644 --- a/src/RangeWithValue/scale.test.ts +++ b/src/RangeWithValue/scale.test.ts @@ -1,4 +1,4 @@ -import { scaleHasTickLabels, ticksOfScale } from './scale'; +import { ticksOfScale } from './scale'; describe('ticksOfScale', () => { it('subdivides the decades of a logarithmic scale', () => { @@ -8,10 +8,12 @@ describe('ticksOfScale', () => { bufferedMax: 100, scale: 'logarithmic', }), - ).toEqual([1, 2, 3, 5, 7, 10, 20, 30, 50, 70, 100]); + ).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, + ]); }); - it('subdivides a logarithmic scale spanning less than a decade more densely', () => { + it('subdivides a logarithmic scale spanning less than a decade', () => { expect( ticksOfScale({ bufferedMin: 0.033, @@ -47,35 +49,3 @@ describe('ticksOfScale', () => { ).toEqual([0.04, 0.06, 0.08, 0.1, 0.12, 0.14, 0.16]); }); }); - -describe('scaleHasTickLabels', () => { - it('labels a linear scale of any width', () => { - expect( - scaleHasTickLabels({ - bufferedMin: 0.012, - bufferedMax: 0.048, - scale: 'linear', - }), - ).toBe(true); - }); - - it('labels a logarithmic scale spanning a decade', () => { - expect( - scaleHasTickLabels({ - bufferedMin: 0.01, - bufferedMax: 0.1, - scale: 'logarithmic', - }), - ).toBe(true); - }); - - it('leaves a logarithmic scale spanning less than a decade unlabelled', () => { - expect( - scaleHasTickLabels({ - bufferedMin: 0.012, - bufferedMax: 0.048, - scale: 'logarithmic', - }), - ).toBe(false); - }); -}); diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index 40748f90..9515b5b3 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -115,26 +115,7 @@ export function ticksOfScale({ .map(withoutFloatingPointNoise); } -const LABELLED_SPAN_IN_DECADES = 1; - -/** Below one decade the bounds and the mean already fill the label row. */ -export function scaleHasTickLabels({ - bufferedMin, - bufferedMax, - scale, -}: { - bufferedMin: number; - bufferedMax: number; - scale: RangeWithValueScale; -}): boolean { - return ( - scale !== 'logarithmic' || - Math.log10(bufferedMax / bufferedMin) >= LABELLED_SPAN_IN_DECADES - ); -} - -const SUBDIVISIONS = [1, 2, 3, 5, 7]; -const SUBDIVISIONS_OF_A_NARROW_WINDOW = [1, 2, 3, 4, 5, 6, 7, 8, 9]; +const SUBDIVISIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9]; function logarithmicTicks( bufferedMin: number, @@ -142,36 +123,17 @@ function logarithmicTicks( ): Array { const firstDecade = Math.floor(Math.log10(bufferedMin)); const decadeCount = Math.ceil(Math.log10(bufferedMax)) - firstDecade + 1; + const subdivisions = + decadeCount > DECADES_WITHOUT_SUBDIVISION ? [1] : SUBDIVISIONS; return Array.from( { length: decadeCount }, (_, index) => firstDecade + index, ).flatMap((decade) => - logarithmicSubdivisions(bufferedMin, bufferedMax, decadeCount).map( - (multiple) => multiple * 10 ** decade, - ), + subdivisions.map((multiple) => multiple * 10 ** decade), ); } -/** Unlabelled strokes may sit denser, and a narrow window needs them to read as a scale. */ -function logarithmicSubdivisions( - bufferedMin: number, - bufferedMax: number, - decadeCount: number, -): Array { - if (decadeCount > DECADES_WITHOUT_SUBDIVISION) { - return [1]; - } - - return scaleHasTickLabels({ - bufferedMin, - bufferedMax, - scale: 'logarithmic', - }) - ? SUBDIVISIONS - : SUBDIVISIONS_OF_A_NARROW_WINDOW; -} - function linearTicks(bufferedMin: number, bufferedMax: number): Array { const step = roundedStep((bufferedMax - bufferedMin) / TARGET_LINEAR_TICKS); const firstTick = Math.ceil(bufferedMin / step); From 2160fd1b2cd977c3c9a04ad40c148176cc8a42d6 Mon Sep 17 00:00:00 2001 From: Simon Bigelmayr Date: Thu, 17 Sep 2026 15:23:37 +0200 Subject: [PATCH 14/14] refactor(RangeWithValue): tighten the scale module and its stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/RangeWithValue/index.stories.tsx | 34 +++++++-------------------- src/RangeWithValue/index.test.tsx | 15 ------------ src/RangeWithValue/index.tsx | 19 +++++---------- src/RangeWithValue/scale.test.ts | 30 +++++++++++++++++++++++- src/RangeWithValue/scale.ts | 35 ++++++++++------------------ 5 files changed, 55 insertions(+), 78 deletions(-) diff --git a/src/RangeWithValue/index.stories.tsx b/src/RangeWithValue/index.stories.tsx index 0a8b68f0..18635026 100644 --- a/src/RangeWithValue/index.stories.tsx +++ b/src/RangeWithValue/index.stories.tsx @@ -52,20 +52,20 @@ Default.args = { showMean: false, }; -export const ThreeDecimals = Template.bind({}); -ThreeDecimals.args = { - expectedMin: 0, +export const EqualBounds = Template.bind({}); +EqualBounds.args = { + expectedMin: 0.029, expectedMax: 0.029, actualValue: 0.03, rangeType: 'open-ended', showMean: false, }; -export const EqualBounds = Template.bind({}); -EqualBounds.args = { - expectedMin: 0.029, - expectedMax: 0.029, - actualValue: 0.03, +export const EqualBoundsMet = Template.bind({}); +EqualBoundsMet.args = { + expectedMin: 0, + expectedMax: 0, + actualValue: 0, rangeType: 'open-ended', showMean: false, }; @@ -79,15 +79,6 @@ InvalidBounds.args = { showMean: false, }; -export const MeanNeedsMoreDecimals = Template.bind({}); -MeanNeedsMoreDecimals.args = { - expectedMin: 0.04, - expectedMax: 0.15, - actualValue: 0.1, - rangeType: 'closed', - showMean: true, -}; - export const LogarithmicScale = Template.bind({}); LogarithmicScale.args = { expectedMin: 0.038, @@ -126,12 +117,3 @@ MissingMeasurement.args = { rangeType: 'closed', showMean: false, }; - -export const EqualBoundsMet = Template.bind({}); -EqualBoundsMet.args = { - expectedMin: 0, - expectedMax: 0, - actualValue: 0, - rangeType: 'open-ended', - showMean: false, -}; diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx index f4fc4a79..c554192c 100644 --- a/src/RangeWithValue/index.test.tsx +++ b/src/RangeWithValue/index.test.tsx @@ -84,21 +84,6 @@ describe('RangeWithValue', () => { expect(screen.queryByText('0.1')).not.toBeInTheDocument(); }); - it('leaves the ticks of a linear scale unlabelled', () => { - render( - , - ); - - expect(screen.getByText('0.038')).toBeVisible(); - expect(screen.getByText('0.153')).toBeVisible(); - expect(screen.queryByText('0.12')).not.toBeInTheDocument(); - }); - it('centers the mean of a logarithmic scale', () => { render( { if (![expectedMin, expectedMax, actualValue].every(Number.isFinite)) { - return `Keine gültigen Zahlenwerte: ${expectedMin} / ${expectedMax} / ${actualValue}`; + return `Keine gültigen Zahlenwerte: ${expectedMin} / ${expectedMax} / ${actualValue}.`; } if (expectedMax < expectedMin) { - return `Ungültige Grenzwerte: ${expectedMin} ist größer als ${expectedMax}`; + return `Ungültige Grenzwerte: ${expectedMin} ist größer als ${expectedMax}.`; } - if (scale === 'logarithmic' && Math.min(expectedMin, actualValue) <= 0) { - return `Keine logarithmische Skala für Werte kleiner oder gleich null: ${Math.min( - expectedMin, - actualValue, - )}`; + if (scale === 'logarithmic' && lowestValue <= 0) { + return `Keine logarithmische Skala für Werte kleiner oder gleich null: ${lowestValue}.`; } return null; @@ -79,8 +76,6 @@ export function RangeWithValue({ } const rangeValues = getBufferedRange({ - max: Math.max(expectedMax, actualValue), - min: Math.min(expectedMin, actualValue), actualValue, expectedMin, expectedMax, @@ -183,9 +178,7 @@ export function RangeWithValue({ <> {showMean && ( - + )} )} diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts index 37c2a8e5..e3e87ce8 100644 --- a/src/RangeWithValue/scale.test.ts +++ b/src/RangeWithValue/scale.test.ts @@ -1,4 +1,4 @@ -import { ticksOfScale } from './scale'; +import { meanOfScale, ticksOfScale } from './scale'; describe('ticksOfScale', () => { it('subdivides the decades of a logarithmic scale', () => { @@ -49,3 +49,31 @@ describe('ticksOfScale', () => { ).toEqual([0.04, 0.06, 0.08, 0.1, 0.12, 0.14, 0.16]); }); }); + +describe('meanOfScale', () => { + it('keeps the decimals an arithmetic mean needs beyond its bounds', () => { + expect( + meanOfScale({ expectedMin: 0.04, expectedMax: 0.15, scale: 'linear' }), + ).toBe(0.095); + }); + + it('follows the decimals of its bounds on a logarithmic scale', () => { + expect( + meanOfScale({ + expectedMin: 0.038, + expectedMax: 0.153, + scale: 'logarithmic', + }), + ).toBe(0.076); + }); + + it('keeps a geometric mean of bounds written in exponential notation', () => { + expect( + meanOfScale({ + expectedMin: 1e-7, + expectedMax: 1e-5, + scale: 'logarithmic', + }), + ).toBe(0.000001); + }); +}); diff --git a/src/RangeWithValue/scale.ts b/src/RangeWithValue/scale.ts index 9515b5b3..1da21a34 100644 --- a/src/RangeWithValue/scale.ts +++ b/src/RangeWithValue/scale.ts @@ -1,7 +1,7 @@ export type RangeWithValueScale = 'linear' | 'logarithmic'; /** Averaging 0.04 and 0.15 yields 0.09500000000000001. */ -export function withoutFloatingPointNoise(value: number): number { +function withoutFloatingPointNoise(value: number): number { return Number(value.toPrecision(12)); } @@ -13,18 +13,14 @@ export function projectOntoScale( return scale === 'logarithmic' ? Math.log(value) : value; } -/** Always ensure the value is visible and add 10% padding to the range. */ +/** Keeps the value inside the scale and pads it by bufferPercentage on both ends. */ export function getBufferedRange({ - max, - min, actualValue, expectedMin, expectedMax, bufferPercentage, scale, }: { - max: number; - min: number; actualValue: number; expectedMin: number; expectedMax: number; @@ -35,18 +31,14 @@ export function getBufferedRange({ bufferedMax: number; scaleSpan: number; } { - let minValue = min; - let maxValue = max; - if (actualValue < expectedMin) { - minValue = actualValue; - maxValue = expectedMax; - } else if (actualValue > expectedMax) { - minValue = expectedMin; - maxValue = actualValue; - } - - const projectedMin = projectOntoScale(minValue, scale); - const projectedMax = projectOntoScale(maxValue, scale); + const projectedMin = projectOntoScale( + Math.min(expectedMin, actualValue), + scale, + ); + const projectedMax = projectOntoScale( + Math.max(expectedMax, actualValue), + scale, + ); const scaleSpan = projectedMax - projectedMin; const buffer = scaleSpan * bufferPercentage; @@ -92,10 +84,7 @@ export function positionOnScale({ const DECADES_WITHOUT_SUBDIVISION = 4; const TARGET_LINEAR_TICKS = 6; -/** - * Ticks make the scale readable and its type visible: evenly spaced when - * linear, crowding towards the lower bound when logarithmic. - */ +/** Evenly spaced when linear, crowding towards the lower bound when logarithmic. */ export function ticksOfScale({ bufferedMin, bufferedMax, @@ -173,7 +162,7 @@ export function meanOfScale({ scale: RangeWithValueScale; }): number { if (scale !== 'logarithmic') { - return (expectedMin + expectedMax) / 2; + return withoutFloatingPointNoise((expectedMin + expectedMax) / 2); } const decimals = Math.max(