diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index 160beed93c5..cbb6ab2d9a0 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -2,8 +2,16 @@ import React from "react" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { ChartDimension } from "@ourworldindata/grapher" +import { OwidVariableRoundingMode } from "@ourworldindata/types" +import { startCase } from "@ourworldindata/utils" import { ChartEditor, DimensionErrorMessage } from "./ChartEditor.js" -import { Toggle, BindAutoString, BindAutoFloat, ColorBox } from "./Forms.js" +import { + Toggle, + BindAutoString, + BindAutoFloat, + ColorBox, + SelectField, +} from "./Forms.js" import { Link } from "./Link.js" import { faChevronDown, @@ -49,6 +57,13 @@ export class DimensionCard extends React.Component<{ return this.props.dimension.column.def.color } + @computed get roundingMode(): OwidVariableRoundingMode { + return ( + this.props.dimension.display.roundingMode ?? + OwidVariableRoundingMode.decimalPlaces + ) + } + private get tableDisplaySettings() { const { tableDisplay = {} } = this.props.dimension.display return ( @@ -166,13 +181,49 @@ export class DimensionCard extends React.Component<{ auto={column.shortUnit ?? ""} onBlur={this.onChange} /> + { + const roundingMode = + value as OwidVariableRoundingMode + this.props.dimension.display.roundingMode = + roundingMode !== + OwidVariableRoundingMode.decimalPlaces + ? roundingMode + : undefined + + this.onChange() + }} + options={Object.keys(OwidVariableRoundingMode).map( + (key) => ({ + value: key, + label: startCase(key), + }) + )} + /> + {this.roundingMode === + OwidVariableRoundingMode.significantFigures && ( + + )} { store={newVariable.display} placeholder={newVariable.shortUnit} /> + + { + const roundingMode = + value as OwidVariableRoundingMode + newVariable.display.roundingMode = + roundingMode !== + OwidVariableRoundingMode.decimalPlaces + ? roundingMode + : undefined + }} + options={Object.keys( + OwidVariableRoundingMode + ).map((key) => ({ + value: key, + label: key, + }))} + /> diff --git a/devTools/schemaProcessor/columns.json b/devTools/schemaProcessor/columns.json index 1c7c545d28c..5b62e445b34 100644 --- a/devTools/schemaProcessor/columns.json +++ b/devTools/schemaProcessor/columns.json @@ -578,11 +578,22 @@ "pointer": "/dimensions/0/display/unit", "editor": "textfield" }, + { + "type": "string", + "pointer": "/dimensions/0/display/roundingMode", + "editor": "dropdown", + "enumOptions": ["decimalPlaces", "significantFigures"] + }, { "type": "integer", "pointer": "/dimensions/0/display/numDecimalPlaces", "editor": "numeric" }, + { + "type": "integer", + "pointer": "/dimensions/0/display/numSignificantFigures", + "editor": "numeric" + }, { "type": "string", "pointer": "/dimensions/0/display/zeroDay", diff --git a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts index 11890352ef1..e34dfecb092 100644 --- a/packages/@ourworldindata/core-table/src/CoreTableColumns.ts +++ b/packages/@ourworldindata/core-table/src/CoreTableColumns.ts @@ -35,6 +35,7 @@ import { EntityName, OwidVariableRow, ErrorValue, + OwidVariableRoundingMode, } from "@ourworldindata/types" import { ErrorValueTypes, isNotErrorValue } from "./ErrorValues.js" import { @@ -223,10 +224,28 @@ export abstract class AbstractCoreColumn { return this.originalTimeColumn.formatValue(time) } + @imemo get roundingMode(): OwidVariableRoundingMode { + return ( + this.display?.roundingMode ?? OwidVariableRoundingMode.decimalPlaces + ) + } + + @imemo get roundsToFixedDecimals(): boolean { + return this.roundingMode === OwidVariableRoundingMode.decimalPlaces + } + + @imemo get roundsToSignificantFigures(): boolean { + return this.roundingMode === OwidVariableRoundingMode.significantFigures + } + @imemo get numDecimalPlaces(): number { return this.display?.numDecimalPlaces ?? 2 } + @imemo get numSignificantFigures(): number { + return this.display?.numSignificantFigures ?? 3 + } + @imemo get unit(): string | undefined { return this.display?.unit ?? this.def.unit } @@ -612,7 +631,9 @@ abstract class AbstractColumnWithNumberFormatting< formatValue(value: unknown, options?: TickFormattingOptions): string { if (isNumber(value)) { return formatValue(value, { + roundingMode: this.roundingMode, numDecimalPlaces: this.numDecimalPlaces, + numSignificantFigures: this.numSignificantFigures, ...options, }) } @@ -731,6 +752,7 @@ class IntegerColumn extends NumericColumn { class CurrencyColumn extends NumericColumn { formatValue(value: unknown, options?: TickFormattingOptions): string { return super.formatValue(value, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, numDecimalPlaces: 0, unit: this.shortUnit, ...options, diff --git a/packages/@ourworldindata/grapher/src/axis/Axis.ts b/packages/@ourworldindata/grapher/src/axis/Axis.ts index 5f9ec8a0ee0..61c3f260d0a 100644 --- a/packages/@ourworldindata/grapher/src/axis/Axis.ts +++ b/packages/@ourworldindata/grapher/src/axis/Axis.ts @@ -20,6 +20,7 @@ import { Tickmark, ValueRange, cloneDeep, + OwidVariableRoundingMode, } from "@ourworldindata/utils" import { AxisConfig, AxisManager } from "./AxisConfig" import { MarkdownTextWrap } from "@ourworldindata/components" @@ -323,6 +324,7 @@ abstract class AbstractAxis { private getTickFormattingOptions(): TickFormattingOptions { const options: TickFormattingOptions = { ...this.config.tickFormattingOptions, + roundingMode: OwidVariableRoundingMode.decimalPlaces, } // The chart's tick formatting function is used by default to format axis ticks. This means diff --git a/packages/@ourworldindata/grapher/src/color/ColorScale.ts b/packages/@ourworldindata/grapher/src/color/ColorScale.ts index b996f8f28df..f37db2a468d 100644 --- a/packages/@ourworldindata/grapher/src/color/ColorScale.ts +++ b/packages/@ourworldindata/grapher/src/color/ColorScale.ts @@ -20,6 +20,7 @@ import { BinningStrategy, Color, CoreValueType, + OwidVariableRoundingMode, } from "@ourworldindata/types" import { CoreColumn } from "@ourworldindata/core-table" @@ -271,10 +272,15 @@ export class ColorScale { const color = customNumericColors[index] ?? baseColor const label = customNumericLabels[index] + const roundingOptions = { + roundingMode: OwidVariableRoundingMode.decimalPlaces, + } const displayMin = - this.colorScaleColumn?.formatValueShort(min) ?? min.toString() + this.colorScaleColumn?.formatValueShort(min, roundingOptions) ?? + min.toString() const displayMax = - this.colorScaleColumn?.formatValueShort(max) ?? max.toString() + this.colorScaleColumn?.formatValueShort(max, roundingOptions) ?? + max.toString() const currentMin = min const isFirst = index === 0 diff --git a/packages/@ourworldindata/grapher/src/dataTable/DataTable.tsx b/packages/@ourworldindata/grapher/src/dataTable/DataTable.tsx index a9142f58765..da23e24d602 100644 --- a/packages/@ourworldindata/grapher/src/dataTable/DataTable.tsx +++ b/packages/@ourworldindata/grapher/src/dataTable/DataTable.tsx @@ -13,6 +13,7 @@ import { Time, EntityName, OwidTableSlugs, + OwidVariableRoundingMode, } from "@ourworldindata/types" import { BlankOwidTable, @@ -724,6 +725,7 @@ export class DataTable extends React.Component<{ return value === undefined ? value : column.formatValueShort(value, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, numberAbbreviation: false, trailingZeroes: true, useNoBreakSpace: true, diff --git a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx index c0bbfe5d467..91ec06917f2 100644 --- a/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx +++ b/packages/@ourworldindata/grapher/src/entitySelector/EntitySelector.tsx @@ -900,7 +900,8 @@ export class EntitySelector extends React.Component<{ if (!isFiniteWithGuard(value)) return { formattedValue: "No data" } return { - formattedValue: displayColumn.formatValueShort(value), + formattedValue: + displayColumn.formatValueShortWithAbbreviations(value), width: clamp(barScale(value), 0, 1), } } diff --git a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx index 6ff32f3171b..afc3bb7a810 100644 --- a/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx +++ b/packages/@ourworldindata/grapher/src/lineCharts/LineChart.tsx @@ -39,7 +39,13 @@ import { LineLegendManager, } from "../lineLegend/LineLegend" import { ComparisonLine } from "../scatterCharts/ComparisonLine" -import { Tooltip, TooltipState, TooltipTable } from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" +import { + Tooltip, + TooltipState, + TooltipTable, + makeTooltipRoundingNotice, +} from "../tooltip/Tooltip" import { NoDataModal } from "../noDataModal/NoDataModal" import { extent } from "d3-array" import { @@ -564,9 +570,21 @@ export class LineChart ? `% change since ${formatColumn.formatTime(startTime)}` : unitLabel const subtitleFormat = subtitle === unitLabel ? "unit" : undefined - const footer = sortedData.some((series) => series.isProjection) - ? `Projected data` + + const projectionNotice = sortedData.some( + (series) => series.isProjection + ) + ? { icon: TooltipFooterIcon.stripes, text: "Projected data" } + : undefined + const roundingNotice = formatColumn.roundsToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + formatColumn.numSignificantFigures + ), + } : undefined + const footer = excludeUndefined([projectionNotice, roundingNotice]) return ( string { - const { mapConfig, mapColumn, colorScale } = this + @computed private get formatTooltipValueIfCustom(): ( + d: PrimitiveType + ) => string | undefined { + const { mapConfig, colorScale } = this - return (d: PrimitiveType): string => { - if (mapConfig.tooltipUseCustomLabels) { - // Find the bin (and its label) that this value belongs to - const bin = colorScale.getBinForValue(d) - const label = bin?.label - if (label !== undefined && label !== "") return label - } - return isNumber(d) - ? mapColumn?.formatValueShort(d) ?? "" - : anyToString(d) + return (d: PrimitiveType): string | undefined => { + if (!mapConfig.tooltipUseCustomLabels) return undefined + // Find the bin (and its label) that this value belongs to + const bin = colorScale.getBinForValue(d) + const label = bin?.label + if (label !== undefined && label !== "") return label + else return undefined } } @@ -620,7 +617,7 @@ export class MapChart manager: MapChartManager colorScaleManager: ColorScaleManager - formatValue: (d: PrimitiveType) => string + formatValueIfCustom: (d: PrimitiveType) => string | undefined timeSeriesTable: OwidTable targetTime?: Time } @@ -42,6 +56,10 @@ export class MapTooltip extends React.Component { return this.props.manager.mapColumnSlug } + @computed private get mapColumn(): CoreColumn { + return this.mapTable.get(this.mapColumnSlug) + } + @computed private get mapAndYColumnAreTheSame(): boolean { const { yColumnSlug, yColumnSlugs, mapColumnSlug } = this.props.manager return yColumnSlugs && mapColumnSlug !== undefined @@ -75,7 +93,7 @@ export class MapTooltip extends React.Component { @computed private get datum(): | OwidVariableRow | undefined { - return this.mapTable.get(this.mapColumnSlug).owidRows[0] + return this.mapColumn.owidRows[0] } @computed private get hasTimeSeriesData(): boolean { @@ -198,10 +216,10 @@ export class MapTooltip extends React.Component { } render(): React.ReactElement { - const { mapTable, datum, lineColorScale } = this + const { mapTable, mapColumn, datum, lineColorScale } = this const { targetTime, - formatValue, + formatValueIfCustom, tooltipState: { target, position, fading }, } = this.props @@ -214,9 +232,22 @@ export class MapTooltip extends React.Component { ? timeColumn.formatValue(datum?.time) : datum?.time.toString() ?? "" const valueColor: string | undefined = darkenColorForHighContrastText( - lineColorScale?.getColor(datum?.value) ?? "#333" - ), - valueLabel = datum ? formatValue(datum.value) : undefined + lineColorScale?.getColor(datum?.value) ?? "#333" + ) + + let valueLabel: string | undefined, + isValueLabelRounded = false + if (datum) { + const customValueLabel = formatValueIfCustom(datum.value) + if (customValueLabel !== undefined) { + valueLabel = customValueLabel + } else if (isNumber(datum.value)) { + valueLabel = mapColumn?.formatValueShort(datum.value) + isValueLabelRounded = true + } else { + valueLabel = anyToString(datum.value) + } + } const { yAxisConfig } = this.sparklineManager, yColumn = this.sparklineTable.get(this.mapColumnSlug), @@ -231,14 +262,37 @@ export class MapTooltip extends React.Component { useCustom = isString(minCustom) && isString(maxCustom), minLabel = useCustom ? minCustom - : yColumn.formatValueShort(minVal ?? 0), + : yColumn.formatValueShort(minVal ?? 0, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, + }), maxLabel = useCustom ? maxCustom - : yColumn.formatValueShort(maxVal ?? 0) + : yColumn.formatValueShort(maxVal ?? 0, { + roundingMode: OwidVariableRoundingMode.decimalPlaces, + }) const { innerBounds: axisBounds } = this.sparklineChart.dualAxis const notice = datum && datum.time !== targetTime ? displayTime : undefined + const toleranceNotice = notice + ? { + icon: TooltipFooterIcon.notice, + text: makeTooltipToleranceNotice(notice), + } + : undefined + const roundingNotice = + isValueLabelRounded && mapColumn.roundsToSignificantFigures + ? { + icon: this.showSparkline + ? TooltipFooterIcon.significance + : TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + mapColumn.numSignificantFigures, + { plural: false } + ), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) const labelX = axisBounds.right - SPARKLINE_NUDGE const labelTop = axisBounds.top - SPARKLINE_NUDGE @@ -258,14 +312,17 @@ export class MapTooltip extends React.Component { title={target?.featureId} subtitle={datum ? displayDatumTime : displayTime} subtitleFormat={notice ? "notice" : undefined} - footer={notice} - footerFormat="notice" + footer={footer} dissolve={fading} > {this.showSparkline && (
column.roundsToSignificantFigures + ) + const anyRoundedToSigFigs = columns.some( + (column) => column.roundsToSignificantFigures + ) + const sigFigs = excludeUndefined( + columns.map((column) => + column.roundsToSignificantFigures + ? column.numSignificantFigures + : undefined + ) + ) + + const toleranceNotice = targetNotice + ? { + icon: TooltipFooterIcon.notice, + text: makeTooltipToleranceNotice(targetNotice), + } + : undefined + const roundingNotice = anyRoundedToSigFigs + ? { + icon: allRoundedToSigFigs + ? TooltipFooterIcon.none + : TooltipFooterIcon.significance, + text: makeTooltipRoundingNotice(sigFigs, { + plural: sigFigs.length > 1, + }), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) + const superscript = !allRoundedToSigFigs + return ( v.size))} + superscript={superscript} /> ) diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx index 3f4529aa067..b2dde512839 100644 --- a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx +++ b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx @@ -2,7 +2,12 @@ import React from "react" import { computed } from "mobx" import { scaleLinear, ScaleLinear } from "d3-scale" import { TextWrap } from "@ourworldindata/components" -import { first, last, makeIdForHumanConsumption } from "@ourworldindata/utils" +import { + first, + last, + makeIdForHumanConsumption, + OwidVariableRoundingMode, +} from "@ourworldindata/utils" import { BASE_FONT_SIZE, GRAPHER_DARK_TEXT, @@ -171,7 +176,11 @@ export class ScatterSizeLegend { !!notice) ? timeColumn.formatValue(endTime) : undefined + const toleranceNotice = targetNotice + ? { icon: TooltipFooterIcon.notice, text: targetNotice } + : undefined + + const columns = excludeUndefined([xColumn, yColumn]) + const allRoundedToSigFigs = columns.every( + (column) => column.roundsToSignificantFigures + ) + const anyRoundedToSigFigs = columns.some( + (column) => column.roundsToSignificantFigures + ) + const sigFigs = excludeUndefined( + columns.map((column) => + column.roundsToSignificantFigures + ? column.numSignificantFigures + : undefined + ) + ) + const roundingNotice = anyRoundedToSigFigs + ? { + icon: allRoundedToSigFigs + ? TooltipFooterIcon.none + : TooltipFooterIcon.significance, + text: makeTooltipRoundingNotice(sigFigs, { + plural: sigFigs.length > 1, + }), + } + : undefined + const superscript = + !!roundingNotice && roundingNotice.icon !== TooltipFooterIcon.none + + const footer = excludeUndefined([toleranceNotice, roundingNotice]) return ( {yValues.map(({ name, value, notice }) => ( @@ -1036,6 +1073,7 @@ export class MarimekkoChart column={yColumn} value={value} notice={notice} + superscript={superscript} /> ))} {xColumn && ( @@ -1043,6 +1081,7 @@ export class MarimekkoChart column={xColumn} value={xPoint?.value} notice={xNotice} + superscript={superscript} /> )} diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 519837d36c8..40c824ea97b 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx @@ -32,7 +32,13 @@ import { LineLegendManager, } from "../lineLegend/LineLegend" import { NoDataModal } from "../noDataModal/NoDataModal" -import { Tooltip, TooltipState, TooltipTable } from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" +import { + Tooltip, + TooltipState, + TooltipTable, + makeTooltipRoundingNotice, +} from "../tooltip/Tooltip" import { rgb } from "d3-color" import { AbstractStackedChart, @@ -503,13 +509,23 @@ export class StackedAreaChart const hoveredPointIndex = target.index const bottomSeriesPoint = series[0].points[hoveredPointIndex] - const yColumn = this.yColumns[0], // Assumes same type for all columns. - formattedTime = yColumn.formatTime(bottomSeriesPoint.position), - { unit, shortUnit } = yColumn + const formatColumn = this.yColumns[0], // Assumes same type for all columns. + formattedTime = formatColumn.formatTime(bottomSeriesPoint.position), + { unit, shortUnit } = formatColumn const lastStackedPoint = last(series)!.points[hoveredPointIndex] const totalValue = lastStackedPoint.value + lastStackedPoint.valueOffset + const roundingNotice = formatColumn.roundsToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + formatColumn.numSignificantFigures + ), + } + : undefined + const footer = excludeUndefined([roundingNotice]) + return ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx index a366c658813..2d9cbe456ea 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedBarChart.tsx @@ -11,6 +11,7 @@ import { colorScaleConfigDefaults, dyFromAlign, makeIdForHumanConsumption, + excludeUndefined, } from "@ourworldindata/utils" import { VerticalAxisComponent, @@ -23,7 +24,13 @@ import { VerticalColorLegendManager, LegendItem, } from "../verticalColorLegend/VerticalColorLegend" -import { Tooltip, TooltipState, TooltipTable } from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" +import { + Tooltip, + TooltipState, + TooltipTable, + makeTooltipRoundingNotice, +} from "../tooltip/Tooltip" import { BASE_FONT_SIZE, GRAPHER_AREA_OPACITY_DEFAULT, @@ -311,8 +318,8 @@ export class StackedBarChart hoverTime = hoveredTick.time } else return - const yColumn = yColumns[0], // we can just use the first column for formatting, b/c we assume all columns have same type - { unit, shortUnit } = yColumn + const formatColumn = yColumns[0], // we can just use the first column for formatting, b/c we assume all columns have same type + { unit, shortUnit } = formatColumn const totalValue = sum( series.map( @@ -321,6 +328,16 @@ export class StackedBarChart ) ) + const roundingNotice = formatColumn.roundsToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + formatColumn.numSignificantFigures + ), + } + : undefined + const footer = excludeUndefined([roundingNotice]) + return ( diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx index 7a27a162d6e..79d77ecb59f 100644 --- a/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx +++ b/packages/@ourworldindata/grapher/src/stackedCharts/StackedDiscreteBarChart.tsx @@ -46,7 +46,13 @@ import { withMissingValuesAsZeroes, } from "../stackedCharts/StackedUtils" import { ChartManager } from "../chart/ChartManager" -import { Tooltip, TooltipState, TooltipTable } from "../tooltip/Tooltip" +import { TooltipFooterIcon } from "../tooltip/TooltipProps.js" +import { + Tooltip, + TooltipState, + TooltipTable, + makeTooltipRoundingNotice, +} from "../tooltip/Tooltip" import { StackedPoint, StackedSeries } from "./StackedConstants" import { ColorSchemes } from "../color/ColorSchemes" import { @@ -770,7 +776,22 @@ export class StackedDiscreteBarChart hasNotice = item?.bars.some( ({ point }) => !point.fake && point.time !== targetTime ), - notice = hasNotice ? timeColumn.formatValue(targetTime) : undefined + targetNotice = hasNotice + ? timeColumn.formatValue(targetTime) + : undefined + + const toleranceNotice = targetNotice + ? { icon: TooltipFooterIcon.notice, text: targetNotice } + : undefined + const roundingNotice = this.formatColumn.roundsToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + this.formatColumn.numSignificantFigures + ), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) return ( target && @@ -786,8 +807,7 @@ export class StackedDiscreteBarChart title={target.entityName} subtitle={unit !== shortUnit ? unit : undefined} subtitleFormat="unit" - footer={notice} - footerFormat="notice" + footer={footer} dissolve={fading} > = { notice: , stripes:
, + significance: ( +
+ +
+ ), + none: null, +} + +export function IconCircledS({ + asSup = false, +}: { + asSup?: boolean +}): React.ReactElement { + return ( +
+
+ +
+ ) } export class TooltipState { @@ -87,7 +115,6 @@ class TooltipCard extends React.Component< subtitle, subtitleFormat, footer, - footerFormat, dissolve, children, offsetX = 0, @@ -131,7 +158,6 @@ class TooltipCard extends React.Component< // flag the year in the header and add note in footer (if necessary) const timeNotice = !!subtitle && subtitleFormat === "notice" - const tolerance = footerFormat === "notice" // style the box differently if just displaying title/subtitle const plain = hasHeader && !children @@ -162,10 +188,25 @@ class TooltipCard extends React.Component<
)} {children &&
{children}
} - {footer && ( -
- {footerFormat && TOOLTIP_ICON[footerFormat]} -

{footer}

+ {footer && footer.length > 0 && ( +
1, + })} + > + {footer?.map(({ icon, text }) => ( +
+ {TOOLTIP_ICON[icon]} +

{text}

+
+ ))}
)}
diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx index 1f268deceb4..8af852ad12c 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx @@ -4,31 +4,48 @@ import { CoreColumn } from "@ourworldindata/core-table" import { NO_DATA_LABEL } from "../color/ColorScale.js" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faInfoCircle } from "@fortawesome/free-solid-svg-icons" -import { sum, zip, uniq, isNumber } from "@ourworldindata/utils" +import { + sum, + zip, + uniq, + isNumber, + sortBy, + formatInlineList, +} from "@ourworldindata/utils" import { TooltipTableProps, TooltipValueProps, TooltipValueRangeProps, } from "./TooltipProps" +import { IconCircledS } from "./Tooltip" export const NO_DATA_COLOR = "#999" export class TooltipValue extends React.Component { render(): React.ReactElement | null { - const { column, value, color, notice } = this.props, + const { column, value, color, notice, superscript } = this.props, displayValue = isNumber(value) ? column.formatValueShort(value) : value ?? NO_DATA_LABEL, displayColor = displayValue === NO_DATA_LABEL ? NO_DATA_COLOR : color + const { roundsToSignificantFigures } = column + const showSigSuperscript = superscript && roundsToSignificantFigures + const sigSuperscript = showSigSuperscript ? ( + + ) : null + return ( - {displayValue} + + {displayValue} + {sigSuperscript} + ) } @@ -54,7 +71,7 @@ export class TooltipValueRange extends React.Component { } render(): React.ReactElement | null { - const { column, values, color, notice } = this.props, + const { column, values, color, notice, superscript } = this.props, [firstValue, lastValue] = values.map((v) => column.formatValueShort(v) ), @@ -75,12 +92,24 @@ export class TooltipValueRange extends React.Component { ? this.arrowIcon("down") : this.arrowIcon("right") + const { roundsToSignificantFigures } = column + const showSigSuperscript = superscript && roundsToSignificantFigures + const sigSuperscript = showSigSuperscript ? : null + return values.length ? ( - {firstTerm} + + {firstTerm} + {!lastTerm && sigSuperscript} + {trend} - {lastTerm} + {lastTerm && ( + + {lastTerm} + {sigSuperscript} + + )} ) : null @@ -177,6 +206,7 @@ export class TooltipTable extends React.Component { values, notice, swatch = "transparent", + superscript, } = row const [_m, seriesName, seriesParenthetical] = name.trim().match(/^(.*?)(\([^()]*\))?$/) ?? [] @@ -210,8 +240,16 @@ export class TooltipTable extends React.Component { )} {zip(columns, values).map(([column, value]) => { + if (!column) return null + const { roundsToSignificantFigures } = + column + const showSigSuperscript = + superscript && + roundsToSignificantFigures + const sigSuperscript = + showSigSuperscript ? : null const missing = value === undefined - return column ? ( + return ( { value, format )} + {!missing && sigSuperscript} - ) : null + ) })} {notice && ( @@ -263,3 +302,25 @@ export class TooltipTable extends React.Component { ) } } + +export function makeTooltipToleranceNotice(targetYear: string): string { + return `Data not available for ${targetYear}. Showing closest available data point instead` +} + +export function makeTooltipRoundingNotice( + numSignificantFigures: number | number[], + { plural }: { plural: boolean } = { plural: true } +): string { + let numSigFigs: string = "" + if (isNumber(numSignificantFigures)) { + numSigFigs = `${numSignificantFigures}` + } else { + const uniqueNumSigFigs = uniq(numSignificantFigures) + numSigFigs = formatInlineList(sortBy(uniqueNumSigFigs), "or") + } + + const values = plural ? "Values" : "Value" + const are = plural ? "are" : "is" + const figures = numSigFigs === "1" ? "figure" : "figures" + return `${values} ${are} rounded to ${numSigFigs} significant ${figures}` +} diff --git a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts index 1823edd4245..c344f41a378 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipProps.ts @@ -8,6 +8,12 @@ export interface TooltipManager { } export type TooltipFadeMode = "delayed" | "immediate" | "none" +export enum TooltipFooterIcon { + notice = "notice", + stripes = "stripes", + significance = "significance", + none = "none", +} export interface TooltipProps { id: number | string @@ -20,8 +26,7 @@ export interface TooltipProps { title?: string | number // header text subtitle?: string | number // header deck subtitleFormat?: "notice" | "unit" // optional postprocessing for subtitle - footer?: string // target year for tolerance notice or freeform contents - footerFormat?: "notice" | "stripes" // add icon for tolerance or projection + footer?: { icon: TooltipFooterIcon; text: string }[] style?: React.CSSProperties // css overrides (particularly width/maxWidth) dissolve?: TooltipFadeMode // flag that the tooltip should begin fading out tooltipManager: TooltipManager @@ -33,6 +38,7 @@ export interface TooltipValueProps { value?: number | string color?: string notice?: number | string // actual year data was drawn from (when ≠ target year) + superscript?: boolean // show significance-s superscript if applicable } export interface TooltipValueRangeProps { @@ -40,6 +46,7 @@ export interface TooltipValueRangeProps { values: number[] color?: string notice?: (number | string | undefined)[] // actual year data was drawn from (when ≠ target year) + superscript?: boolean // show significance-s superscript if applicable } export interface TooltipTableProps { @@ -58,6 +65,7 @@ export interface TooltipTableRow { striped?: boolean // use textured swatch (to show data is extrapolated) notice?: string | number // actual year data was drawn (when ≠ target year) values: (string | number | undefined)[] + superscript?: boolean // show significance-s superscript if applicable } export interface TooltipTableData { diff --git a/packages/@ourworldindata/types/src/OwidVariableDisplayConfigInterface.ts b/packages/@ourworldindata/types/src/OwidVariableDisplayConfigInterface.ts index 7bbcfe714fd..148d66b02fa 100644 --- a/packages/@ourworldindata/types/src/OwidVariableDisplayConfigInterface.ts +++ b/packages/@ourworldindata/types/src/OwidVariableDisplayConfigInterface.ts @@ -11,7 +11,9 @@ export interface OwidVariableDisplayConfigInterface { shortUnit?: string isProjection?: boolean conversionFactor?: number + roundingMode?: OwidVariableRoundingMode numDecimalPlaces?: number + numSignificantFigures?: number tolerance?: number yearIsDay?: boolean zeroDay?: string @@ -27,6 +29,11 @@ export interface OwidVariableDataTableConfigInterface { hideRelativeChange?: boolean } +export enum OwidVariableRoundingMode { + decimalPlaces = "decimalPlaces", + significantFigures = "significantFigures", +} + export interface OwidChartDimensionInterface { property: DimensionProperty targetYear?: Time diff --git a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts index 04796ab5116..05c6b380692 100644 --- a/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts +++ b/packages/@ourworldindata/types/src/grapherTypes/GrapherTypes.ts @@ -1,4 +1,7 @@ -import { OwidChartDimensionInterface } from "../OwidVariableDisplayConfigInterface.js" +import { + OwidChartDimensionInterface, + OwidVariableRoundingMode, +} from "../OwidVariableDisplayConfigInterface.js" import { ColumnSlugs, EntityName } from "../domainTypes/CoreTableTypes.js" import { AxisAlign, Position } from "../domainTypes/Layout.js" import { Integer, QueryParams, TopicId } from "../domainTypes/Various.js" @@ -204,7 +207,9 @@ export interface Tickmark { solid?: boolean // mostly for labelling domain start (e.g. 0) } export interface TickFormattingOptions { + roundingMode?: OwidVariableRoundingMode numDecimalPlaces?: number + numSignificantFigures?: number unit?: string trailingZeroes?: boolean spaceBeforeUnit?: boolean diff --git a/packages/@ourworldindata/types/src/index.ts b/packages/@ourworldindata/types/src/index.ts index c96a70588c4..b82f50c0f31 100644 --- a/packages/@ourworldindata/types/src/index.ts +++ b/packages/@ourworldindata/types/src/index.ts @@ -384,10 +384,11 @@ export { export type { OwidSource } from "./OwidSource.js" export type { OwidOrigin } from "./OwidOrigin.js" -export type { - OwidVariableDisplayConfigInterface, - OwidVariableDataTableConfigInterface, - OwidChartDimensionInterface, +export { + type OwidVariableDisplayConfigInterface, + type OwidVariableDataTableConfigInterface, + OwidVariableRoundingMode, + type OwidChartDimensionInterface, } from "./OwidVariableDisplayConfigInterface.js" export { diff --git a/packages/@ourworldindata/utils/src/OwidVariable.ts b/packages/@ourworldindata/utils/src/OwidVariable.ts index 77921574955..5ab7b5b0cb4 100644 --- a/packages/@ourworldindata/utils/src/OwidVariable.ts +++ b/packages/@ourworldindata/utils/src/OwidVariable.ts @@ -10,6 +10,7 @@ import { import { OwidVariableDataTableConfigInterface, OwidVariableDisplayConfigInterface, + OwidVariableRoundingMode, } from "@ourworldindata/types" class OwidVariableDisplayConfigDefaults { @@ -18,7 +19,9 @@ class OwidVariableDisplayConfigDefaults { @observable shortUnit?: string = undefined @observable isProjection?: boolean = undefined @observable conversionFactor?: number = undefined + @observable roundingMode?: OwidVariableRoundingMode = undefined @observable numDecimalPlaces?: number = undefined + @observable numSignificantFigures?: number = undefined @observable tolerance?: number = undefined @observable yearIsDay?: boolean = undefined @observable zeroDay?: string = undefined diff --git a/packages/@ourworldindata/utils/src/Util.test.ts b/packages/@ourworldindata/utils/src/Util.test.ts index 9a1a44952cc..e721b069eb0 100755 --- a/packages/@ourworldindata/utils/src/Util.test.ts +++ b/packages/@ourworldindata/utils/src/Util.test.ts @@ -29,6 +29,7 @@ import { findGreatestCommonDivisorOfArray, traverseEnrichedBlock, cartesian, + formatInlineList, } from "./Util.js" import { BlockImageSize, @@ -793,3 +794,27 @@ describe(cartesian, () => { ]) }) }) + +describe(formatInlineList, () => { + it("returns an empty string when no items are given", () => { + expect(formatInlineList([])).toEqual("") + }) + + it("returns a single item as a string", () => { + expect(formatInlineList(["a"])).toEqual("a") + }) + + it("formats two items correctly", () => { + expect(formatInlineList(["a", "b"])).toEqual("a and b") + }) + + it("formats three items correctly using 'and'", () => { + expect(formatInlineList(["a", "b", "c"])).toEqual("a, b and c") + }) + + it("formats four items correctly using 'or'", () => { + expect(formatInlineList(["a", "b", "c", "d"], "or")).toEqual( + "a, b, c or d" + ) + }) +}) diff --git a/packages/@ourworldindata/utils/src/Util.ts b/packages/@ourworldindata/utils/src/Util.ts index 52a205bf26b..897bf0d95d8 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1953,3 +1953,12 @@ export function createTagGraph( return recursivelySetChildren(tagGraph) as TagGraphRoot } + +export function formatInlineList( + array: unknown[], + connector: "and" | "or" = "and" +): string { + if (array.length === 0) return "" + if (array.length === 1) return `${array[0]}` + return `${array.slice(0, -1).join(", ")} ${connector} ${last(array)}` +} diff --git a/packages/@ourworldindata/utils/src/formatValue.test.ts b/packages/@ourworldindata/utils/src/formatValue.test.ts index 99798f58ea8..5920dd455db 100644 --- a/packages/@ourworldindata/utils/src/formatValue.test.ts +++ b/packages/@ourworldindata/utils/src/formatValue.test.ts @@ -1,8 +1,11 @@ #! /usr/bin/env jest -import { TickFormattingOptions } from "@ourworldindata/types" +import { + TickFormattingOptions, + OwidVariableRoundingMode, +} from "@ourworldindata/types" import { formatValue } from "./formatValue" -describe(formatValue, () => { +describe("rounding to a fixed number of decimals", () => { // prettier-ignore const cases: [string, number, string, TickFormattingOptions][] = [ ["default", 1, "1", {}], @@ -20,6 +23,9 @@ describe(formatValue, () => { ["hundred thousand specific default", 123456, "123,456", {}], ["hundred thousand rounding default", 12388, "12,388", {}], ["hundred thousand specific decimals", 123456.789, "123,456.79", {}], + ["999999 specific default", 999999.99, "999,999.99", {}], + // TODO: make this test pass + // ["999999 rounding default", 999999.999, "1 million", {}], ["million", 1000000, "1 million", {}], ["billion", 1000000000, "1 billion", {}], ["trillion", 1000000000000, "1 trillion", {}], @@ -91,3 +97,109 @@ describe(formatValue, () => { }) }) }) + +describe("rounding to significant figures", () => { + // prettier-ignore + const cases: [string, number, string, TickFormattingOptions][] = [ + ["default", 1, "1.00", {}], + ["default negative", -1, "-1.00", {}], + ["default small", 0.001, "0.00100", {}], + ["default very small", 0.0000000001, "0.000000000100", {}], + ["default million specific", 1179766, "1.18 million", {}], + ["default billion specific", 1234567890, "1.23 billion", {}], + ["default 10 billion specific", 12345678901, "12.3 billion", {}], + ["default billion with rounding", 1239999999, "1.24 billion", {}], + ["thousand", 1000, "1,000", {}], + ["thousand rounding", 1234, "1,230", {}], + ["ten thousand", 10000, "10,000", {}], + ["hundred thousand default", 100000, "100,000", {}], + ["hundred thousand specific default", 123456, "123,000", {}], + ["hundred thousand rounding default", 12388, "12,400", {}], + ["hundred thousand specific decimals", 123456.789, "123,000", {}], + // TODO: make this tests pass + // ["999999 specific default", 999999, "1.00 million", {}], + ["million", 1000000, "1.00 million", {}], + ["billion", 1000000000, "1.00 billion", {}], + ["trillion", 1000000000000, "1.00 trillion", {}], + ["quadrillion", 1000000000000000, "1.00 quadrillion", {}], + ["negative million", -1000000, "-1.00 million", {}], + ["negative billion", -1000000000, "-1.00 billion", {}], + ["negative trillion", -1000000000000, "-1.00 trillion", {}], + ["negative quadrillion", -1000000000000000, "-1.00 quadrillion", {}], + ["1000 short prefix", 1000, "1.00k", { numberAbbreviation: "short" }], + ["1499 short prefix", 1499, "1.50k", { numberAbbreviation: "short" }], + ["1001 short prefix", 1001, "1.00k", { numberAbbreviation: "short" }], + ["1009 short prefix", 1009, "1.01k", { numberAbbreviation: "short" }], + ["12345 short prefix", 12345, "12.3k", { numberAbbreviation: "short" }], + ["123456 short prefix", 123456, "123k", { numberAbbreviation: "short" }], + ["hundred thousand short prefix decimal", 100000.44, "100k", { numberAbbreviation: "short" }], + ["1000 long prefix", 1000, "1,000", { numberAbbreviation: "long" }], + ["1499 long prefix", 1499, "1,500", { numberAbbreviation: "long" }], + ["1001 long prefix", 1001, "1,000", { numberAbbreviation: "long" }], + ["1009 long prefix", 1009, "1,010", { numberAbbreviation: "long" }], + ["ten thousand long prefix", 10000, "10,000", { numberAbbreviation: "long" }], + ["hundred thousand long prefix", 100000, "100,000", { numberAbbreviation: "long" }], + ["hundred thousand long prefix decimal", 100000.44, "100,000", { numberAbbreviation: "long" }], + ["million short prefix", 1000000, "1.00M", { numberAbbreviation: "short" }], + ["billion short prefix", 1000000000, "1.00B", { numberAbbreviation: "short" }], + ["trillion short prefix", 1000000000000, "1.00T", { numberAbbreviation: "short" }], + ["quadrillion short prefix", 1000000000000000, "1.00quad", { numberAbbreviation: "short" }], + ["1 with 1 significant figure", 1, "1", { numSignificantFigures: 1 }], + ["1 with 2 significant figures", 1, "1.0", { numSignificantFigures: 2 }], + ["1 with 3 significant figures", 1, "1.00", { numSignificantFigures: 3 }], + ["0.999 with 1 significant figure", 0.999, "1", { numSignificantFigures: 1 }], + ["0.999 with 2 significant figures", 0.999, "1.0", { numSignificantFigures: 2 }], + ["0.999 with 3 significant figures", 0.999, "0.999", { numSignificantFigures: 3 }], + ["0.999 with 4 significant figures", 0.999, "0.9990", { numSignificantFigures: 4 }], + ["1234 with 1 significant figure", 1234, "1,000", { numSignificantFigures: 1 }], + ["1234 with 2 significant figures", 1234, "1,200", { numSignificantFigures: 2 }], + ["1234 with 3 significant figures", 1234, "1,230", { numSignificantFigures: 3 }], + ["1234 with 4 significant figures", 1234, "1,234", { numSignificantFigures: 4 }], + ["1234 with 5 significant figures", 1234, "1,234.0", { numSignificantFigures: 5 }], + ["1234 with 6 significant figures", 1234, "1,234.00", { numSignificantFigures: 6 }], + ["0.0012 with 1 significant figure", 0.0012, '0.001', {numSignificantFigures: 1}], + ["0.0012 with 2 significant figures", 0.0012, '0.0012', {numSignificantFigures: 2}], + ["0.0012 with 3 significant figures", 0.0012, '0.00120', {numSignificantFigures: 3}], + ["2 significant figures with abbreviation", 1234567, "1.2 million", { numSignificantFigures: 2, numberAbbreviation: "long" }], + ["3 significant figures with abbreviation", 1234567, "1.23 million", { numSignificantFigures: 3, numberAbbreviation: "long" }], + ["2 significant figures with short abbreviation", 1234, "1.2k", { numSignificantFigures: 2, numberAbbreviation: "short" }], + ["3 significant figures with percentage", 19.986, "20.0%", { numSignificantFigures: 3, unit: "%" }], + ["4 significant figures with percentage", 19.986, "19.99%", { numSignificantFigures: 4, unit: "%" }], + ["with unit", 1, "$1.00", { unit: "$" }], + ["with custom unit", 1, "1.00pp", { unit: "pp", spaceBeforeUnit: false }], + ["with custom unit and space", 1, "1.00 pp", { unit: "pp", spaceBeforeUnit: true }], + ["negative with unit", -1, "-$1.00", { unit: "$" }], + ["trailingZeroes true", 1.10, "1.10", { trailingZeroes: false }], // trailingZeroes is ignored + ["trailingZeroes false", 1.10, "1.10", { trailingZeroes: true }], // trailingZeroes is ignored + ["$ spaceBeforeUnit false", 1.1, "$1.10", { spaceBeforeUnit: false, unit: "$" }], + ["$ spaceBeforeUnit true", 1.1, "$1.10", { spaceBeforeUnit: true, unit: "$" }], + ["% spaceBeforeUnit true", 1.1, "1.10 %", { spaceBeforeUnit: true, unit: "%" }], + ["% spaceBeforeUnit false", 1.1, "1.10%", { spaceBeforeUnit: false, unit: "%" }], + ["% small", 0.1, "0.100%", { unit: "%" }], + ["% very small", 0.001, "0.00100%", { unit: "%" }], + ["%compound spaceBeforeUnit false", 1.1, "1.10%compound", { spaceBeforeUnit: false, unit: "%compound" }], + ["numberAbbreviation long", 1000000000, "1.00 billion", { numberAbbreviation: "long" }], + ["numberAbbreviation million specific", 846691846.8, "847 million", { numberAbbreviation: "long" }], + ["numberAbbreviation billion specific", 123456789012, "123 billion", { numberAbbreviation: "long" }], + ["numberAbbreviation long with unit", 1000000000, "$1.00 billion", { numberAbbreviation: "long", unit: "$" }], + ["numberAbbreviation short", 1000000000, "1.00B", { numberAbbreviation: "short" }], + ["numberAbbreviation %", 20000, "20,000%", { numberAbbreviation: "short", unit: "%" }], + ["numberAbbreviation false", 1000000000, "1,000,000,000", { numberAbbreviation: false }], + ["numberAbbreviation false very small", 0.000000001, "0.000000001", { numberAbbreviation: false, numSignificantFigures: 1 }], + ["showPlus true", 1, "+1.00", { showPlus: true }], + ["showPlus false", 1, "1.00", { showPlus: false }], + ["showPlus false with negative number", -1, "-1.00", { showPlus: false }], + ["showPlus true with unit", 1, "+$1.00", { showPlus: true, unit: "$" }], + ["showPlus true with % and 4 significant numbers", 1.23456, "+1.235%", { showPlus: true, unit: "%", numSignificantFigures: 4 }], + ] + cases.forEach(([description, input, output, options]) => { + it(description, () => { + expect( + formatValue(input, { + ...options, + roundingMode: OwidVariableRoundingMode.significantFigures, + }) + ).toBe(output) + }) + }) +}) diff --git a/packages/@ourworldindata/utils/src/formatValue.ts b/packages/@ourworldindata/utils/src/formatValue.ts index 53286c4e622..db2522614c9 100644 --- a/packages/@ourworldindata/utils/src/formatValue.ts +++ b/packages/@ourworldindata/utils/src/formatValue.ts @@ -1,6 +1,9 @@ import { FormatSpecifier } from "d3-format" import { createFormatter } from "./Util.js" -import { TickFormattingOptions } from "@ourworldindata/types" +import { + OwidVariableRoundingMode, + TickFormattingOptions, +} from "@ourworldindata/types" // Used outside this module to figure out if the unit will be joined with the number. export function checkIsVeryShortUnit(unit: string): unit is "$" | "£" | "%" { @@ -15,8 +18,19 @@ function checkIsUnitPercent(unit: string): unit is "%" { return unit[0] === "%" } -function getTrim({ trailingZeroes }: { trailingZeroes: boolean }): "~" | "" { - return trailingZeroes ? "" : "~" +function getTrim({ + roundingMode, + trailingZeroes, +}: { + roundingMode: OwidVariableRoundingMode + trailingZeroes: boolean +}): "~" | "" { + // always show trailing zeroes when rounding to significant figures + return roundingMode === OwidVariableRoundingMode.significantFigures + ? "" + : trailingZeroes + ? "" + : "~" } function getSign({ showPlus }: { showPlus: boolean }): "+" | "" { @@ -28,40 +42,58 @@ function getSymbol({ unit }: { unit: string }): "$" | "" { } function getType({ + roundingMode, numberAbbreviation, value, unit, }: { + roundingMode: OwidVariableRoundingMode numberAbbreviation: "long" | "short" | false value: number unit: string -}): "f" | "s" { +}): "f" | "s" | "r" { // f: fixed-point notation (i.e. fixed number of decimal points) + // r: decimal notation, rounded to significant digits // s: decimal notation with an SI prefix, rounded to significant digits + + const typeMap: Record = { + [OwidVariableRoundingMode.decimalPlaces]: "f", + [OwidVariableRoundingMode.significantFigures]: "r", + } + const type = typeMap[roundingMode] + if (checkIsUnitPercent(unit)) { - return "f" + return type } if (numberAbbreviation === "long") { // do not abbreviate until 1 million - return Math.abs(value) < 1e6 ? "f" : "s" + return Math.abs(value) < 1e6 ? type : "s" } if (numberAbbreviation === "short") { // do not abbreviate until 1 thousand - return Math.abs(value) < 1e3 ? "f" : "s" + return Math.abs(value) < 1e3 ? type : "s" } - return "f" + return type } function getPrecision({ value, + roundingMode, numDecimalPlaces, + numSignificantFigures, type, }: { value: number + roundingMode: OwidVariableRoundingMode numDecimalPlaces: number - type: "f" | "s" + numSignificantFigures: number + type: "f" | "s" | "r" }): string { + if (roundingMode === OwidVariableRoundingMode.significantFigures) { + return `${numSignificantFigures}` + } + if (type === "f") { return `${numDecimalPlaces}` } @@ -116,6 +148,7 @@ function replaceSIPrefixes({ function postprocessString({ string, + roundingMode, numberAbbreviation, spaceBeforeUnit, useNoBreakSpace, @@ -124,6 +157,7 @@ function postprocessString({ numDecimalPlaces, }: { string: string + roundingMode: OwidVariableRoundingMode numberAbbreviation: "long" | "short" | false spaceBeforeUnit: boolean useNoBreakSpace: boolean @@ -134,9 +168,11 @@ function postprocessString({ let output = string // handling infinitesimal values - const tooSmallThreshold = Math.pow(10, -numDecimalPlaces).toPrecision(1) - if (numberAbbreviation && 0 < value && value < +tooSmallThreshold) { - output = "<" + output.replace(/0\.?(\d+)?/, tooSmallThreshold) + if (roundingMode !== OwidVariableRoundingMode.significantFigures) { + const tooSmallThreshold = Math.pow(10, -numDecimalPlaces).toPrecision(1) + if (numberAbbreviation && 0 < value && value < +tooSmallThreshold) { + output = "<" + output.replace(/0\.?(\d+)?/, tooSmallThreshold) + } } if (numberAbbreviation) { @@ -158,12 +194,14 @@ function postprocessString({ export function formatValue( value: number, { - trailingZeroes = false, + roundingMode = OwidVariableRoundingMode.decimalPlaces, + trailingZeroes = false, // only applies to fixed-point notation unit = "", spaceBeforeUnit = !checkIsUnitPercent(unit), useNoBreakSpace = false, showPlus = false, - numDecimalPlaces = 2, + numDecimalPlaces = 2, // only applies to fixed-point notation + numSignificantFigures = 3, // only applies to sig fig rounding numberAbbreviation = "long", }: TickFormattingOptions ): string { @@ -173,22 +211,25 @@ export function formatValue( // https://observablehq.com/@ikesau/d3-format-interactive-demo const specifier = new FormatSpecifier({ zero: "0", - trim: getTrim({ trailingZeroes }), + trim: getTrim({ roundingMode, trailingZeroes }), sign: getSign({ showPlus }), symbol: getSymbol({ unit }), comma: ",", precision: getPrecision({ + roundingMode, value, numDecimalPlaces, - type: getType({ numberAbbreviation, value, unit }), + numSignificantFigures, + type: getType({ roundingMode, numberAbbreviation, value, unit }), }), - type: getType({ numberAbbreviation, value, unit }), + type: getType({ roundingMode, numberAbbreviation, value, unit }), }).toString() const formattedString = formatter(specifier)(value) const postprocessedString = postprocessString({ string: formattedString, + roundingMode, numberAbbreviation, spaceBeforeUnit, useNoBreakSpace, diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index 2b26d290b3c..1f5f7a90491 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -127,6 +127,7 @@ export { commafyNumber, isFiniteWithGuard, createTagGraph, + formatInlineList, } from "./Util.js" export {