diff --git a/package-lock.json b/package-lock.json index a5db5e794a6..90d950660eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8923,6 +8923,12 @@ "@types/node": "*" } }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true + }, "node_modules/@types/d3-scale": { "version": "4.0.8", "dev": true, @@ -35220,6 +35226,7 @@ "@testing-library/svelte": "^4.0.0", "@types/chroma-js": "^2.4.3", "@types/codemirror": "^5.60.15", + "@types/d3-format": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/dompurify": "^3.0.5", "@types/luxon": "^3.4.2", diff --git a/web-common/package.json b/web-common/package.json index 4c30c892557..f6af206c258 100644 --- a/web-common/package.json +++ b/web-common/package.json @@ -47,6 +47,7 @@ "@testing-library/svelte": "^4.0.0", "@types/chroma-js": "^2.4.3", "@types/codemirror": "^5.60.15", + "@types/d3-format": "^3.0.4", "@types/d3-time-format": "^4.0.3", "@types/dompurify": "^3.0.5", "@types/luxon": "^3.4.2", diff --git a/web-common/src/features/metrics-views/editor/metrics-schema.json b/web-common/src/features/metrics-views/editor/metrics-schema.json index 3a8c18325c1..0edf5a2d9fd 100644 --- a/web-common/src/features/metrics-views/editor/metrics-schema.json +++ b/web-common/src/features/metrics-views/editor/metrics-schema.json @@ -123,6 +123,35 @@ "type": "string", "description": "Controls the formatting of this measure using a d3-format string." }, + "format_d3_locale": { + "type": "object", + "description": "Defines a custom locale for d3-format.", + "properties": { + "decimal": { "type": "string", "description": "The decimal point (e.g., \".\")." }, + "thousands": { "type": "string", "description": "The group separator (e.g., \",\")." }, + "grouping": { + "type": "array", + "items": { "type": "integer" }, + "description": "The array of group sizes (e.g., [3]), cycled as needed." + }, + "currency": { + "type": "array", + "items": { "type": "string" }, + "minItems": 2, + "maxItems": 2, + "description": "The currency prefix and suffix (e.g., [\"$\", \"\"])." + }, + "numerals": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional array of ten strings to replace the numerals 0-9." + }, + "percent": { "type": "string", "description": "Optional symbol to replace the percent suffix." }, + "minus": { "type": "string", "description": "Optional minus sign (defaults to \"−\")." }, + "nan": { "type": "string", "description": "Optional value for not-a-number (defaults to \"NaN\")." } + }, + "required": ["currency"] + }, "ignore": { "type": "boolean", "description": "If true, hides the measure from the dashboard." 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 d3f1d0488ea..d984ce0dc4b 100644 --- a/web-common/src/lib/number-formatting/format-measure-value.ts +++ b/web-common/src/lib/number-formatting/format-measure-value.ts @@ -1,5 +1,15 @@ +import { + currencyHumanizer, + getLocaleFromConfig, + includesCurrencySymbol, + isValidD3Locale, +} from "@rilldata/web-common/lib/number-formatting/utils/d3-format-utils"; import type { MetricsViewSpecMeasureV2 } from "@rilldata/web-common/runtime-client"; -import { format as d3format } from "d3-format"; +import { + format as d3format, + formatLocale as d3FormatLocale, + type FormatLocaleDefinition, +} from "d3-format"; import { FormatPreset, NumberKind, @@ -108,14 +118,6 @@ function humanizeDataType( } } -/** - * Parse the currency symbol from a d3 format string. - * For d3 the currency symbol is always "$" in the format string - */ -export function includesCurrencySymbol(formatString: string): boolean { - return formatString.includes("$"); -} - /** * This function is intended to provide a lossless * humanized string representation of a number in cases @@ -176,7 +178,18 @@ export function createMeasureValueFormatter( // otherwise, use the humanize formatter. if (measureSpec.formatD3 !== undefined && measureSpec.formatD3 !== "") { try { - const d3formatter = d3format(measureSpec.formatD3); + let d3formatter: (n: number | { valueOf(): number }) => string; + + const isValidLocale = isValidD3Locale(measureSpec.formatD3Locale); + if (isValidLocale) { + const locale = getLocaleFromConfig( + measureSpec.formatD3Locale as FormatLocaleDefinition, + ); + d3formatter = d3FormatLocale(locale).format(measureSpec.formatD3); + } else { + d3formatter = d3format(measureSpec.formatD3); + } + const hasCurrencySymbol = includesCurrencySymbol(measureSpec.formatD3); const hasPercentSymbol = measureSpec.formatD3.includes("%"); return (value: number | string | T) => { @@ -185,6 +198,16 @@ export function createMeasureValueFormatter( // For the Big Number and Tooltips, override the d3formatter if (isBigNumber || isTooltip) { if (hasCurrencySymbol) { + if (isValidLocale && measureSpec?.formatD3Locale?.currency) { + const currency = measureSpec.formatD3Locale.currency as [ + string, + string, + ]; + return currencyHumanizer( + currency, + humanizer(value, FormatPreset.HUMANIZE), + ); + } return humanizer(value, FormatPreset.CURRENCY_USD); } else if (hasPercentSymbol) { return humanizer(value, FormatPreset.PERCENTAGE); @@ -194,7 +217,8 @@ export function createMeasureValueFormatter( } return d3formatter(value); }; - } catch { + } catch (error) { + console.warn("Invalid d3 format:", error); return (value: number | string | T) => typeof value === "number" ? humanizer(value, FormatPreset.HUMANIZE) diff --git a/web-common/src/lib/number-formatting/utils/d3-format-utils.ts b/web-common/src/lib/number-formatting/utils/d3-format-utils.ts new file mode 100644 index 00000000000..7b55cd00439 --- /dev/null +++ b/web-common/src/lib/number-formatting/utils/d3-format-utils.ts @@ -0,0 +1,45 @@ +import type { MetricsViewSpecMeasureV2FormatD3Locale } from "@rilldata/web-common/runtime-client"; +import type { FormatLocaleDefinition } from "d3-format"; + +export function isValidD3Locale( + config: MetricsViewSpecMeasureV2FormatD3Locale | undefined, +): boolean { + if (!config) return false; + if (config.currency) { + // currency is an array of 2 strings + if (!Array.isArray(config.currency) || config.currency.length !== 2) + return false; + return true; + } + return false; +} + +export function getLocaleFromConfig( + config: FormatLocaleDefinition, +): FormatLocaleDefinition { + const base: FormatLocaleDefinition = { + currency: ["$", ""], + thousands: ",", + grouping: [3], + decimal: ".", + }; + + return { ...base, ...config }; +} + +export function currencyHumanizer( + currency: [string, string], + humanizedValue: string, +): string { + const [prefix, suffix] = currency; + // Replace the "$" symbol with the appropriate currency prefix/suffix + return `${prefix}${humanizedValue.replace(/\$/g, "")}${suffix}`; +} + +/** + * Parse the currency symbol from a d3 format string. + * For d3 the currency symbol is always "$" in the format string + */ +export function includesCurrencySymbol(formatString: string): boolean { + return formatString.includes("$"); +}