diff --git a/docs/docs/reference/project-files/dashboards.md b/docs/docs/reference/project-files/dashboards.md index d967bd72929..ff2ce019b06 100644 --- a/docs/docs/reference/project-files/dashboards.md +++ b/docs/docs/reference/project-files/dashboards.md @@ -47,7 +47,8 @@ _**`measures`**_ — numeric [aggregates](../../develop/metrics-dashboard#measur - _**`format_preset`**_ — controls the formatting of this measure in the dashboard according to option specified below. Measures cannot have both `format_preset` and `format_d3` entries. _(optional; if neither `format_preset` nor `format_d3` is supplied, measures will be formatted with the `humanize` preset)_ - _`humanize`_ — round off numbers in an opinionated way to thousands (K), millions (M), billions (B), etc - _`none`_ — raw output - - _`currency_usd`_ — output rounded to 2 decimal points prepended with a dollar sign + - _`currency_usd`_ — output rounded to 2 decimal points prepended with a dollar sign: `$` + - _`currency_eur`_ — output rounded to 2 decimal points prepended with a euro symbol: `€` - _`percentage`_ — output transformed from a rate to a percentage appended with a percentage sign - _`interval_ms`_ — time intervals given in milliseconds are transformed into human readable time units like hours (h), days (d), years (y), etc diff --git a/web-common/src/components/data-graphic/guides/Axis.svelte b/web-common/src/components/data-graphic/guides/Axis.svelte index 6d0c97a06bc..397846feed6 100644 --- a/web-common/src/components/data-graphic/guides/Axis.svelte +++ b/web-common/src/components/data-graphic/guides/Axis.svelte @@ -168,7 +168,6 @@ This component will draw an axis on the specified side. // this formatter often does the right thing, but may not in some // circumstances. See https://github.com/rilldata/rill/issues/3631 const formatter = new SingleDigitTimesPowerOfTenFormatter(ticks, { - strategy: "singleDigitTimesPowerOfTen", numberKind, padWithInsignificantZeros: false, }); diff --git a/web-common/src/lib/number-formatting/format-measure-value.ts b/web-common/src/lib/number-formatting/format-measure-value.ts index 17ef13e94b9..0645cf43915 100644 --- a/web-common/src/lib/number-formatting/format-measure-value.ts +++ b/web-common/src/lib/number-formatting/format-measure-value.ts @@ -3,49 +3,82 @@ import { format as d3format } from "d3-format"; import { FormatPreset, formatPresetToNumberKind, + NumberKind, type FormatterFactoryOptions, } from "./humanizer-types"; import { formatMsInterval, formatMsToDuckDbIntervalString, } from "./strategies/intervals"; -import { humanizedFormatterFactory } from "./humanizer"; - -export function defaultHumanizer(value: number): string { - return humanizeDataType(value, FormatPreset.HUMANIZE); -} +import { PerRangeFormatter } from "./strategies/per-range"; +import { + defaultCurrencyOptions, + defaultGenericNumOptions, + defaultPercentOptions, +} from "./strategies/per-range-default-options"; +import { NonFormatter } from "./strategies/none"; /** * This function is intended to provides a compact, * potentially lossy, humanized string representation of a number. */ -function humanizeDataType(value: number, type: FormatPreset): string { +function humanizeDataType(value: number, preset: FormatPreset): string { if (typeof value !== "number") { console.warn( - `humanizeDataType only accepts numbers, got ${value} for FormatPreset "${type}"`, + `humanizeDataType only accepts numbers, got ${value} for FormatPreset "${preset}"`, ); return JSON.stringify(value); } - const numberKind = formatPresetToNumberKind(type); - let innerOptions: FormatterFactoryOptions; + const numberKind = formatPresetToNumberKind(preset); - if (type === FormatPreset.NONE) { - innerOptions = { - strategy: "none", + let options: FormatterFactoryOptions; + + if (preset === FormatPreset.NONE) { + options = { numberKind, padWithInsignificantZeros: false, }; - } else if (type === FormatPreset.INTERVAL) { - return formatMsInterval(value); } else { - innerOptions = { - strategy: "default", + options = { numberKind, }; } - return humanizedFormatterFactory([value], innerOptions).stringFormat(value); + + switch (preset) { + case FormatPreset.NONE: + return new NonFormatter(options).stringFormat(value); + + case FormatPreset.CURRENCY_USD: + return new PerRangeFormatter( + defaultCurrencyOptions(NumberKind.DOLLAR), + ).stringFormat(value); + + case FormatPreset.CURRENCY_EUR: + return new PerRangeFormatter( + defaultCurrencyOptions(NumberKind.EURO), + ).stringFormat(value); + + case FormatPreset.PERCENTAGE: + return new PerRangeFormatter(defaultPercentOptions).stringFormat(value); + + case FormatPreset.INTERVAL: + return formatMsInterval(value); + + case FormatPreset.HUMANIZE: + return new PerRangeFormatter(defaultGenericNumOptions).stringFormat( + value, + ); + + default: + console.warn( + "Unknown format preset, using default formatter. All number kinds should be handled.", + ); + return new PerRangeFormatter(defaultGenericNumOptions).stringFormat( + value, + ); + } } /** diff --git a/web-common/src/lib/number-formatting/humanizer-types.ts b/web-common/src/lib/number-formatting/humanizer-types.ts index 57eb6d43542..a31fa442b86 100644 --- a/web-common/src/lib/number-formatting/humanizer-types.ts +++ b/web-common/src/lib/number-formatting/humanizer-types.ts @@ -7,7 +7,8 @@ import type { MetricsViewSpecMeasureV2 } from "@rilldata/web-common/runtime-clie export enum FormatPreset { HUMANIZE = "humanize", NONE = "none", - CURRENCY = "currency_usd", + CURRENCY_USD = "currency_usd", + CURRENCY_EUR = "currency_eur", PERCENTAGE = "percentage", INTERVAL = "interval_ms", } @@ -16,6 +17,24 @@ export enum FormatPreset { * This enum represents the semantic kind of the number being * handled (which is not the same thing as how the number is * formatted, though it can inform formatting). + * + * NOTE: (brendan, Jan 2024) + * Requirements have changed since this was written, + * and it's due for a rethinking. Based on experience and + * requirements surfaced over the past year, recommended + * approach would be to replace NumberKind with + * something that makes the following concepts orthogonal + * - units (dollar, euro, percent, etc) + * - underlying conceptual number type (integer, real, 2-digit decimal). + * Note that in JS, these are all _stored_ as floats, but retaining + * the conceptual type is important for presentation + * - formatting precision -- this can vary based on context, + * and could include options like + * - full number (which might still involve rounding off floating + * point errors for a Decimal) + * - some number of significant digits with letter suffixes + * - single digit time power of ten representation + * - etc... */ export enum NumberKind { /** @@ -26,6 +45,14 @@ export enum NumberKind { */ DOLLAR = "DOLLAR", + /** + * A real number with units of EUROS. Note that this + * does not imply any restriction on the range of the number; + * ANY positive or negative real number of ANY SIZE can have + * this units. + */ + EURO = "EURO", + /** * A real number with units of "%". Note that this * does not imply any restriction on the range of the number; @@ -56,9 +83,12 @@ export enum NumberKind { */ export const formatPresetToNumberKind = (type: FormatPreset) => { switch (type) { - case FormatPreset.CURRENCY: + case FormatPreset.CURRENCY_USD: return NumberKind.DOLLAR; + case FormatPreset.CURRENCY_EUR: + return NumberKind.EURO; + case FormatPreset.PERCENTAGE: return NumberKind.PERCENT; @@ -98,7 +128,7 @@ export const numberKindForMeasure = (measure: MetricsViewSpecMeasureV2) => { export type NumberParts = { neg?: "-"; - dollar?: "$"; + currencySymbol?: "$" | "€"; int: string; dot: "" | "."; frac: string; @@ -108,12 +138,35 @@ export type NumberParts = { }; /** - * This is a no-op strategy that + * Options common to all formatting strategies */ -export type FormatterOptionsNoneStrategy = { - strategy: "none"; +export type FormatterOptionsCommon = { + /** + * The kind of number being formatted + */ + + numberKind: NumberKind; + + /** + * If true, pad numbers with insignificant zeros in order + * to have a consistent number of digits to the right of the + * decimal point + */ + padWithInsignificantZeros?: boolean; + + /** + * If `true`, use upper case "E" for exponential notation. + * If `false` or `undefined`, use lower case "e" + */ + upperCaseEForExponent?: boolean; }; +/** + * This strategy does not apply formatting to the _number itself_, + * but does add units if needed. + */ +export type FormatterOptionsNoneStrategy = FormatterOptionsCommon; + /** * Strategy for handling numbers that are guaranteed to be an * integer multiple of a power of ten, such as the output of @@ -128,46 +181,54 @@ export type FormatterOptionsNoneStrategy = { * or log a warning if a of a non integer multiple of a power * of ten given as an input. */ -export type FormatterOptionsIntTimesPowerOfTenStrategy = { - strategy: "singleDigitTimesPowerOfTen"; - onInvalidInput?: "doNothing" | "throw" | "consoleWarn"; -}; +export type FormatterOptionsIntTimesPowerOfTenStrategy = + FormatterOptionsCommon & { + onInvalidInput?: "doNothing" | "throw" | "consoleWarn"; + }; /** - * The "default" strategy actaully delegates to a set of - * pre-defined FormatterRangeSpecsStrategies, one for - * each of the three NumberKinds currently supported. - */ -export type FormatterOptionsDefaultStrategy = { - strategy: "default"; -}; - -/** - * Specifies a set of formatting options + * Specifies a set of formatting options for numbers within + * a given order of magnitude range. */ export type RangeFormatSpec = { - // minimum order of magnitude for this range. - // Target number must have OoM >= minMag. + /** + * Minimum order of magnitude for this range. + * Target number must have OoM >= minMag. + */ minMag: number; - // supremum number for this range. - // Target number must have OoM < supMag. + + /** + * Supremum order of magnitude for this range. + * Target number must have OoM OoM < supMag. + */ supMag: number; - // max number of digits left of decimal point - // if undefined, default is 3 digits + /** + *Max number of digits left of decimal point. + * If undefined, default is 3 digits + */ maxDigitsLeft?: number; - // max number of digits right of decimal point + + /** + * Max number of digits right of decimal point. + */ maxDigitsRight: number; - // This sets the order of magnitude used to format numbers - // in this range. For example, if baseMagnitude=3, then we'd have: - // - 1,000,000 => 1,000k - // - 100 => .1k - // If this is set to 0, numbers in this range - // will be rendered as plain numbers (no suffix). - // If not set, the engineering magnitude of `min` is used by default. + + /** + * If set, this will be used as the order of magnitude + * for formatting numbers in this range. + * For example, if baseMagnitude=3, then we'd have: + * - 1,000,000 => 1,000k + * - 100 => .1k + * If this is set to 0, numbers in this range + * will be rendered as plain numbers (no suffix). + * If not set, the engineering magnitude of `min` is used by default. + */ baseMagnitude?: number; - // if not set, treated as true + /** + * Whether or not to pad numbers with insignificant zeros. If undefined, treated as true + */ padWithInsignificantZeros?: boolean; /** @@ -190,7 +251,7 @@ export type RangeFormatSpec = { * * `rangeSpecs` is a series of RangeFormatSpecs. Ranges may not overlap, * and there can be no gaps in coverage between the ranges that - * are defined, though the it is not required the the entire + * are defined, though it is not required the the entire * number line be covered--defaults will be used outside of the * covered range. * @@ -213,108 +274,21 @@ export type RangeFormatSpec = { * if more than three digits are desired left of the decimal point, an * explicit range must be set with maxDigitsLeft. */ -export type FormatterRangeSpecsStrategy = { - strategy: "perRange"; +export type FormatterRangeSpecsStrategy = FormatterOptionsCommon & { rangeSpecs: RangeFormatSpec[]; defaultMaxDigitsRight: number; }; -// FIXME: These strategies still need production grade implementation. -// If we decide not to implement these strategis for production, -// this code can be removed. -// export type FormatterOptionsLargestMag = { -// // options specific to the largestMagnitude strategy -// strategy: "largestMagnitude"; -// }; -// export type FormatterOptionsDigitBudget = { -// // options specific to the multipleMagnitudes strategy -// strategy: "digitBudget"; -// maxDigitsLeft: number; -// maxDigitsRight: number; -// minDigitsNonzero: number; - -// // Method for showing that non-integers have a fractional -// // part if they would otherwise be rounded such that they -// // have no fractional digits. -// // "none": don't do anything special. -// // Ex: 21379.23 with max 5 digits would be "21379" -// // "trailingDot": add a trailing decimal point if a non-integer -// // would be truncated to the e0 digit. -// // Ex: 21379.23 with max 5 digits would be "21379." -// // "reserveDigit": Always reserve one digit from the max digit -// // budget to show a digit of precision after the decimal point. -// // Ex: 21379.23 with max 5 digits would require an order of mag -// // suffix, e.g. "21.379 k"; or with max 6 digits "21379.2" -// nonIntHandling: "none" | "trailingDot" | "reserveDigit"; -// }; - -export type FormatterOptionsCommon = { - // Options common to all strategies - - // max number of digits to be shown for formatted numbers - // maxTotalDigits: number; - - // The kind of number being formatted - numberKind: NumberKind; - - // If true, pad numbers with insignificant zeros in order - // to have a consistent number of digits to the right of the - // decimal point - padWithInsignificantZeros?: boolean; - - // method for formatting exact zeros - // "none": don't do anything special. - // Ex: If the general option padWithInsignificantZeros is used such - // that e.g. a 0 is rendered as "0.000", then if - // this option is "none", the trailing zeros will be retained - // "trailingDot": add a trailing decimal point to exact zeros "0." - // "zeroOnly": render exact zeros as "0" - // zeroHandling: "none" | "trailingDot" | "zeroOnly"; - - // pxWidthLookupFn?: PxWidthLookupFn; - - // not actually used for formatting, but needed to calculate the - // px sizes of maxWidthsInSample and maxWidthsPossible - // alignDecimal?: boolean; - - // If `true`, use upper case "E" for exponential notation; - // If `false` or `undefined`, use lower case - upperCaseEForExponent?: boolean; -}; - -export type FormatterFactoryOptions = ( +export type FormatterFactoryOptions = | FormatterOptionsNoneStrategy - // FIXME: These strategies still need production grade implementation. - // If we decide not to implement these strategis for production, - // this code can be removed. - // | FormatterOptionsDigitBudget - // | FormatterOptionsLargestMag - | FormatterOptionsIntTimesPowerOfTenStrategy - | FormatterRangeSpecsStrategy - | FormatterOptionsDefaultStrategy -) & - FormatterOptionsCommon; + | FormatterRangeSpecsStrategy; export type NumPartPxWidthLookupFn = (str: string, isNumStr: boolean) => number; -export type FormatterFactory = ( - sample: number[], - options: FormatterFactoryOptions, -) => Formatter; +export type FormatterFactory = (options: FormatterFactoryOptions) => Formatter; export interface Formatter { options: FormatterFactoryOptions; - stringFormat(x: number): string; - partsFormat(x: number): NumberParts; - - // FIXME: we can add these parts of the interface back in if we want to implement - // alignment. If we decide that we don't want to pursue that, - // we can remove this commented code - // largestPossibleNumberStringParts: NumberParts; - // maxPxWidthsSampled(): FormatterWidths; - // maxPxWidthsPossible(): FormatterWidths; - // maxCharWidthsSampled(): FormatterWidths; - // maxCharWidthsPossible(): FormatterWidths; } diff --git a/web-common/src/lib/number-formatting/humanizer.ts b/web-common/src/lib/number-formatting/humanizer.ts deleted file mode 100644 index be7719329b2..00000000000 --- a/web-common/src/lib/number-formatting/humanizer.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Formatter, FormatterFactory, NumberKind } from "./humanizer-types"; -import { SingleDigitTimesPowerOfTenFormatter } from "./strategies/SingleDigitTimesPowerOfTen"; -import { NonFormatter } from "./strategies/none"; -import { IntervalFormatter } from "./strategies/intervals"; -import { PerRangeFormatter } from "./strategies/per-range"; -import { - defaultDollarOptions, - defaultGenericNumOptions, - defaultPercentOptions, -} from "./strategies/per-range-default-options"; - -/** - * This FormatterFactory is intended to be the user-facing - * wrapper for formatters. This fumction delegates to a number - * of different formatters depending upon the options - * used, but by going through this wrapper those details - * can be somewhat abstracted away in favor of config - * options. - * - * @param sample - * @param options - * @returns Formatter - */ -export const humanizedFormatterFactory: FormatterFactory = ( - sample: number[], - options, -): Formatter => { - let formatter: Formatter; - - switch (options.strategy) { - case "none": - formatter = new NonFormatter(sample, options); - break; - - case "default": - // default strategy simply - // delegates to the range strategy formatter with - // appropriate default presets for NumberKind - switch (options.numberKind) { - case NumberKind.DOLLAR: - formatter = new PerRangeFormatter(sample, defaultDollarOptions); - break; - case NumberKind.PERCENT: - formatter = new PerRangeFormatter(sample, defaultPercentOptions); - break; - case NumberKind.INTERVAL: - formatter = new IntervalFormatter(); - break; - default: - formatter = new PerRangeFormatter(sample, defaultGenericNumOptions); - break; - } - break; - - case "singleDigitTimesPowerOfTen": - formatter = new SingleDigitTimesPowerOfTenFormatter(sample, options); - break; - - default: - console.warn( - `Number formatter strategy "${options.strategy}" is not implemented, using default strategy`, - ); - formatter = new PerRangeFormatter(sample, defaultGenericNumOptions); - break; - } - - return formatter; -}; diff --git a/web-common/src/lib/number-formatting/percentage-formatter.ts b/web-common/src/lib/number-formatting/percentage-formatter.ts index 1379dda899a..c729e9ad586 100644 --- a/web-common/src/lib/number-formatting/percentage-formatter.ts +++ b/web-common/src/lib/number-formatting/percentage-formatter.ts @@ -33,8 +33,7 @@ export function formatMeasurePercentageDifference(value: number): NumberParts { }; } - const factory = new PerRangeFormatter([], { - strategy: "perRange", + const factory = new PerRangeFormatter({ rangeSpecs: [ { minMag: -2, diff --git a/web-common/src/lib/number-formatting/proper-fraction-formatter.ts b/web-common/src/lib/number-formatting/proper-fraction-formatter.ts index 868bf03c6b7..c68f9f69d9f 100644 --- a/web-common/src/lib/number-formatting/proper-fraction-formatter.ts +++ b/web-common/src/lib/number-formatting/proper-fraction-formatter.ts @@ -29,8 +29,7 @@ export function formatProperFractionAsPercent(value: number): NumberParts { } else if (value === 0) { return { percent: "%", int: "0", dot: "", frac: "", suffix: "" }; } - const factory = new PerRangeFormatter([], { - strategy: "perRange", + const factory = new PerRangeFormatter({ rangeSpecs: [ { minMag: -2, diff --git a/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.spec.ts b/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.spec.ts index e8e84e83c21..2e6caee98d3 100644 --- a/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.spec.ts +++ b/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.spec.ts @@ -6,10 +6,8 @@ import { import { describe, it, expect } from "vitest"; const baseOptions: FormatterFactoryOptions = { - strategy: "singleDigitTimesPowerOfTen", padWithInsignificantZeros: true, numberKind: NumberKind.ANY, - onInvalidInput: "doNothing", }; const closeToIntTimesPowerOfTenCases: [number, boolean][] = [ diff --git a/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.ts b/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.ts index d65f4056cf1..83e68f0bc7d 100644 --- a/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.ts +++ b/web-common/src/lib/number-formatting/strategies/SingleDigitTimesPowerOfTen.ts @@ -44,11 +44,9 @@ export const closeToIntTimesPowerOfTen = (x: number) => * of ten given as an input. */ export class SingleDigitTimesPowerOfTenFormatter implements Formatter { - options: FormatterOptionsCommon & FormatterOptionsIntTimesPowerOfTenStrategy; + options: FormatterOptionsIntTimesPowerOfTenStrategy; initialSample: number[]; - largestPossibleNumberStringParts: NumberParts; - constructor( sample: number[], options: FormatterOptionsCommon & @@ -56,16 +54,6 @@ export class SingleDigitTimesPowerOfTenFormatter implements Formatter { ) { this.options = options; this.initialSample = sample; - - this.largestPossibleNumberStringParts = { - neg: "-", - dollar: options.numberKind === NumberKind.DOLLAR ? "$" : undefined, - int: "999", - dot: "", - frac: "", - suffix: "e-324", - percent: options.numberKind === NumberKind.PERCENT ? "%" : undefined, - }; } stringFormat(x: number): string { @@ -82,7 +70,6 @@ export class SingleDigitTimesPowerOfTenFormatter implements Formatter { } const { onInvalidInput } = this.options; - const isCurrency = this.options.numberKind === NumberKind.DOLLAR; const isPercent = this.options.numberKind === NumberKind.PERCENT; if (isPercent) x = 100 * x; @@ -124,8 +111,10 @@ export class SingleDigitTimesPowerOfTenFormatter implements Formatter { } } - if (isCurrency) { - numParts.dollar = "$"; + if (this.options.numberKind === NumberKind.DOLLAR) { + numParts.currencySymbol = "$"; + } else if (this.options.numberKind === NumberKind.EURO) { + numParts.currencySymbol = "€"; } if (this.options.numberKind === NumberKind.PERCENT) { numParts.percent = "%"; diff --git a/web-common/src/lib/number-formatting/strategies/intervals.spec.ts b/web-common/src/lib/number-formatting/strategies/intervals.spec.ts index cc3f9fbea3c..547874cc3ae 100644 --- a/web-common/src/lib/number-formatting/strategies/intervals.spec.ts +++ b/web-common/src/lib/number-formatting/strategies/intervals.spec.ts @@ -23,8 +23,7 @@ describe("formatMsInterval - non numeric inputs", () => { try { inputString = JSON.stringify(input); } catch (error) { - //@ts-ignore - inputString = input.toString(); + inputString = input?.toString(); } it(`returns the empty string for non numeric input: ${inputString}`, () => { @@ -82,7 +81,7 @@ const time_formula_normal_cases = [ describe("formatMsInterval - normal cases", () => { time_formula_normal_cases.forEach(([input, output]) => { - const ms = eval(input); + const ms = eval(input) as number; it(`return "${output}" for input: ${ms.toString()}ms (${input})`, () => { expect(formatMsInterval(ms)).toEqual(output); }); @@ -142,7 +141,7 @@ const time_formula_special_cases = [ describe("formatMsInterval - special cases", () => { time_formula_special_cases.forEach(([input, output]) => { - const ms = eval(input.toString()); + const ms = eval(input.toString()) as number; it(`return "${output}" for input: ${ms.toString()}ms (${input})`, () => { expect(formatMsInterval(ms)).toEqual(output); }); diff --git a/web-common/src/lib/number-formatting/strategies/intervals.ts b/web-common/src/lib/number-formatting/strategies/intervals.ts index 0a0f3d934fa..1346806a00c 100644 --- a/web-common/src/lib/number-formatting/strategies/intervals.ts +++ b/web-common/src/lib/number-formatting/strategies/intervals.ts @@ -24,10 +24,8 @@ const timeUnits = { y: "y", }; -// TODO: Rewrite this to use the sample and provided options export class IntervalFormatter implements Formatter { options: FormatterOptionsCommon & FormatterRangeSpecsStrategy; - initialSample: number[]; stringFormat(x: number): string { return formatMsInterval(x); diff --git a/web-common/src/lib/number-formatting/strategies/none.ts b/web-common/src/lib/number-formatting/strategies/none.ts index 56c983852c8..3424a3cdc65 100644 --- a/web-common/src/lib/number-formatting/strategies/none.ts +++ b/web-common/src/lib/number-formatting/strategies/none.ts @@ -1,6 +1,5 @@ import { shortScaleSuffixIfAvailableForStr } from "../utils/short-scale-suffixes"; import { - FormatterOptionsCommon, NumberParts, Formatter, NumberKind, @@ -9,15 +8,10 @@ import { import { numStrToParts } from "../utils/number-parts-utils"; export class NonFormatter implements Formatter { - options: FormatterOptionsCommon & FormatterOptionsNoneStrategy; - initialSample: number[]; + options: FormatterOptionsNoneStrategy; - constructor( - sample: number[], - options: FormatterOptionsCommon & FormatterOptionsNoneStrategy, - ) { + constructor(options: FormatterOptionsNoneStrategy) { this.options = options; - this.initialSample = sample; } stringFormat(x: number): string { @@ -25,9 +19,8 @@ export class NonFormatter implements Formatter { } partsFormat(x: number): NumberParts { - let numParts; + let numParts: NumberParts; - const isCurrency = this.options.numberKind === NumberKind.DOLLAR; const isPercent = this.options.numberKind === NumberKind.PERCENT; if (isPercent) x = 100 * x; @@ -48,9 +41,12 @@ export class NonFormatter implements Formatter { numParts.suffix = numParts.suffix.replace("E", "e"); } - if (isCurrency) { - numParts.dollar = "$"; + if (this.options.numberKind === NumberKind.DOLLAR) { + numParts.currencySymbol = "$"; + } else if (this.options.numberKind === NumberKind.EURO) { + numParts.currencySymbol = "€"; } + if (isPercent) { numParts.percent = "%"; } diff --git a/web-common/src/lib/number-formatting/strategies/per-range-default-options.ts b/web-common/src/lib/number-formatting/strategies/per-range-default-options.ts index 004d8f3feec..3d2601e5918 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range-default-options.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range-default-options.ts @@ -6,7 +6,6 @@ import { export const defaultGenericNumOptions: FormatterOptionsCommon & FormatterRangeSpecsStrategy = { - strategy: "perRange", rangeSpecs: [ { minMag: -2, @@ -22,7 +21,6 @@ export const defaultGenericNumOptions: FormatterOptionsCommon & export const defaultPercentOptions: FormatterOptionsCommon & FormatterRangeSpecsStrategy = { - strategy: "perRange", rangeSpecs: [ { minMag: -2, @@ -36,9 +34,9 @@ export const defaultPercentOptions: FormatterOptionsCommon & numberKind: NumberKind.PERCENT, }; -export const defaultDollarOptions: FormatterOptionsCommon & - FormatterRangeSpecsStrategy = { - strategy: "perRange", +export const defaultCurrencyOptions = ( + numberKind: NumberKind, +): FormatterOptionsCommon & FormatterRangeSpecsStrategy => ({ rangeSpecs: [ { minMag: -2, @@ -49,5 +47,5 @@ export const defaultDollarOptions: FormatterOptionsCommon & }, ], defaultMaxDigitsRight: 1, - numberKind: NumberKind.DOLLAR, -}; + numberKind, +}); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.default-dollar.spec.ts b/web-common/src/lib/number-formatting/strategies/per-range.default-dollar.spec.ts index 038d92994ae..1f9e9be3d7b 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range.default-dollar.spec.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range.default-dollar.spec.ts @@ -1,5 +1,6 @@ +import { NumberKind } from "../humanizer-types"; import { PerRangeFormatter } from "./per-range"; -import { defaultDollarOptions } from "./per-range-default-options"; +import { defaultCurrencyOptions } from "./per-range-default-options"; import { describe, it, expect } from "vitest"; const defaultDollarTestCases: [number, string][] = [ @@ -89,7 +90,9 @@ const defaultDollarTestCases: [number, string][] = [ describe("range formatter, using default options for NumberKind.DOLLAR nums, `.stringFormat()`", () => { defaultDollarTestCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter([input], defaultDollarOptions); + const formatter = new PerRangeFormatter( + defaultCurrencyOptions(NumberKind.DOLLAR), + ); expect(formatter.stringFormat(input)).toEqual(output); }); }); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.default-euro.spec.ts b/web-common/src/lib/number-formatting/strategies/per-range.default-euro.spec.ts new file mode 100644 index 00000000000..be4080a426f --- /dev/null +++ b/web-common/src/lib/number-formatting/strategies/per-range.default-euro.spec.ts @@ -0,0 +1,99 @@ +import { NumberKind } from "../humanizer-types"; +import { PerRangeFormatter } from "./per-range"; +import { defaultCurrencyOptions } from "./per-range-default-options"; +import { describe, it, expect } from "vitest"; + +const defaultEuroTestCases: [number, string][] = [ + // integers + [999_999_999, "€1.0B"], + [12_345_789, "€12.3M"], + [2_345_789, "€2.3M"], + [999_999, "€1.0M"], + [345_789, "€345.8k"], + [45_789, "€45.8k"], + [5_789, "€5.8k"], + [999, "€999.00"], + [789, "€789.00"], + [89, "€89.00"], + [9, "€9.00"], + [0, "€0"], + [-0, "€0"], + [-999_999_999, "-€1.0B"], + [-12_345_789, "-€12.3M"], + [-2_345_789, "-€2.3M"], + [-999_999, "-€1.0M"], + [-345_789, "-€345.8k"], + [-45_789, "-€45.8k"], + [-5_789, "-€5.8k"], + [-999, "-€999.00"], + [-789, "-€789.00"], + [-89, "-€89.00"], + [-9, "-€9.00"], + + // non integers + [999_999_999.1234686, "€1.0B"], + [12_345_789.1234686, "€12.3M"], + [2_345_789.1234686, "€2.3M"], + [999_999.4397, "€1.0M"], + [345_789.1234686, "€345.8k"], + [45_789.1234686, "€45.8k"], + [5_789.1234686, "€5.8k"], + [999.999, "€1.0k"], + [999.995, "€1.0k"], + [999.994, "€999.99"], + [999.99, "€999.99"], + [999.1234686, "€999.12"], + [789.1234686, "€789.12"], + [89.1234686, "€89.12"], + [9.1234686, "€9.12"], + [0.1234686, "€0.12"], + + [-999_999_999.1234686, "-€1.0B"], + [-12_345_789.1234686, "-€12.3M"], + [-2_345_789.1234686, "-€2.3M"], + [-999_999.4397, "-€1.0M"], + [-345_789.1234686, "-€345.8k"], + [-45_789.1234686, "-€45.8k"], + [-5_789.1234686, "-€5.8k"], + [-999.999, "-€1.0k"], + [-999.1234686, "-€999.12"], + [-789.1234686, "-€789.12"], + [-89.1234686, "-€89.12"], + [-9.1234686, "-€9.12"], + [-0.1234686, "-€0.12"], + + // // infinitesimals + [0.9, "€0.90"], + [0.095, "€0.10"], + [0.0095, "€0.01"], + [0.001, "€1.0e-3"], + [0.00095, "€950.0e-6"], + [0.000999999, "€1.0e-3"], + [0.00012335234, "€123.4e-6"], + [0.000_000_999999, "€1.0e-6"], + [0.000_000_02341253, "€23.4e-9"], + [0.000_000_000_999999, "€1.0e-9"], + + // padding with insignificant zeros for small nums + [999.1, "€999.10"], + [789.1, "€789.10"], + [89.1, "€89.10"], + [9.1, "€9.10"], + [0.1, "€0.10"], + [-999.1, "-€999.10"], + [-789.1, "-€789.10"], + [-89.1, "-€89.10"], + [-9.1, "-€9.10"], + [-0.1, "-€0.10"], +]; + +describe("range formatter, using default options for NumberKind.DOLLAR nums, `.stringFormat()`", () => { + defaultEuroTestCases.forEach(([input, output]) => { + it(`returns the correct string in case: ${input}`, () => { + const formatter = new PerRangeFormatter( + defaultCurrencyOptions(NumberKind.EURO), + ); + expect(formatter.stringFormat(input)).toEqual(output); + }); + }); +}); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.default-generic-nums.spec.ts b/web-common/src/lib/number-formatting/strategies/per-range.default-generic-nums.spec.ts index bc5c9aee71e..884d0000378 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range.default-generic-nums.spec.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range.default-generic-nums.spec.ts @@ -86,10 +86,7 @@ const defaultGenericNumTestCases: [number, string][] = [ describe("range formatter, using default options for generic nums, `.stringFormat()`", () => { defaultGenericNumTestCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter( - [input], - defaultGenericNumOptions, - ); + const formatter = new PerRangeFormatter(defaultGenericNumOptions); expect(formatter.stringFormat(input)).toEqual(output); }); }); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.disabled.ts b/web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.ts similarity index 68% rename from web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.disabled.ts rename to web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.ts index 73137944fa7..6c6b12e656e 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.disabled.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range.default-percent.spec.ts @@ -39,14 +39,17 @@ const defaultGenericNumTestCases: [number, string][] = [ [5_789.1234686 / 100, "5.8k%"], [999.999 / 100, "1.0k%"], [999.995 / 100, "1.0k%"], - [999.994 / 100, "999.99%"], - [999.99 / 100, "999.99%"], - [999.1234686 / 100, "999.12%"], - [789.1234686 / 100, "789.12%"], - [89.1234686 / 100, "89.12%"], - [9.1234686 / 100, "9.12%"], - [0.1234686 / 100, "0.12%"], + // FIXME: rounding to 2 decimals not working as desired + // [999.994 / 100, "999.99%"], // ACTUALLY GETTING '1.0k%' + // [999.99 / 100, "999.99%"], // ACTUALLY GETTING '1.0k%' + // [999.1234686 / 100, "999.12%"], // ACTUALLY GETTING '999.1%' + // [789.1234686 / 100, "789.12%"], // ACTUALLY GETTING '789.1%' + // [89.1234686 / 100, "89.12%"], // ACTUALLY GETTING '89.1%' + // [9.1234686 / 100, "9.12%"], // ACTUALLY GETTING '9.1%' + // [0.1234686 / 100, "0.12%"], // ACTUALLY GETTING '0.1%' + + // NEGATIVE [-999_999_999.1234686 / 100, "-1.0B%"], [-12_345_789.1234686 / 100, "-12.3M%"], [-2_345_789.1234686 / 100, "-2.3M%"], @@ -55,16 +58,18 @@ const defaultGenericNumTestCases: [number, string][] = [ [-45_789.1234686 / 100, "-45.8k%"], [-5_789.1234686 / 100, "-5.8k%"], [-999.999 / 100, "-1.0k%"], - [-999.1234686 / 100, "-999.12%"], - [-789.1234686 / 100, "-789.12%"], - [-89.1234686 / 100, "-89.12%"], - [-9.1234686 / 100, "-9.12%"], - [-0.1234686 / 100, "-0.12%"], + // FIXME: rounding to 2 decimals not working as desired + // [-999.1234686 / 100, "-999.12%"], // ACTUALLY GETTING '-999.1%' + // [-789.1234686 / 100, "-789.12%"],// ACTUALLY GETTING '-789.1%' + // [-89.1234686 / 100, "-89.12%"], // ACTUALLY GETTING '-89.1%' + // [-9.1234686 / 100, "-9.12%"], // ACTUALLY GETTING '-9.1%' + // [-0.1234686 / 100, "-0.12%"], // ACTUALLY GETTING '-0.1%' // infinitesimals + making sure there is no padding with insignificant zeros [0.008, "0.8%"], [0.005, "0.5%"], - /** NOTE CORNER CASE TO IGNORE + + /** FIXME CORNER CASES TO IGNORE FOR NOW * ideally, 0.009 would format as "0.9%" (no sero padding). * In practice because of weirness around FP representations of * numbers with fractional parts ending in a "9", we have @@ -76,13 +81,15 @@ const defaultGenericNumTestCases: [number, string][] = [ * impact users anyway (no one is ever likely to notice this, * especially since it is not incorrect to have the extra zero), * so putting in a fix is not worth it in terms of the additional - * code complexity that would be introduced + * code complexity that would be introduced */ - [0.009, "0.90%"], + // [0.009, "0.90%"], // ACTUALLY GETTING '0.10%' + // [0.0095 / 100, "0.01%"], // ACTUALLY GETTING '9.5e-3%' + + // FIXME: rounding to 2 decimals not working as desired + // [0.095 / 100, "0.10%"], // ACTUALLY GETTING '0.1%' // Note: .10 IS significant in this case - [0.095 / 100, "0.10%"], - [0.0095 / 100, "0.01%"], [0.001 / 100, "1.0e-3%"], [0.00095 / 100, "950.0e-6%"], [0.000999999 / 100, "1.0e-3%"], @@ -95,7 +102,7 @@ const defaultGenericNumTestCases: [number, string][] = [ describe("range formatter, using default options for NumberKind.PERCENT, `.stringFormat()`", () => { defaultGenericNumTestCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter([input], defaultPercentOptions); + const formatter = new PerRangeFormatter(defaultPercentOptions); expect(formatter.stringFormat(input)).toEqual(output); }); }); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.spec.ts b/web-common/src/lib/number-formatting/strategies/per-range.spec.ts index 8aa13e31c94..163ba90f759 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range.spec.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range.spec.ts @@ -3,7 +3,6 @@ import { PerRangeFormatter } from "./per-range"; import { describe, it, expect } from "vitest"; const invalidRangeOptions1: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 3, supMag: 3, maxDigitsRight: 0 }, { minMag: -3, supMag: 3, maxDigitsRight: 3 }, @@ -13,7 +12,6 @@ const invalidRangeOptions1: FormatterFactoryOptions = { }; const invalidRangeOptions2: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 3, supMag: 2, maxDigitsRight: 0 }, { minMag: -3, supMag: 3, maxDigitsRight: 3 }, @@ -24,19 +22,14 @@ const invalidRangeOptions2: FormatterFactoryOptions = { describe("range formatter constructor, throws if given an invalid range", () => { it(`should throw`, () => { - expect( - () => new PerRangeFormatter([100.12], invalidRangeOptions1), - ).toThrow(); + expect(() => new PerRangeFormatter(invalidRangeOptions1)).toThrow(); }); it(`should throw`, () => { - expect( - () => new PerRangeFormatter([100.12], invalidRangeOptions2), - ).toThrow(); + expect(() => new PerRangeFormatter(invalidRangeOptions2)).toThrow(); }); }); const overlappingRangeOptions1: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 2, supMag: 6, maxDigitsRight: 0 }, { minMag: -3, supMag: 3, maxDigitsRight: 3 }, @@ -46,7 +39,6 @@ const overlappingRangeOptions1: FormatterFactoryOptions = { }; const overlappingRangeOptions2: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 2, supMag: 6, maxDigitsRight: 0 }, { minMag: -3, supMag: 3, maxDigitsRight: 3 }, @@ -59,19 +51,14 @@ const overlappingRangeOptions2: FormatterFactoryOptions = { describe("range formatter constructor, throws if given overlapping ranges", () => { it(`should throw`, () => { - expect( - () => new PerRangeFormatter([100.12], overlappingRangeOptions1), - ).toThrow(); + expect(() => new PerRangeFormatter(overlappingRangeOptions1)).toThrow(); }); it(`should throw`, () => { - expect( - () => new PerRangeFormatter([100.12], overlappingRangeOptions2), - ).toThrow(); + expect(() => new PerRangeFormatter(overlappingRangeOptions2)).toThrow(); }); }); const gappedRangeOptions1: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 6, supMag: 9, maxDigitsRight: 0 }, { minMag: -3, supMag: 3, maxDigitsRight: 3 }, @@ -82,14 +69,11 @@ const gappedRangeOptions1: FormatterFactoryOptions = { describe("range formatter constructor, throws if given gap in range coverage", () => { it(`should throw`, () => { - expect( - () => new PerRangeFormatter([100.12], gappedRangeOptions1), - ).toThrow(); + expect(() => new PerRangeFormatter(gappedRangeOptions1)).toThrow(); }); }); const mar2ProposalOptions: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 3, @@ -181,14 +165,13 @@ const mar2ProposalTestCases: [number, string][] = [ describe("range formatter, using options for 2022-03-02 proposal `.stringFormat()`", () => { mar2ProposalTestCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter([input], mar2ProposalOptions); + const formatter = new PerRangeFormatter(mar2ProposalOptions); expect(formatter.stringFormat(input)).toEqual(output); }); }); }); const mar2ProposalNoZeroPadOptions: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 3, @@ -244,10 +227,7 @@ const mar2ProposalNoZeroPadTestCases: [number, string][] = [ describe("range formatter, using options for 2022-03-02 proposal and NO padding with insignificant zeros `.stringFormat()`", () => { mar2ProposalNoZeroPadTestCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter( - [input], - mar2ProposalNoZeroPadOptions, - ); + const formatter = new PerRangeFormatter(mar2ProposalNoZeroPadOptions); expect(formatter.stringFormat(input)).toEqual(output); }); }); @@ -255,7 +235,6 @@ describe("range formatter, using options for 2022-03-02 proposal and NO padding describe("range formatter, correct handling of useTrailingDot`.stringFormat()`", () => { const options: FormatterFactoryOptions = { - strategy: "perRange", rangeSpecs: [ { minMag: 3, @@ -286,7 +265,7 @@ describe("range formatter, correct handling of useTrailingDot`.stringFormat()`", testCases.forEach(([input, output]) => { it(`returns the correct string in case: ${input}`, () => { - const formatter = new PerRangeFormatter([input], options); + const formatter = new PerRangeFormatter(options); expect(formatter.stringFormat(input)).toEqual(output); }); }); diff --git a/web-common/src/lib/number-formatting/strategies/per-range.ts b/web-common/src/lib/number-formatting/strategies/per-range.ts index 4044d9c17a5..9ced5c25635 100644 --- a/web-common/src/lib/number-formatting/strategies/per-range.ts +++ b/web-common/src/lib/number-formatting/strategies/per-range.ts @@ -52,18 +52,7 @@ export class PerRangeFormatter implements Formatter { options: FormatterOptionsCommon & FormatterRangeSpecsStrategy; initialSample: number[]; - // FIXME: we can add this back in if we want to implement - // alignment. If we decide that we don't want to pursue that, - // we can remove this commented code - // largestPossibleNumberStringParts: NumberParts; - // maxPxWidthsSampledSoFar: FormatterWidths; - // maxCharWidthsSampledSoFar: FormatterWidths; - // largestPossibleNumberStringParts: NumberParts; - - constructor( - sample: number[], - options: FormatterRangeSpecsStrategy & FormatterOptionsCommon, - ) { + constructor(options: FormatterRangeSpecsStrategy & FormatterOptionsCommon) { this.options = options; // sort ranges from small to large by lower bound @@ -101,7 +90,6 @@ export class PerRangeFormatter implements Formatter { ); } } - this.initialSample = sample; } stringFormat(x: number): string { @@ -169,7 +157,9 @@ export class PerRangeFormatter implements Formatter { numParts.suffix = numParts.suffix.replace("E", "e"); } if (this.options.numberKind === NumberKind.DOLLAR) { - numParts.dollar = "$"; + numParts.currencySymbol = "$"; + } else if (this.options.numberKind === NumberKind.EURO) { + numParts.currencySymbol = "€"; } if (this.options.numberKind === NumberKind.PERCENT) { numParts.percent = "%"; diff --git a/web-common/src/lib/number-formatting/utils/number-parts-utils.ts b/web-common/src/lib/number-formatting/utils/number-parts-utils.ts index 7a8893fe5be..9b2a8e3078e 100644 --- a/web-common/src/lib/number-formatting/utils/number-parts-utils.ts +++ b/web-common/src/lib/number-formatting/utils/number-parts-utils.ts @@ -2,7 +2,7 @@ import type { NumberParts } from "../humanizer-types"; export const numberPartsToString = (parts: NumberParts): string => (parts.neg || "") + - (parts.dollar || "") + + (parts.currencySymbol || "") + parts.int + parts.dot + parts.frac +