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, - }; -}