From 3904976f8e6a4776f9992f9e1842abd46792b22e Mon Sep 17 00:00:00 2001 From: sophiamersmann Date: Thu, 13 Jun 2024 15:05:10 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(grapher)=20round=20to=20signifi?= =?UTF-8?q?cant=20figures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminSiteClient/DimensionCard.tsx | 2 +- adminSiteClient/VariableEditPage.tsx | 38 +++++- devTools/schemaProcessor/columns.json | 11 ++ .../core-table/src/CoreTableColumns.ts | 23 +++- .../@ourworldindata/grapher/src/axis/Axis.ts | 2 + .../grapher/src/color/ColorScale.ts | 10 +- .../grapher/src/dataTable/DataTable.tsx | 2 + .../src/entitySelector/EntitySelector.tsx | 3 +- .../grapher/src/lineCharts/LineChart.tsx | 25 +++- .../grapher/src/mapCharts/MapChart.tsx | 2 +- .../grapher/src/mapCharts/MapTooltip.tsx | 55 +++++++-- .../src/scatterCharts/ScatterPlotChart.tsx | 49 +++++++- .../src/scatterCharts/ScatterSizeLegend.tsx | 15 ++- .../src/stackedCharts/MarimekkoChart.tsx | 44 ++++++- .../src/stackedCharts/StackedAreaChart.tsx | 35 +++++- .../src/stackedCharts/StackedBarChart.tsx | 36 +++++- .../stackedCharts/StackedDiscreteBarChart.tsx | 28 ++++- .../grapher/src/tooltip/Tooltip.scss | 33 +++-- .../grapher/src/tooltip/Tooltip.tsx | 28 +++-- .../grapher/src/tooltip/TooltipContents.tsx | 75 +++++++++-- .../grapher/src/tooltip/TooltipProps.ts | 12 +- .../types/src/grapherTypes/GrapherTypes.ts | 7 +- .../@ourworldindata/utils/src/Util.test.ts | 23 ++++ packages/@ourworldindata/utils/src/Util.ts | 9 ++ .../utils/src/formatValue.test.ts | 116 +++++++++++++++++- .../@ourworldindata/utils/src/formatValue.ts | 75 ++++++++--- packages/@ourworldindata/utils/src/index.ts | 1 + 27 files changed, 659 insertions(+), 100 deletions(-) diff --git a/adminSiteClient/DimensionCard.tsx b/adminSiteClient/DimensionCard.tsx index 26cf69f9f72..c268c73528d 100644 --- a/adminSiteClient/DimensionCard.tsx +++ b/adminSiteClient/DimensionCard.tsx @@ -2,7 +2,7 @@ import React from "react" import { observable, computed, action } from "mobx" import { observer } from "mobx-react" import { ChartDimension } from "@ourworldindata/grapher" -import { OwidVariableId, OwidVariableRoundingMode } from "@ourworldindata/types" +import { OwidVariableRoundingMode } from "@ourworldindata/types" import { startCase } from "@ourworldindata/utils" import { ChartEditor, DimensionErrorMessage } from "./ChartEditor.js" import { diff --git a/adminSiteClient/VariableEditPage.tsx b/adminSiteClient/VariableEditPage.tsx index 281201496a4..e8461b7eb7c 100644 --- a/adminSiteClient/VariableEditPage.tsx +++ b/adminSiteClient/VariableEditPage.tsx @@ -19,6 +19,7 @@ import { FieldsRow, BindDropdown, Toggle, + SelectField, } from "./Forms.js" import { OwidVariableWithDataAndSource, @@ -38,7 +39,11 @@ import { OriginList } from "./OriginList.js" import { SourceList } from "./SourceList.js" import { AdminAppContext, AdminAppContextType } from "./AdminAppContext.js" import { Base64 } from "js-base64" -import { GrapherTabOption, GrapherInterface } from "@ourworldindata/types" +import { + GrapherTabOption, + GrapherInterface, + OwidVariableRoundingMode, +} from "@ourworldindata/types" import { Grapher } from "@ourworldindata/grapher" import { faCircleInfo } from "@fortawesome/free-solid-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" @@ -250,19 +255,42 @@ class VariableEditor extends React.Component<{ variable: VariablePageData }> { store={newVariable.display} placeholder={newVariable.shortUnit} /> + + { + const roundingMode = + value as OwidVariableRoundingMode + newVariable.display.roundingMode = + roundingMode !== + OwidVariableRoundingMode.fixed + ? 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..ffba2642cc7 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": ["fixed", "significant"] + }, { "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 4d95ec86328..75eaca88695 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,13 +224,26 @@ export abstract class AbstractCoreColumn { return this.originalTimeColumn.formatValue(time) } + @imemo get roundingMode(): OwidVariableRoundingMode { + return this.display?.roundingMode ?? OwidVariableRoundingMode.fixed + } + + @imemo get isRoundingToFixedDecimals(): boolean { + return this.roundingMode === OwidVariableRoundingMode.fixed + } + + @imemo get isRoundingToSignificantFigures(): boolean { + return this.roundingMode === OwidVariableRoundingMode.significant + } + @imemo get numDecimalPlaces(): number { - return this.display?.numDecimalPlaces ?? 2 + return this.isRoundingToFixedDecimals + ? this.display?.numDecimalPlaces ?? 2 + : 2 } @imemo get numSignificantFigures(): number { - // TODO: what should the default be? - return this.display?.numSignificantFigures ?? 4 + return this.display?.numSignificantFigures ?? 3 } @imemo get unit(): string | undefined { @@ -617,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, }) } @@ -736,6 +752,7 @@ class IntegerColumn extends NumericColumn { class CurrencyColumn extends NumericColumn { formatValue(value: unknown, options?: TickFormattingOptions): string { return super.formatValue(value, { + roundingMode: OwidVariableRoundingMode.fixed, 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..8c31241504f 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.fixed, } // 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..8c2c98516c4 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.fixed, + } 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..53521b968cb 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.fixed, 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..654147e4f4b 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.isRoundingToSignificantFigures + ? { + icon: TooltipFooterIcon.none, + text: makeTooltipRoundingNotice( + formatColumn.numSignificantFigures + ), + } : undefined + const footer = excludeUndefined([projectionNotice, roundingNotice]) return ( { 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 +92,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,7 +215,7 @@ export class MapTooltip extends React.Component { } render(): React.ReactElement { - const { mapTable, datum, lineColorScale } = this + const { mapTable, mapColumn, datum, lineColorScale } = this const { targetTime, formatValue, @@ -231,14 +248,34 @@ export class MapTooltip extends React.Component { useCustom = isString(minCustom) && isString(maxCustom), minLabel = useCustom ? minCustom - : yColumn.formatValueShort(minVal ?? 0), + : yColumn.formatValueShort(minVal ?? 0, { + roundingMode: OwidVariableRoundingMode.fixed, + }), maxLabel = useCustom ? maxCustom - : yColumn.formatValueShort(maxVal ?? 0) + : yColumn.formatValueShort(maxVal ?? 0, { + roundingMode: OwidVariableRoundingMode.fixed, + }) 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 = mapColumn.isRoundingToSignificantFigures + ? { + icon: TooltipFooterIcon.dagger, + text: makeTooltipRoundingNotice( + mapColumn.numSignificantFigures, + { multipleValues: false } + ), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) const labelX = axisBounds.right - SPARKLINE_NUDGE const labelTop = axisBounds.top - SPARKLINE_NUDGE @@ -258,14 +295,14 @@ 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.isRoundingToSignificantFigures + ) + const anyRoundedToSigFigs = columns.some( + (column) => column.isRoundingToSignificantFigures + ) + const sigFigs = excludeUndefined( + columns.map((column) => + column.isRoundingToSignificantFigures + ? column.numSignificantFigures + : undefined + ) + ) + const toleranceNotice = targetNotice + ? { + icon: TooltipFooterIcon.notice, + text: makeTooltipToleranceNotice(targetNotice), + } + : undefined + const roundingNotice = anyRoundedToSigFigs + ? { + icon: allRoundedToSigFigs + ? TooltipFooterIcon.none + : TooltipFooterIcon.dagger, + text: makeTooltipRoundingNotice(sigFigs), + } + : undefined + const footer = excludeUndefined([toleranceNotice, roundingNotice]) + const dagger = !allRoundedToSigFigs + return ( v.size))} + dagger={dagger} /> ) diff --git a/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx b/packages/@ourworldindata/grapher/src/scatterCharts/ScatterSizeLegend.tsx index 3f4529aa067..55259792aeb 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.isRoundingToSignificantFigures + ) + const anyRoundedToSigFigs = columns.some( + (column) => column.isRoundingToSignificantFigures + ) + const sigFigs = excludeUndefined( + columns.map((column) => + column.isRoundingToSignificantFigures + ? column.numSignificantFigures + : undefined + ) + ) + const roundingNotice = anyRoundedToSigFigs + ? { + icon: allRoundedToSigFigs + ? TooltipFooterIcon.none + : TooltipFooterIcon.dagger, + text: makeTooltipRoundingNotice(sigFigs), + } + : undefined + const dagger = !allRoundedToSigFigs + + const footer = excludeUndefined([toleranceNotice, roundingNotice]) return ( {yValues.map(({ name, value, notice }) => ( @@ -1036,6 +1070,7 @@ export class MarimekkoChart column={yColumn} value={value} notice={notice} + dagger={dagger} /> ))} {xColumn && ( @@ -1043,6 +1078,7 @@ export class MarimekkoChart column={xColumn} value={xPoint?.value} notice={xNotice} + dagger={dagger} /> )} diff --git a/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx b/packages/@ourworldindata/grapher/src/stackedCharts/StackedAreaChart.tsx index 519837d36c8..5e4496dcb30 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.isRoundingToSignificantFigures + ? { + 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..f0372200139 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.isRoundingToSignificantFigures + ? { + 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..bca579e884c 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.isRoundingToSignificantFigures + ? { + 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} > div + div { + margin-top: 4px; + } + + > div.no-icon { + font-style: italic; + } + .icon { position: absolute; width: 12px; @@ -336,6 +354,11 @@ height: 12px; @include diagonal-background($grey, white); } + + &.dagger { + text-align: center; + font-size: 0.9em; + } } p { @@ -346,16 +369,6 @@ max-width: 260px; } - // add boilerplate around time value for tolerance notices - p.tolerance { - &::before { - content: "Data not available for "; - } - &::after { - content: ". Showing closest available data point instead"; - } - } - .icon ~ p { padding-left: 19px; } diff --git a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx index c6482210994..77e90e2c680 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/Tooltip.tsx @@ -5,13 +5,20 @@ import { observer } from "mobx-react" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" import { faInfoCircle } from "@fortawesome/free-solid-svg-icons" import { Bounds, PointVector } from "@ourworldindata/utils" -import { TooltipProps, TooltipManager, TooltipFadeMode } from "./TooltipProps" +import { + TooltipProps, + TooltipManager, + TooltipFadeMode, + TooltipFooterIcon, +} from "./TooltipProps" export * from "./TooltipContents.js" export const TOOLTIP_FADE_DURATION = 400 // $fade-time + $fade-delay in scss -const TOOLTIP_ICON = { +const TOOLTIP_ICON: Record = { notice: , stripes:
, + dagger:
, + none: null, } export class TooltipState { @@ -87,7 +94,6 @@ class TooltipCard extends React.Component< subtitle, subtitleFormat, footer, - footerFormat, dissolve, children, offsetX = 0, @@ -131,7 +137,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 +167,19 @@ class TooltipCard extends React.Component<
)} {children &&
{children}
} - {footer && ( + {footer && footer.length > 0 && (
- {footerFormat && TOOLTIP_ICON[footerFormat]} -

{footer}

+ {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..43260623f71 100644 --- a/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx +++ b/packages/@ourworldindata/grapher/src/tooltip/TooltipContents.tsx @@ -4,7 +4,14 @@ 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, + formatList, +} from "@ourworldindata/utils" import { TooltipTableProps, TooltipValueProps, @@ -15,20 +22,27 @@ 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, dagger } = this.props, displayValue = isNumber(value) ? column.formatValueShort(value) : value ?? NO_DATA_LABEL, displayColor = displayValue === NO_DATA_LABEL ? NO_DATA_COLOR : color + const { isRoundingToSignificantFigures } = column + const showRoundingFootnote = dagger && isRoundingToSignificantFigures + const roundingFootnote = showRoundingFootnote ? : null + return ( - {displayValue} + + {displayValue} + {roundingFootnote} + ) } @@ -54,7 +68,7 @@ export class TooltipValueRange extends React.Component { } render(): React.ReactElement | null { - const { column, values, color, notice } = this.props, + const { column, values, color, notice, dagger } = this.props, [firstValue, lastValue] = values.map((v) => column.formatValueShort(v) ), @@ -75,12 +89,24 @@ export class TooltipValueRange extends React.Component { ? this.arrowIcon("down") : this.arrowIcon("right") + const { isRoundingToSignificantFigures } = column + const showRoundingFootnote = dagger && isRoundingToSignificantFigures + const roundingFootnote = showRoundingFootnote ? : null + return values.length ? ( - {firstTerm} + + {firstTerm} + {!lastTerm && roundingFootnote} + {trend} - {lastTerm} + {lastTerm && ( + + {lastTerm} + {roundingFootnote} + + )} ) : null @@ -177,6 +203,7 @@ export class TooltipTable extends React.Component { values, notice, swatch = "transparent", + dagger, } = row const [_m, seriesName, seriesParenthetical] = name.trim().match(/^(.*?)(\([^()]*\))?$/) ?? [] @@ -210,8 +237,17 @@ export class TooltipTable extends React.Component { )} {zip(columns, values).map(([column, value]) => { + if (!column) return null + const { isRoundingToSignificantFigures } = + column + const showRoundingFootnote = + dagger && isRoundingToSignificantFigures + const roundingFootnote = + showRoundingFootnote ? ( + + ) : null const missing = value === undefined - return column ? ( + return ( { value, format )} + {!missing && roundingFootnote} - ) : null + ) })} {notice && ( @@ -263,3 +300,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[], + { multipleValues }: { multipleValues: boolean } = { multipleValues: true } +): string { + let numSigFigs: string = "" + if (isNumber(numSignificantFigures)) { + numSigFigs = `${numSignificantFigures}` + } else { + const uniqueNumSigFigs = uniq(numSignificantFigures) + numSigFigs = formatList(sortBy(uniqueNumSigFigs), "or") + } + + const values = multipleValues ? "Values" : "Value" + const are = multipleValues ? "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..8b3474699eb 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", + dagger = "dagger", + 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) + dagger?: boolean // show footnote dagger 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) + dagger?: boolean // show footnote dagger 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)[] + dagger?: boolean // show footnote dagger if applicable } export interface TooltipTableData { 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/utils/src/Util.test.ts b/packages/@ourworldindata/utils/src/Util.test.ts index 9a1a44952cc..5635c684dbf 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, + formatList, } from "./Util.js" import { BlockImageSize, @@ -793,3 +794,25 @@ describe(cartesian, () => { ]) }) }) + +describe(formatList, () => { + it("returns an empty string when no items are given", () => { + expect(formatList([])).toEqual("") + }) + + it("returns a single item as a string", () => { + expect(formatList(["a"])).toEqual("a") + }) + + it("concats two items correctly", () => { + expect(formatList(["a", "b"])).toEqual("a and b") + }) + + it("concats three items correctly using 'and'", () => { + expect(formatList(["a", "b", "c"])).toEqual("a, b and c") + }) + + it("returns four items correctly using 'or'", () => { + expect(formatList(["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 e1475feb8f1..7eaf1519cfc 100644 --- a/packages/@ourworldindata/utils/src/Util.ts +++ b/packages/@ourworldindata/utils/src/Util.ts @@ -1913,3 +1913,12 @@ export function commafyNumber(value: number): string { export function isFiniteWithGuard(value: unknown): value is number { return isFinite(value as any) } + +export function formatList( + 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..3e24ea71dcb 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 signficant figure", 0.0012, '0.001', {numSignificantFigures: 1}], + ["0.0012 with 2 signficant figures", 0.0012, '0.0012', {numSignificantFigures: 2}], + ["0.0012 with 3 signficant 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.significant, + }) + ).toBe(output) + }) + }) +}) diff --git a/packages/@ourworldindata/utils/src/formatValue.ts b/packages/@ourworldindata/utils/src/formatValue.ts index 53286c4e622..457fa6994b7 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.significant + ? "" + : 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.fixed]: "f", + [OwidVariableRoundingMode.significant]: "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.significant) { + 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.significant) { + 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.fixed, + 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 a06387d836b..a829669bf5a 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -126,6 +126,7 @@ export { roundDownToNearestHundred, commafyNumber, isFiniteWithGuard, + formatList, } from "./Util.js" export {