diff --git a/packages/cubejs-client-core/package.json b/packages/cubejs-client-core/package.json index dabe5a8ccf7fe..688d5b7745b03 100644 --- a/packages/cubejs-client-core/package.json +++ b/packages/cubejs-client-core/package.json @@ -20,6 +20,8 @@ "dependencies": { "core-js": "^3.6.5", "cross-fetch": "^3.0.2", + "d3-format": "^3.1.0", + "d3-time-format": "^4.1.0", "dayjs": "^1.10.4", "ramda": "^0.27.2", "url-search-params-polyfill": "^7.0.0", @@ -41,6 +43,8 @@ "license": "MIT", "devDependencies": { "@cubejs-backend/linter": "1.6.32", + "@types/d3-format": "^3", + "@types/d3-time-format": "^4", "@types/moment-range": "^4.0.0", "@types/ramda": "^0.27.34", "@vitest/coverage-v8": "^4", diff --git a/packages/cubejs-client-core/src/format-d3-numeric-locale.ts b/packages/cubejs-client-core/src/format-d3-numeric-locale.ts new file mode 100644 index 0000000000000..7517e4d09e1da --- /dev/null +++ b/packages/cubejs-client-core/src/format-d3-numeric-locale.ts @@ -0,0 +1,119 @@ +import { formatLocale } from 'd3-format'; + +import type { FormatLocaleDefinition, FormatLocaleObject } from 'd3-format'; + +import enUS from 'd3-format/locale/en-US.json'; +import enGB from 'd3-format/locale/en-GB.json'; +import zhCN from 'd3-format/locale/zh-CN.json'; +import esES from 'd3-format/locale/es-ES.json'; +import esMX from 'd3-format/locale/es-MX.json'; +import deDE from 'd3-format/locale/de-DE.json'; +import jaJP from 'd3-format/locale/ja-JP.json'; +import frFR from 'd3-format/locale/fr-FR.json'; +import ptBR from 'd3-format/locale/pt-BR.json'; +import koKR from 'd3-format/locale/ko-KR.json'; +import itIT from 'd3-format/locale/it-IT.json'; +import nlNL from 'd3-format/locale/nl-NL.json'; +import ruRU from 'd3-format/locale/ru-RU.json'; + +// Pre-built d3 locale definitions for the most popular locales. +// Used as a fallback when Intl is unavailable (e.g. some edge runtimes). +export const formatD3NumericLocale: Record = { + 'en-US': enUS as unknown as FormatLocaleDefinition, + 'en-GB': enGB as unknown as FormatLocaleDefinition, + 'zh-CN': zhCN as unknown as FormatLocaleDefinition, + 'es-ES': esES as unknown as FormatLocaleDefinition, + 'es-MX': esMX as unknown as FormatLocaleDefinition, + 'de-DE': deDE as unknown as FormatLocaleDefinition, + 'ja-JP': jaJP as unknown as FormatLocaleDefinition, + 'fr-FR': frFR as unknown as FormatLocaleDefinition, + 'pt-BR': ptBR as unknown as FormatLocaleDefinition, + 'ko-KR': koKR as unknown as FormatLocaleDefinition, + 'it-IT': itIT as unknown as FormatLocaleDefinition, + 'nl-NL': nlNL as unknown as FormatLocaleDefinition, + 'ru-RU': ruRU as unknown as FormatLocaleDefinition, +}; + +const currencySymbols: Record = { + USD: '$', + EUR: '€', + GBP: '£', + JPY: '¥', + CNY: '¥', + KRW: '₩', + INR: '₹', + RUB: '₽', +}; + +function getCurrencySymbol(locale: string | undefined, currencyCode: string): [string, string] { + try { + const cf = new Intl.NumberFormat(locale, { style: 'currency', currency: currencyCode }); + const currencyParts = cf.formatToParts(1); + const currencySymbol = currencyParts.find((p) => p.type === 'currency')?.value ?? currencyCode; + const firstMeaningfulType = currencyParts.find((p) => !['literal', 'nan'].includes(p.type))?.type; + const symbolIsPrefix = firstMeaningfulType === 'currency'; + + return symbolIsPrefix ? [currencySymbol, ''] : ['', currencySymbol]; + } catch { + const symbol = currencySymbols[currencyCode] ?? currencyCode; + return [symbol, '']; + } +} + +function deriveGrouping(locale: string): number[] { + // en-US → "1,234,567,890" → sizes [1,3,3,3] → [3] + // en-IN → "1,23,45,67,890" → sizes [1,2,2,2,3] → [3,2] + const sizes = new Intl.NumberFormat(locale).formatToParts(1234567890) + .filter((p) => p.type === 'integer') + .map((p) => p.value.length); + + if (sizes.length <= 1) { + return [3]; + } + + // d3 repeats the last array element for all remaining groups, + // so we only need the two rightmost (least-significant) group sizes. + const first = sizes[sizes.length - 1]; + const second = sizes[sizes.length - 2]; + + return first === second ? [first] : [first, second]; +} + +function getD3NumericLocaleFromIntl(locale: string, currencyCode = 'USD'): FormatLocaleDefinition { + const nf = new Intl.NumberFormat(locale); + const numParts = nf.formatToParts(1234567.89); + const find = (type: string) => numParts.find((p) => p.type === type)?.value ?? ''; + + return { + decimal: find('decimal') || '.', + thousands: find('group') || ',', + grouping: deriveGrouping(locale), + currency: getCurrencySymbol(locale, currencyCode), + }; +} + +const localeCache: Record = Object.create(null); + +export function getD3NumericLocale(locale: string, currencyCode = 'USD'): FormatLocaleObject { + const key = `${locale}:${currencyCode}`; + if (localeCache[key]) { + return localeCache[key]; + } + + let definition: FormatLocaleDefinition; + + if (formatD3NumericLocale[locale]) { + definition = { ...formatD3NumericLocale[locale], currency: getCurrencySymbol(locale, currencyCode) }; + } else { + try { + definition = getD3NumericLocaleFromIntl(locale, currencyCode); + } catch (e: unknown) { + console.warn('Failed to generate d3 local via Intl, failing back to en-US', e); + + definition = formatD3NumericLocale['en-US']; + } + } + + localeCache[key] = formatLocale(definition); + return localeCache[key]; +} diff --git a/packages/cubejs-client-core/src/format.ts b/packages/cubejs-client-core/src/format.ts new file mode 100644 index 0000000000000..34427bdae7d18 --- /dev/null +++ b/packages/cubejs-client-core/src/format.ts @@ -0,0 +1,125 @@ +import { format as d3Format } from 'd3-format'; +import { timeFormat } from 'd3-time-format'; +import { getD3NumericLocale } from './format-d3-numeric-locale'; + +import type { DimensionFormat, MeasureFormat, TCubeMemberType } from './types'; + +// Default d3-format specifiers — aligned with the named _2 formats +// (number_2, currency_2, percent_2) in named-numeric-formats.ts +const DEFAULT_NUMBER_FORMAT = ',.2f'; +const DEFAULT_CURRENCY_FORMAT = '$,.2f'; +const DEFAULT_PERCENT_FORMAT = '.2%'; + +function detectLocale() { + try { + return new Intl.NumberFormat().resolvedOptions().locale; + } catch (e) { + console.warn('Failed to detect locale', e); + + return 'en-US'; + } +} + +const currentLocale = detectLocale(); + +const DEFAULT_DATETIME_FORMAT = '%Y-%m-%d %H:%M:%S'; +const DEFAULT_DATE_FORMAT = '%Y-%m-%d'; +const DEFAULT_DATE_MONTH_FORMAT = '%Y-%m'; +const DEFAULT_DATE_QUARTER_FORMAT = '%Y-Q%q'; +const DEFAULT_DATE_YEAR_FORMAT = '%Y'; + +function getTimeFormatByGrain(grain: string | undefined): string { + switch (grain) { + case 'day': + case 'week': + return DEFAULT_DATE_FORMAT; + case 'month': + return DEFAULT_DATE_MONTH_FORMAT; + case 'quarter': + return DEFAULT_DATE_QUARTER_FORMAT; + case 'year': + return DEFAULT_DATE_YEAR_FORMAT; + case 'second': + case 'minute': + case 'hour': + default: + return DEFAULT_DATETIME_FORMAT; + } +} + +function parseNumber(value: any): number { + if (value === null || value === undefined) { + return 0; + } + + return parseFloat(value); +} + +export type FormatValueMember = { + type: TCubeMemberType; + format?: DimensionFormat | MeasureFormat; + /** ISO 4217 currency code (e.g. 'USD', 'EUR'). Used when format is 'currency'. */ + currency?: string; + /** Time dimension granularity (e.g. 'day', 'month', 'year'). Used for time formatting when no explicit format is set. */ + granularity?: string; +}; + +export type FormatValueOptions = FormatValueMember & { + /** Locale tag (e.g. 'en-US', 'de-DE', 'nl-NL'). Defaults to the runtime's locale via Intl.NumberFormat. */ + locale?: string, + /** String to return for null/undefined values. Defaults to '∅'. */ + emptyPlaceholder?: string; +}; + +export function formatValue( + value: any, + { type, format, currency = 'USD', granularity, locale = currentLocale, emptyPlaceholder = '∅' }: FormatValueOptions +): string { + if (value === null || value === undefined) { + return emptyPlaceholder; + } + + if (format && typeof format === 'object') { + if (format.type === 'custom-numeric') { + return d3Format(format.value)(parseNumber(value)); + } + + if (format.type === 'custom-time') { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Invalid date' : timeFormat(format.value)(date); + } + + // { type: 'link', label: string } — return value as string + return String(value); + } + + if (typeof format === 'string') { + switch (format) { + case 'currency': + return getD3NumericLocale(locale, currency).format(DEFAULT_CURRENCY_FORMAT)(parseNumber(value)); + case 'percent': + return getD3NumericLocale(locale).format(DEFAULT_PERCENT_FORMAT)(parseNumber(value)); + case 'number': + return getD3NumericLocale(locale).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); + case 'imageUrl': + case 'id': + case 'link': + default: + return String(value); + } + } + + // No explicit format — infer from type + if (type === 'time') { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Invalid date'; + + return timeFormat(getTimeFormatByGrain(granularity))(date); + } + + if (type === 'number') { + return getD3NumericLocale(locale, currency).format(DEFAULT_NUMBER_FORMAT)(parseNumber(value)); + } + + return String(value); +} diff --git a/packages/cubejs-client-core/src/index.ts b/packages/cubejs-client-core/src/index.ts index 821deabe68fa4..a27eec6d1fbef 100644 --- a/packages/cubejs-client-core/src/index.ts +++ b/packages/cubejs-client-core/src/index.ts @@ -910,3 +910,5 @@ export * from './HttpTransport'; export * from './utils'; export * from './time'; export * from './types'; +// We don't export it for now, because size of builds for cjs/umd users will be affected +// export * from './format'; diff --git a/packages/cubejs-client-core/test/format-no-intl.test.ts b/packages/cubejs-client-core/test/format-no-intl.test.ts new file mode 100644 index 0000000000000..8aa094701aa83 --- /dev/null +++ b/packages/cubejs-client-core/test/format-no-intl.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; + +describe('formatValue without Intl', () => { + const originalIntl = globalThis.Intl; + + beforeAll(() => { + vi.resetModules(); + + // @ts-expect-error — intentionally removing Intl to simulate environments where it is unavailable + delete globalThis.Intl; + }); + + afterAll(() => { + globalThis.Intl = originalIntl; + }); + + it('detectLocale falls back to en-US and formatting works', async () => { + const { formatValue } = await import('../src/format'); + + // number type uses the detected locale (should be en-US fallback) + expect(formatValue(1234.56, { type: 'number' })).toBe('1,234.56'); + }); + + it('currency formatting falls back to en-US locale definition', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue(1234.56, { type: 'number', format: 'currency' })).toBe('$1,234.56'); + }); + + it('percent formatting works without Intl', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue(0.1234, { type: 'number', format: 'percent' })).toBe('12.34%'); + }); + + it('time formatting works without Intl', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15'); + }); + + it('null/undefined still return emptyPlaceholder', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue(null, { type: 'number' })).toBe('∅'); + expect(formatValue(undefined, { type: 'number' })).toBe('∅'); + }); + + // Known locale (de-DE) — pre-built d3 definition is used, + // getCurrencySymbol falls back to the static currencySymbols map. + it('known locale (de-DE) uses pre-built locale definition', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'de-DE' })).toBe('1.234,56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale: 'de-DE' })).toBe('€1.234,56'); + expect(formatValue(0.1234, { type: 'number', format: 'percent', locale: 'de-DE' })).toBe('12,34%'); + }); + + // Unknown locale (sv-SE) — getD3NumericLocaleFromIntl throws, + // falls back entirely to en-US. + it('unknown locale (sv-SE) falls back to en-US', async () => { + const { formatValue } = await import('../src/format'); + + expect(formatValue(1234.56, { type: 'number', format: 'number', locale: 'sv-SE' })).toBe('1,234.56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale: 'sv-SE' })).toBe('$1,234.56'); + }); +}); diff --git a/packages/cubejs-client-core/test/format.test.ts b/packages/cubejs-client-core/test/format.test.ts new file mode 100644 index 0000000000000..9af1179e47f85 --- /dev/null +++ b/packages/cubejs-client-core/test/format.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { formatValue } from '../src/format'; + +describe('formatValue', () => { + it('format null', () => { + expect(formatValue(null, { type: 'number' })).toBe('∅'); + expect(formatValue(undefined, { type: 'number' })).toBe('∅'); + }); + + it('format: currency (defaults to USD)', () => { + expect(formatValue(0, { type: 'number', format: 'currency' })).toBe('$0.00'); + expect(formatValue(-42.5, { type: 'number', format: 'currency' })).toBe('−$42.50'); + expect(formatValue('1234.56', { type: 'number', format: 'currency' })).toBe('$1,234.56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency' })).toBe('$1,234.56'); + }); + + it('format: currency with currency code', () => { + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR' })).toBe('€1,234.56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'GBP' })).toBe('£1,234.56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'JPY' })).toBe('¥1,234.56'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD' })).toBe('$1,234.56'); + }); + + it('format: percent', () => { + expect(formatValue(0.1234, { type: 'number', format: 'percent' })).toBe('12.34%'); + expect(formatValue(0, { type: 'number', format: 'percent' })).toBe('0.00%'); + expect(formatValue(1, { type: 'number', format: 'percent' })).toBe('100.00%'); + }); + + it('format: number', () => { + expect(formatValue(1234567.89, { type: 'number', format: 'number' })).toBe('1,234,567.89'); + expect(formatValue(1234, { type: 'number', format: 'number' })).toBe('1,234.00'); + expect(formatValue('999.1', { type: 'number', format: 'number' })).toBe('999.10'); + }); + + it('format: custom-numeric', () => { + expect(formatValue(1234.5, { type: 'number', format: { type: 'custom-numeric', value: '.2f' } })).toBe('1234.50'); + expect(formatValue(1234, { type: 'number', format: { type: 'custom-numeric', value: '$,.2f' } })).toBe('$1,234.00'); + expect(formatValue(0.5, { type: 'number', format: { type: 'custom-numeric', value: '.0%' } })).toBe('50%'); + expect(formatValue(1500, { type: 'number', format: { type: 'custom-numeric', value: '.2s' } })).toBe('1.5k'); + }); + + it('format: custom-time', () => { + expect(formatValue('2024-03-15T10:30:00.000', { type: 'time', format: { type: 'custom-time', value: '%Y-%m-%d' } })).toBe('2024-03-15'); + expect(formatValue('2024-03-15T10:30:00.000', { type: 'time', format: { type: 'custom-time', value: '%H:%M' } })).toBe('10:30'); + }); + + it('passthrough formats', () => { + expect(formatValue('https://img.example.com/photo.png', { type: 'string', format: 'imageUrl' })).toBe('https://img.example.com/photo.png'); + expect(formatValue(12345, { type: 'number', format: 'id' })).toBe('12345'); + expect(formatValue('https://example.com', { type: 'string', format: 'link' })).toBe('https://example.com'); + expect(formatValue('https://example.com', { type: 'string', format: { type: 'link', label: 'Example' } })).toBe('https://example.com'); + }); + + it('type-based fallback: number', () => { + expect(formatValue(1234.56, { type: 'number' })).toBe('1,234.56'); + }); + + it('type-based fallback: time with grain', () => { + expect(formatValue('2024-03-15T00:00:00.000', { type: 'time', granularity: 'day' })).toBe('2024-03-15'); + expect(formatValue('2024-03-01T00:00:00.000', { type: 'time', granularity: 'month' })).toBe('2024-03'); + expect(formatValue('2024-01-01T00:00:00.000', { type: 'time', granularity: 'year' })).toBe('2024'); + expect(formatValue('2024-03-11T00:00:00.000', { type: 'time', granularity: 'week' })).toBe('2024-03-11'); + expect(formatValue('2024-03-01T00:00:00.000', { type: 'time', granularity: 'quarter' })).toBe('2024-Q1'); + expect(formatValue('2024-03-15T14:00:00.000', { type: 'time', granularity: 'hour' })).toBe('2024-03-15 14:00:00'); + expect(formatValue('2024-03-15T14:30:45.000', { type: 'time' })).toBe('2024-03-15 14:30:45'); + }); + + it('format with nl-NL locale', () => { + const locale = 'nl-NL'; + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'EUR', locale })).toBe('€1.234,56'); + expect(formatValue(0, { type: 'number', format: 'currency', currency: 'EUR', locale })).toBe('€0,00'); + expect(formatValue(1234.56, { type: 'number', format: 'currency', currency: 'USD', locale })).toBe('US$1.234,56'); + expect(formatValue(1234.56, { type: 'number', format: 'number', locale })).toBe('1.234,56'); + expect(formatValue(1234.56, { type: 'number', locale })).toBe('1.234,56'); + }); + + it('format with en-IN locale (non-uniform digit grouping)', () => { + const locale = 'en-IN'; + expect(formatValue(1234567.89, { type: 'number', format: 'number', locale })).toBe('12,34,567.89'); + expect(formatValue(1234567.89, { type: 'number', format: 'currency', currency: 'INR', locale })).toBe('₹12,34,567.89'); + expect(formatValue(1234567.89, { type: 'number', locale })).toBe('12,34,567.89'); + }); + + it('invalid date input returns Invalid date', () => { + expect(formatValue('not-a-date', { type: 'time' })).toBe('Invalid date'); + expect(formatValue('not-a-date', { type: 'time', granularity: 'day' })).toBe('Invalid date'); + expect(formatValue('not-a-date', { type: 'time', format: { type: 'custom-time', value: '%Y-%m-%d' } })).toBe('Invalid date'); + }); + + it('custom emptyPlaceholder', () => { + expect(formatValue(null, { type: 'number', emptyPlaceholder: 'N/A' })).toBe('N/A'); + expect(formatValue(undefined, { type: 'time', emptyPlaceholder: '-' })).toBe('-'); + }); + + it('default fallback', () => { + expect(formatValue('hello', { type: 'string' })).toBe('hello'); + expect(formatValue(42, { type: 'number' })).toBe('42.00'); + expect(formatValue(true, { type: 'boolean' })).toBe('true'); + expect(formatValue('', { type: 'string' })).toBe(''); + }); +}); diff --git a/packages/cubejs-client-core/vitest.config.ts b/packages/cubejs-client-core/vitest.config.ts index 61156bb38410a..889e60257e4c9 100644 --- a/packages/cubejs-client-core/vitest.config.ts +++ b/packages/cubejs-client-core/vitest.config.ts @@ -1,6 +1,18 @@ +import path from 'path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: [ + { + // d3-format exports map ("./locale/*": "./locale/*.json") doubles the + // .json extension when the import already includes it. Rewrite to the + // actual file path so Vite skips the exports map resolution. + find: /^d3-format\/locale\/(.+)\.json$/, + replacement: path.resolve(__dirname, '../../node_modules/d3-format/locale/$1.json'), + }, + ], + }, test: { globals: true, environment: 'jsdom', diff --git a/yarn.lock b/yarn.lock index 39c8899a89756..e5bf0e11cfde3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8395,6 +8395,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== +"@types/d3-format@^3": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + "@types/d3-interpolate@^3.0.1": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" @@ -8421,6 +8426,11 @@ dependencies: "@types/d3-path" "*" +"@types/d3-time-format@^4": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + "@types/d3-time@*", "@types/d3-time@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" @@ -12778,6 +12788,11 @@ d3-ease@^3.0.1: resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== +d3-format@^3.1.0: + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" @@ -12808,7 +12823,7 @@ d3-shape@^3.1.0: dependencies: d3-path "^3.1.0" -"d3-time-format@2 - 4": +"d3-time-format@2 - 4", d3-time-format@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==