|
| 1 | +import { formatLocale } from 'd3-format'; |
| 2 | + |
| 3 | +import type { FormatLocaleDefinition, FormatLocaleObject } from 'd3-format'; |
| 4 | + |
| 5 | +import enUS from 'd3-format/locale/en-US.json'; |
| 6 | +import enGB from 'd3-format/locale/en-GB.json'; |
| 7 | +import zhCN from 'd3-format/locale/zh-CN.json'; |
| 8 | +import esES from 'd3-format/locale/es-ES.json'; |
| 9 | +import esMX from 'd3-format/locale/es-MX.json'; |
| 10 | +import deDE from 'd3-format/locale/de-DE.json'; |
| 11 | +import jaJP from 'd3-format/locale/ja-JP.json'; |
| 12 | +import frFR from 'd3-format/locale/fr-FR.json'; |
| 13 | +import ptBR from 'd3-format/locale/pt-BR.json'; |
| 14 | +import koKR from 'd3-format/locale/ko-KR.json'; |
| 15 | +import itIT from 'd3-format/locale/it-IT.json'; |
| 16 | +import nlNL from 'd3-format/locale/nl-NL.json'; |
| 17 | +import ruRU from 'd3-format/locale/ru-RU.json'; |
| 18 | + |
| 19 | +// Pre-built d3 locale definitions for the most popular locales. |
| 20 | +// Used as a fallback when Intl is unavailable (e.g. some edge runtimes). |
| 21 | +export const formatD3NumericLocale: Record<string, FormatLocaleDefinition> = { |
| 22 | + 'en-US': enUS as unknown as FormatLocaleDefinition, |
| 23 | + 'en-GB': enGB as unknown as FormatLocaleDefinition, |
| 24 | + 'zh-CN': zhCN as unknown as FormatLocaleDefinition, |
| 25 | + 'es-ES': esES as unknown as FormatLocaleDefinition, |
| 26 | + 'es-MX': esMX as unknown as FormatLocaleDefinition, |
| 27 | + 'de-DE': deDE as unknown as FormatLocaleDefinition, |
| 28 | + 'ja-JP': jaJP as unknown as FormatLocaleDefinition, |
| 29 | + 'fr-FR': frFR as unknown as FormatLocaleDefinition, |
| 30 | + 'pt-BR': ptBR as unknown as FormatLocaleDefinition, |
| 31 | + 'ko-KR': koKR as unknown as FormatLocaleDefinition, |
| 32 | + 'it-IT': itIT as unknown as FormatLocaleDefinition, |
| 33 | + 'nl-NL': nlNL as unknown as FormatLocaleDefinition, |
| 34 | + 'ru-RU': ruRU as unknown as FormatLocaleDefinition, |
| 35 | +}; |
| 36 | + |
| 37 | +const currencySymbols: Record<string, string> = { |
| 38 | + USD: '$', |
| 39 | + EUR: '€', |
| 40 | + GBP: '£', |
| 41 | + JPY: '¥', |
| 42 | + CNY: '¥', |
| 43 | + KRW: '₩', |
| 44 | + INR: '₹', |
| 45 | + RUB: '₽', |
| 46 | +}; |
| 47 | + |
| 48 | +function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] { |
| 49 | + try { |
| 50 | + const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }); |
| 51 | + const currencyParts = cf.formatToParts(1); |
| 52 | + const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode; |
| 53 | + const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type; |
| 54 | + const symbolIsPrefix = firstMeaningfulType === 'currency'; |
| 55 | + |
| 56 | + return symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol]; |
| 57 | + } catch { |
| 58 | + const symbol = currencySymbols[currencyCode] ?? currencyCode; |
| 59 | + return [symbol, '']; |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +function deriveGrouping(locale: string): number[] { |
| 64 | + // en-US → "1,234,567,890" → sizes [1,3,3,3] → [3] |
| 65 | + // en-IN → "1,23,45,67,890" → sizes [1,2,2,2,3] → [3,2] |
| 66 | + const sizes = new Intl.NumberFormat(locale).formatToParts(1234567890) |
| 67 | + .filter((p) => p.type === 'integer') |
| 68 | + .map((p) => p.value.length); |
| 69 | + |
| 70 | + if (sizes.length <= 1) { |
| 71 | + return [3]; |
| 72 | + } |
| 73 | + |
| 74 | + // d3 repeats the last array element for all remaining groups, |
| 75 | + // so we only need the two rightmost (least-significant) group sizes. |
| 76 | + const first = sizes[sizes.length - 1]; |
| 77 | + const second = sizes[sizes.length - 2]; |
| 78 | + |
| 79 | + return first === second ? [first] : [first, second]; |
| 80 | +} |
| 81 | + |
| 82 | +function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): FormatLocaleDefinition { |
| 83 | + const nf = new Intl.NumberFormat(locale); |
| 84 | + const numParts = nf.formatToParts(1234567.89); |
| 85 | + const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? ''; |
| 86 | + |
| 87 | + return { |
| 88 | + decimal: find('decimal') || '.', |
| 89 | + thousands: find('group') || ',', |
| 90 | + grouping: deriveGrouping(locale), |
| 91 | + currency: getCurrencySymbol(locale, currencyCode), |
| 92 | + }; |
| 93 | +} |
| 94 | + |
| 95 | +const localeCache: Record<string, FormatLocaleObject> = Object.create(null); |
| 96 | + |
| 97 | +export function getD3NumericLocale(locale: string, currencyCode = 'USD'): FormatLocaleObject { |
| 98 | + const key = `${locale}:${currencyCode}`; |
| 99 | + if (localeCache[key]) { |
| 100 | + return localeCache[key]; |
| 101 | + } |
| 102 | + |
| 103 | + let definition: FormatLocaleDefinition; |
| 104 | + |
| 105 | + if (formatD3NumericLocale[locale]) { |
| 106 | + definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) }; |
| 107 | + } else { |
| 108 | + try { |
| 109 | + definition = getD3NumericLocaleFromIntl(locale, currencyCode); |
| 110 | + } catch (e: unknown) { |
| 111 | + console.warn('Failed to generate d3 local via Intl, failing back to en-US', e); |
| 112 | + |
| 113 | + definition = formatD3NumericLocale['en-US']; |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + localeCache[key] = formatLocale(definition); |
| 118 | + return localeCache[key]; |
| 119 | +} |
0 commit comments