diff --git a/src/RangeWithValue/components.ts b/src/RangeWithValue/components.ts
index 9d28e93d..2641abf8 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;
@@ -59,11 +68,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..18635026 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'] },
@@ -22,6 +22,9 @@ export default {
showMean: {
control: { type: 'boolean' },
},
+ scale: {
+ control: { type: 'select', options: ['linear', 'logarithmic'] },
+ },
},
};
@@ -31,6 +34,7 @@ const Template: StoryFn<{
actualValue: number;
rangeType: 'closed' | 'open-ended';
showMean: boolean;
+ scale: 'linear' | 'logarithmic';
}> = function Template(args) {
return (
@@ -47,3 +51,69 @@ Default.args = {
rangeType: 'closed',
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 EqualBoundsMet = Template.bind({});
+EqualBoundsMet.args = {
+ expectedMin: 0,
+ expectedMax: 0,
+ actualValue: 0,
+ 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 LogarithmicScale = Template.bind({});
+LogarithmicScale.args = {
+ expectedMin: 0.038,
+ expectedMax: 0.153,
+ actualValue: 0.1,
+ rangeType: 'closed',
+ showMean: true,
+ 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,
+ expectedMax: 0.002,
+ actualValue: 0.001,
+ rangeType: 'open-ended',
+ showMean: true,
+ scale: 'logarithmic',
+};
+
+export const MissingMeasurement = Template.bind({});
+MissingMeasurement.args = {
+ expectedMin: 0.04,
+ expectedMax: 0.15,
+ actualValue: NaN,
+ rangeType: 'closed',
+ showMean: false,
+};
diff --git a/src/RangeWithValue/index.test.tsx b/src/RangeWithValue/index.test.tsx
new file mode 100644
index 00000000..c554192c
--- /dev/null
+++ b/src/RangeWithValue/index.test.tsx
@@ -0,0 +1,236 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { THEME } from '../theme';
+
+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%' });
+ expect(screen.getByText('0.03')).toHaveStyle({
+ left: 'calc(100% - 17.5px)',
+ });
+ });
+
+ it('labels a mean that needs more decimals than its bounds', () => {
+ render(
+ ,
+ );
+
+ 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.0955')).not.toBeInTheDocument();
+ expect(screen.queryByText('0.0762495901628')).not.toBeInTheDocument();
+ });
+
+ it('leaves the ticks of a logarithmic scale unlabelled', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('0.01')).toBeVisible();
+ expect(screen.getByText('10')).toBeVisible();
+ expect(screen.queryByText('0.1')).not.toBeInTheDocument();
+ });
+
+ 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('4')).toHaveStyle({
+ left: 'calc(30.10299956639812% - 10px)',
+ });
+ });
+
+ it('places a value by its distance to the bounds on a linear scale', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('4')).toHaveStyle({
+ left: 'calc(3.0303030303030303% - 10px)',
+ });
+ });
+
+ 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(
+ ,
+ );
+
+ expect(
+ screen.getByText(
+ 'Keine logarithmische Skala für Werte kleiner oder gleich null: 0',
+ {
+ exact: false,
+ },
+ ),
+ ).toBeVisible();
+ });
+
+ it('centers a range that collapses onto the value', () => {
+ render(
+ ,
+ );
+
+ 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', () => {
+ 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..06cc70bd 100644
--- a/src/RangeWithValue/index.tsx
+++ b/src/RangeWithValue/index.tsx
@@ -7,16 +7,28 @@ import { useTheme } from '../theme';
import {
Container,
DownwardLine,
+ InvalidRange,
Label,
LabelWrapper,
RangeLine,
Scale,
+ ScaleTick,
ValuePoint,
} from './components';
-import { colorByRange, getBufferedRange, widthOfValuePoint } from './utils';
+import {
+ getBufferedRange,
+ meanOfScale,
+ positionOnScale,
+ projectOntoScale,
+ RangeWithValueScale,
+ ticksOfScale,
+} from './scale';
+import { colorByRange, widthOfValuePoint } from './utils';
export type RangeWithValueType = 'closed' | 'open-ended';
+export type { RangeWithValueScale };
+
export type RangeWithValueProps = {
expectedMin: number;
expectedMax: number;
@@ -24,6 +36,7 @@ export type RangeWithValueProps = {
rangeType: RangeWithValueType;
bufferPercentage?: number;
showMean?: boolean;
+ scale?: RangeWithValueScale;
};
export function RangeWithValue({
@@ -33,34 +46,60 @@ export function RangeWithValue({
rangeType,
bufferPercentage = 0.1,
showMean,
+ scale = 'linear',
}: RangeWithValueProps) {
const theme = useTheme();
+ const lowestValue = Math.min(expectedMin, actualValue);
+ 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}.`;
+ }
+ if (scale === 'logarithmic' && lowestValue <= 0) {
+ return `Keine logarithmische Skala für Werte kleiner oder gleich null: ${lowestValue}.`;
+ }
+
+ return null;
+ })();
+
+ if (invalidInput) {
+ return (
+
+
+ {invalidInput}
+
+
+ );
+ }
+
const rangeValues = getBufferedRange({
- max: Math.max(expectedMax, actualValue),
- min: Math.min(expectedMin, actualValue),
actualValue,
expectedMin,
expectedMax,
bufferPercentage,
+ scale,
});
- const warnThreshold = rangeValues.range * bufferPercentage;
+ const warnThreshold = rangeValues.scaleSpan * bufferPercentage;
+ const projectedValue = projectOntoScale(actualValue, scale);
const isRangeZero = expectedMin === expectedMax;
- const isNearMin = !isRangeZero && actualValue <= expectedMin + warnThreshold;
- const isNearMax = !isRangeZero && actualValue >= 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 = (val: number) => {
- if (isRangeZero) {
- return 50; // Special case: range 0 -> always centered
- }
-
- 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({
@@ -71,16 +110,30 @@ export function RangeWithValue({
theme,
});
- const meanValue = (expectedMin + expectedMax) / 2;
- const meanLabelWidth = 18;
+ const meanValue = meanOfScale({ expectedMin, expectedMax, scale });
+
+ const ticks = isRangeZero
+ ? []
+ : ticksOfScale({
+ bufferedMin: rangeValues.bufferedMin,
+ bufferedMax: rangeValues.bufferedMax,
+ scale,
+ });
return (
+ {ticks.map((value) => (
+
+ ))}
-
- {showMean && (
-
+ {!isRangeZero && (
+ <>
+
+ {showMean && (
+
+ )}
+ >
)}
{
@@ -118,18 +171,16 @@ export function RangeWithValue({
-
diff --git a/src/RangeWithValue/scale.test.ts b/src/RangeWithValue/scale.test.ts
new file mode 100644
index 00000000..e3e87ce8
--- /dev/null
+++ b/src/RangeWithValue/scale.test.ts
@@ -0,0 +1,79 @@
+import { meanOfScale, ticksOfScale } from './scale';
+
+describe('ticksOfScale', () => {
+ it('subdivides the decades of a logarithmic scale', () => {
+ expect(
+ ticksOfScale({
+ bufferedMin: 1,
+ bufferedMax: 100,
+ scale: 'logarithmic',
+ }),
+ ).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', () => {
+ expect(
+ ticksOfScale({
+ bufferedMin: 0.033,
+ bufferedMax: 0.176,
+ scale: 'logarithmic',
+ }),
+ ).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', () => {
+ expect(
+ ticksOfScale({
+ bufferedMin: 0.001,
+ bufferedMax: 100,
+ scale: 'logarithmic',
+ }),
+ ).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([5, 10, 15, 20]);
+ });
+
+ it('rounds the step of a linear scale to a readable number', () => {
+ expect(
+ ticksOfScale({
+ bufferedMin: 0.027,
+ bufferedMax: 0.164,
+ scale: 'linear',
+ }),
+ ).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
new file mode 100644
index 00000000..1da21a34
--- /dev/null
+++ b/src/RangeWithValue/scale.ts
@@ -0,0 +1,187 @@
+export type RangeWithValueScale = 'linear' | 'logarithmic';
+
+/** Averaging 0.04 and 0.15 yields 0.09500000000000001. */
+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,
+ scale: RangeWithValueScale,
+): number {
+ return scale === 'logarithmic' ? Math.log(value) : value;
+}
+
+/** Keeps the value inside the scale and pads it by bufferPercentage on both ends. */
+export function getBufferedRange({
+ actualValue,
+ expectedMin,
+ expectedMax,
+ bufferPercentage,
+ scale,
+}: {
+ actualValue: number;
+ expectedMin: number;
+ expectedMax: number;
+ bufferPercentage: number;
+ scale: RangeWithValueScale;
+}): {
+ bufferedMin: number;
+ bufferedMax: number;
+ scaleSpan: number;
+} {
+ const projectedMin = projectOntoScale(
+ Math.min(expectedMin, actualValue),
+ scale,
+ );
+ const projectedMax = projectOntoScale(
+ Math.max(expectedMax, actualValue),
+ 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
+ );
+}
+
+const DECADES_WITHOUT_SUBDIVISION = 4;
+const TARGET_LINEAR_TICKS = 6;
+
+/** 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);
+
+ return ticks
+ .filter((value) => value >= bufferedMin && value <= bufferedMax)
+ .map(withoutFloatingPointNoise);
+}
+
+const SUBDIVISIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
+
+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] : SUBDIVISIONS;
+
+ return Array.from(
+ { length: decadeCount },
+ (_, index) => firstDecade + index,
+ ).flatMap((decade) =>
+ subdivisions.map((multiple) => multiple * 10 ** decade),
+ );
+}
+
+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,
+ expectedMax,
+ scale,
+}: {
+ expectedMin: number;
+ expectedMax: number;
+ scale: RangeWithValueScale;
+}): number {
+ if (scale !== 'logarithmic') {
+ return withoutFloatingPointNoise((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;
+}
diff --git a/src/RangeWithValue/utils.ts b/src/RangeWithValue/utils.ts
index f2496074..43b72047 100644
--- a/src/RangeWithValue/utils.ts
+++ b/src/RangeWithValue/utils.ts
@@ -35,42 +35,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,
-}: {
- max: number;
- min: number;
- actualValue: number;
- expectedMin: number;
- expectedMax: number;
- bufferPercentage: number;
-}): {
- 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;
- const buffer = range * bufferPercentage;
-
- return {
- bufferedMin: minValue - buffer,
- bufferedMax: maxValue + buffer,
- range,
- };
-}