diff --git a/src/components/Charts/BarChart/BarChartContent.tsx b/src/components/Charts/BarChart/BarChartContent.tsx index ecfb90e8a1b6..6a038b1ccef7 100644 --- a/src/components/Charts/BarChart/BarChartContent.tsx +++ b/src/components/Charts/BarChart/BarChartContent.tsx @@ -11,6 +11,7 @@ import ChartXAxisLabels from '@components/Charts/components/ChartXAxisLabels'; import ChartYAxisLabels from '@components/Charts/components/ChartYAxisLabels'; import type {HitTestArgs} from '@components/Charts/hooks'; import { + ChartFontsProvider, useChartFontManager, useChartInteractions, useChartLabelFormats, @@ -44,7 +45,7 @@ type BarChartProps = CartesianChartProps & { useSingleColor?: boolean; }; -function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { +function BarChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', useSingleColor = false, onBarPress}: BarChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const fontMgr = useChartFontManager(); @@ -301,6 +302,14 @@ function BarChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left' ); } +function BarChartContent(props: BarChartProps) { + return ( + + + + ); +} + export default BarChartContent; export type {BarChartProps}; export {BAR_INNER_PADDING}; diff --git a/src/components/Charts/LineChart/LineChartContent.tsx b/src/components/Charts/LineChart/LineChartContent.tsx index f0129ac2ae4d..93b9e9a80051 100644 --- a/src/components/Charts/LineChart/LineChartContent.tsx +++ b/src/components/Charts/LineChart/LineChartContent.tsx @@ -13,6 +13,7 @@ import LeftFrameLine from '@components/Charts/components/LeftFrameLine'; import ScatterPoints from '@components/Charts/components/ScatterPoints'; import type {HitTestArgs} from '@components/Charts/hooks'; import { + ChartFontsProvider, useChartFontManager, useChartInteractions, useChartLabelFormats, @@ -47,7 +48,7 @@ type LineChartProps = CartesianChartProps & { onPointPress?: (dataPoint: ChartDataPoint, index: number) => void; }; -function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { +function LineChartContentBody({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left', onPointPress}: LineChartProps) { const theme = useTheme(); const styles = useThemeStyles(); const fontMgr = useChartFontManager(); @@ -304,5 +305,13 @@ function LineChartContent({data, isLoading, yAxisUnit, yAxisUnitPosition = 'left ); } +function LineChartContent(props: LineChartProps) { + return ( + + + + ); +} + export default LineChartContent; export type {LineChartProps}; diff --git a/src/components/Charts/VictoryTheme.ts b/src/components/Charts/VictoryTheme.ts index 78c83379b0d0..30b29a3ab9a6 100644 --- a/src/components/Charts/VictoryTheme.ts +++ b/src/components/Charts/VictoryTheme.ts @@ -2,9 +2,7 @@ * Centralized styles and layout constants for the chart components. */ import colors from '@styles/theme/colors'; - -/** Font families used by all chart label components (Paragraph API multi-font fallback). */ -const CHART_FONT_FAMILIES = ['ExpensifyNeue', 'NotoSansSymbols', 'NotoSansSCMonths']; +import {CHART_FONT_FAMILY_NAMES} from './utils/chartFontConstants'; /** * Expensify Chart Color Palette. @@ -54,7 +52,7 @@ const VictoryTheme = { default: getChartColor(DEFAULT_CHART_COLOR_INDEX), getColor: getChartColor, }, - fontFamilies: CHART_FONT_FAMILIES, + fontFamilies: Array.from(CHART_FONT_FAMILY_NAMES), axis: { /** Number of Y-axis ticks (including zero) */ tickCount: 5, diff --git a/src/components/Charts/context/ChartFontsContext.tsx b/src/components/Charts/context/ChartFontsContext.tsx new file mode 100644 index 000000000000..03ef4db13915 --- /dev/null +++ b/src/components/Charts/context/ChartFontsContext.tsx @@ -0,0 +1,26 @@ +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import {createContext, useContext} from 'react'; +import type ChartFontsValue from '@components/Charts/types/chartFontsTypes'; +import type {ChartDefaultTypeface} from '@components/Charts/types/chartSkiaTypefaceTypes'; + +const ChartFontsContext = createContext(null); + +function useChartFontsContext(): ChartFontsValue { + const context = useContext(ChartFontsContext); + + if (!context) { + throw new Error('Chart fonts hooks must be used within ChartFontsProvider'); + } + + return context; +} + +function useChartTypefaces(): ChartDefaultTypeface { + return useChartFontsContext().typefaces; +} + +function useChartFontManager(): SkTypefaceFontProvider | null { + return useChartFontsContext().fontMgr; +} + +export {ChartFontsContext, useChartFontManager, useChartTypefaces}; diff --git a/src/components/Charts/context/ChartFontsProvider.tsx b/src/components/Charts/context/ChartFontsProvider.tsx new file mode 100644 index 000000000000..b90c811285ae --- /dev/null +++ b/src/components/Charts/context/ChartFontsProvider.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import useChartFonts from '@components/Charts/hooks/useChartFonts'; +import type ChartFontsValue from '@components/Charts/types/chartFontsTypes'; +import {ChartFontsContext} from './ChartFontsContext'; + +type ChartFontsProviderProps = { + children: React.ReactNode; + value?: ChartFontsValue; +}; + +function ChartFontsLoaderProvider({children}: {children: React.ReactNode}) { + const fonts = useChartFonts(); + + return {children}; +} + +function ChartFontsProvider({children, value}: ChartFontsProviderProps) { + if (value) { + return {children}; + } + + return {children}; +} + +export default ChartFontsProvider; diff --git a/src/components/Charts/hooks/index.ts b/src/components/Charts/hooks/index.ts index c18d0c9934d4..7ff2f5556252 100644 --- a/src/components/Charts/hooks/index.ts +++ b/src/components/Charts/hooks/index.ts @@ -2,7 +2,9 @@ export {default as useChartLabelLayout} from './useChartLabelLayout'; export {default as useChartLabelMeasurements} from './useChartLabelMeasurements'; export {default as useChartParagraphs} from './useChartParagraphs'; export {default as useYAxisLabelWidth} from './useYAxisLabelWidth'; -export {default as useChartFontManager, useChartDefaultTypeface} from './useChartFontManager/useChartFontManager'; +export {useChartFontManager} from '@components/Charts/context/ChartFontsContext'; +export {default as ChartFontsProvider} from '@components/Charts/context/ChartFontsProvider'; +export {useChartTypefaces} from '@components/Charts/context/ChartFontsContext'; export {useChartInteractions, TOOLTIP_BAR_GAP} from './useChartInteractions'; export type {HitTestArgs} from './useChartInteractions'; export {default as useChartLabelFormats} from './useChartLabelFormats'; diff --git a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts deleted file mode 100644 index 04cd0344d420..000000000000 --- a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type {DataModule, SkTypefaceFontProvider} from '@shopify/react-native-skia'; -import {useFonts, useTypeface} from '@shopify/react-native-skia'; - -function useChartFontManager(): SkTypefaceFontProvider | null { - return useFonts({ - ExpensifyNeue: [ - require('@assets/fonts/native/ExpensifyNeue-Regular.otf') as DataModule, - require('@assets/fonts/native/ExpensifyNeue-Bold.otf') as DataModule, - require('@assets/fonts/native/ExpensifyNeue-Italic.otf') as DataModule, - require('@assets/fonts/native/ExpensifyNeue-BoldItalic.otf') as DataModule, - ], - NotoSansSymbols: [require('@assets/fonts/NotoSans-Symbols.ttf') as DataModule], - NotoSansSCMonths: [require('@assets/fonts/NotoSansSC-Months.ttf') as DataModule], - }); -} - -function useChartDefaultTypeface() { - const regular = useTypeface(require('@assets/fonts/native/ExpensifyNeue-Regular.otf') as DataModule); - const bold = useTypeface(require('@assets/fonts/native/ExpensifyNeue-Bold.otf') as DataModule); - return {regular, bold}; -} - -export {useChartDefaultTypeface}; -export default useChartFontManager; diff --git a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts deleted file mode 100644 index e0c8a8305fed..000000000000 --- a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type {DataModule, SkTypefaceFontProvider} from '@shopify/react-native-skia'; -import {useFonts, useTypeface} from '@shopify/react-native-skia'; - -function webFont(url: string): DataModule { - // We construct a fake ESModule-shaped object because react-native-skia's `useFonts` on web expects - // a DataModule (i.e. the result of a dynamic `require()` call), which always has the shape - // `{ __esModule: true, default: }`. The `__esModule` property uses a double-underscore prefix - // that violates the naming-convention rule, but it is mandated by the library's internal contract. - return {__esModule: true, default: url} as unknown as DataModule; -} - -function useChartFontManager(): SkTypefaceFontProvider | null { - return useFonts({ - ExpensifyNeue: [ - webFont(require('@assets/fonts/web/ExpensifyNeue-Regular.woff2') as string), - webFont(require('@assets/fonts/web/ExpensifyNeue-Bold.woff2') as string), - webFont(require('@assets/fonts/web/ExpensifyNeue-Italic.woff2') as string), - webFont(require('@assets/fonts/web/ExpensifyNeue-BoldItalic.woff2') as string), - ], - NotoSansSymbols: [webFont(require('@assets/fonts/NotoSans-Symbols.ttf') as string)], - NotoSansSCMonths: [webFont(require('@assets/fonts/NotoSansSC-Months.ttf') as string)], - }); -} - -function useChartDefaultTypeface() { - const regular = useTypeface(webFont(require('@assets/fonts/web/ExpensifyNeue-Regular.woff2') as string)); - const bold = useTypeface(webFont(require('@assets/fonts/web/ExpensifyNeue-Bold.woff2') as string)); - return {regular, bold}; -} - -export {useChartDefaultTypeface}; -export default useChartFontManager; diff --git a/src/components/Charts/hooks/useChartFonts.ts b/src/components/Charts/hooks/useChartFonts.ts new file mode 100644 index 000000000000..5b012437a9d1 --- /dev/null +++ b/src/components/Charts/hooks/useChartFonts.ts @@ -0,0 +1,15 @@ +import {useEffect, useSyncExternalStore} from 'react'; +import type ChartFontsValue from '@components/Charts/types/chartFontsTypes'; +import {getChartFontsSnapshot, loadChartFontsOnce, subscribeToChartFonts} from '@components/Charts/utils/chartFontsCache'; + +function useChartFonts(): ChartFontsValue { + const fonts = useSyncExternalStore(subscribeToChartFonts, getChartFontsSnapshot, getChartFontsSnapshot); + + useEffect(() => { + loadChartFontsOnce(); + }, []); + + return fonts; +} + +export default useChartFonts; diff --git a/src/components/Charts/types/chartFontsTypes.ts b/src/components/Charts/types/chartFontsTypes.ts new file mode 100644 index 000000000000..deaecc415114 --- /dev/null +++ b/src/components/Charts/types/chartFontsTypes.ts @@ -0,0 +1,9 @@ +import type {SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {ChartDefaultTypeface} from './chartSkiaTypefaceTypes'; + +type ChartFontsValue = { + typefaces: ChartDefaultTypeface; + fontMgr: SkTypefaceFontProvider | null; +}; + +export default ChartFontsValue; diff --git a/src/components/Charts/types/chartSkiaTypefaceTypes.ts b/src/components/Charts/types/chartSkiaTypefaceTypes.ts new file mode 100644 index 000000000000..fa26dde27a2a --- /dev/null +++ b/src/components/Charts/types/chartSkiaTypefaceTypes.ts @@ -0,0 +1,9 @@ +import type {SkTypeface} from '@shopify/react-native-skia'; +// eslint-disable-next-line no-restricted-imports +import type FontFamilyStyles from '@styles/utils/FontUtils/fontFamily/types'; + +type ChartSkiaTypefaceKey = Exclude; + +type ChartDefaultTypeface = Record; + +export type {ChartDefaultTypeface, ChartSkiaTypefaceKey}; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types/index.ts similarity index 96% rename from src/components/Charts/types.ts rename to src/components/Charts/types/index.ts index 2ed86d19ec06..b44954eb31e4 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types/index.ts @@ -1,6 +1,6 @@ import type {SkParagraph} from '@shopify/react-native-skia'; import type {ValueOf} from 'type-fest'; -import type {LABEL_ROTATIONS} from './VictoryTheme'; +import type {LABEL_ROTATIONS} from '@components/Charts/VictoryTheme'; type ChartDataPoint = { /** Label displayed under the data point (e.g., "Amazon", "Nov 2025") */ diff --git a/src/components/Charts/utils/chartFontAssets.native.ts b/src/components/Charts/utils/chartFontAssets.native.ts new file mode 100644 index 000000000000..585fdee494e4 --- /dev/null +++ b/src/components/Charts/utils/chartFontAssets.native.ts @@ -0,0 +1,37 @@ +import type {DataModule} from '@shopify/react-native-skia'; +import type {ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; + +const EXPENSIFY_MONO_REGULAR = require('@assets/fonts/native/ExpensifyMono-Regular.otf') as DataModule; +const EXPENSIFY_MONO_BOLD = require('@assets/fonts/native/ExpensifyMono-Bold.otf') as DataModule; +const EXPENSIFY_MONO_ITALIC = require('@assets/fonts/native/ExpensifyMono-Italic.otf') as DataModule; +const EXPENSIFY_MONO_BOLD_ITALIC = require('@assets/fonts/native/ExpensifyMono-BoldItalic.otf') as DataModule; +const EXPENSIFY_NEUE_REGULAR = require('@assets/fonts/native/ExpensifyNeue-Regular.otf') as DataModule; +const EXPENSIFY_NEUE_BOLD = require('@assets/fonts/native/ExpensifyNeue-Bold.otf') as DataModule; +const EXPENSIFY_NEUE_ITALIC = require('@assets/fonts/native/ExpensifyNeue-Italic.otf') as DataModule; +const EXPENSIFY_NEUE_BOLD_ITALIC = require('@assets/fonts/native/ExpensifyNeue-BoldItalic.otf') as DataModule; +const EXPENSIFY_NEW_KANSAS_MEDIUM = require('@assets/fonts/native/ExpensifyNewKansas-Medium.otf') as DataModule; +const EXPENSIFY_NEW_KANSAS_MEDIUM_ITALIC = require('@assets/fonts/native/ExpensifyNewKansas-MediumItalic.otf') as DataModule; +const CUSTOM_EMOJI_FONT = require('@assets/fonts/native/CustomEmojiNativeFont.ttf') as DataModule; +const SANS_SYMBOLS_FONT = require('@assets/fonts/NotoSans-Symbols.ttf') as DataModule; +const SANS_SC_MONTHS_FONT = require('@assets/fonts/NotoSansSC-Months.ttf') as DataModule; + +const CHART_SKIA_TYPEFACE_ASSETS: Record = { + MONOSPACE: EXPENSIFY_MONO_REGULAR, + MONOSPACE_BOLD: EXPENSIFY_MONO_BOLD, + MONOSPACE_ITALIC: EXPENSIFY_MONO_ITALIC, + MONOSPACE_BOLD_ITALIC: EXPENSIFY_MONO_BOLD_ITALIC, + EXP_NEUE: EXPENSIFY_NEUE_REGULAR, + EXP_NEUE_BOLD: EXPENSIFY_NEUE_BOLD, + EXP_NEUE_ITALIC: EXPENSIFY_NEUE_ITALIC, + EXP_NEUE_BOLD_ITALIC: EXPENSIFY_NEUE_BOLD_ITALIC, + EXP_NEW_KANSAS_MEDIUM: EXPENSIFY_NEW_KANSAS_MEDIUM, + EXP_NEW_KANSAS_MEDIUM_ITALIC: EXPENSIFY_NEW_KANSAS_MEDIUM_ITALIC, + CUSTOM_EMOJI_FONT, +}; + +const CHART_FONT_MGR_SUPPLEMENTAL_ASSETS = { + NotoSansSymbols: SANS_SYMBOLS_FONT, + NotoSansSCMonths: SANS_SC_MONTHS_FONT, +} as const; + +export {CHART_FONT_MGR_SUPPLEMENTAL_ASSETS, CHART_SKIA_TYPEFACE_ASSETS}; diff --git a/src/components/Charts/utils/chartFontAssets.ts b/src/components/Charts/utils/chartFontAssets.ts new file mode 100644 index 000000000000..a846320d3d7b --- /dev/null +++ b/src/components/Charts/utils/chartFontAssets.ts @@ -0,0 +1,38 @@ +import type {DataModule} from '@shopify/react-native-skia'; +import type {ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; +import chartWebFont from './chartWebFont'; + +const EXPENSIFY_MONO_REGULAR = chartWebFont(require('@assets/fonts/web/ExpensifyMono-Regular.woff2') as string); +const EXPENSIFY_MONO_BOLD = chartWebFont(require('@assets/fonts/web/ExpensifyMono-Bold.woff2') as string); +const EXPENSIFY_MONO_ITALIC = chartWebFont(require('@assets/fonts/web/ExpensifyMono-Italic.woff2') as string); +const EXPENSIFY_MONO_BOLD_ITALIC = chartWebFont(require('@assets/fonts/web/ExpensifyMono-BoldItalic.woff2') as string); +const EXPENSIFY_NEUE_REGULAR = chartWebFont(require('@assets/fonts/web/ExpensifyNeue-Regular.woff2') as string); +const EXPENSIFY_NEUE_BOLD = chartWebFont(require('@assets/fonts/web/ExpensifyNeue-Bold.woff2') as string); +const EXPENSIFY_NEUE_ITALIC = chartWebFont(require('@assets/fonts/web/ExpensifyNeue-Italic.woff2') as string); +const EXPENSIFY_NEUE_BOLD_ITALIC = chartWebFont(require('@assets/fonts/web/ExpensifyNeue-BoldItalic.woff2') as string); +const EXPENSIFY_NEW_KANSAS_MEDIUM = chartWebFont(require('@assets/fonts/web/ExpensifyNewKansas-Medium.woff2') as string); +const EXPENSIFY_NEW_KANSAS_MEDIUM_ITALIC = chartWebFont(require('@assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2') as string); +const CUSTOM_EMOJI_FONT = chartWebFont(require('@assets/fonts/web/CustomEmojiWebFont.ttf') as string); +const SANS_SYMBOLS_FONT = chartWebFont(require('@assets/fonts/NotoSans-Symbols.ttf') as string); +const SANS_SC_MONTHS_FONT = chartWebFont(require('@assets/fonts/NotoSansSC-Months.ttf') as string); + +const CHART_SKIA_TYPEFACE_ASSETS: Record = { + MONOSPACE: EXPENSIFY_MONO_REGULAR, + MONOSPACE_BOLD: EXPENSIFY_MONO_BOLD, + MONOSPACE_ITALIC: EXPENSIFY_MONO_ITALIC, + MONOSPACE_BOLD_ITALIC: EXPENSIFY_MONO_BOLD_ITALIC, + EXP_NEUE: EXPENSIFY_NEUE_REGULAR, + EXP_NEUE_BOLD: EXPENSIFY_NEUE_BOLD, + EXP_NEUE_ITALIC: EXPENSIFY_NEUE_ITALIC, + EXP_NEUE_BOLD_ITALIC: EXPENSIFY_NEUE_BOLD_ITALIC, + EXP_NEW_KANSAS_MEDIUM: EXPENSIFY_NEW_KANSAS_MEDIUM, + EXP_NEW_KANSAS_MEDIUM_ITALIC: EXPENSIFY_NEW_KANSAS_MEDIUM_ITALIC, + CUSTOM_EMOJI_FONT, +}; + +const CHART_FONT_MGR_SUPPLEMENTAL_ASSETS = { + NotoSansSymbols: SANS_SYMBOLS_FONT, + NotoSansSCMonths: SANS_SC_MONTHS_FONT, +} as const; + +export {CHART_FONT_MGR_SUPPLEMENTAL_ASSETS, CHART_SKIA_TYPEFACE_ASSETS}; diff --git a/src/components/Charts/utils/chartFontConstants.ts b/src/components/Charts/utils/chartFontConstants.ts new file mode 100644 index 000000000000..0ac924e91f90 --- /dev/null +++ b/src/components/Charts/utils/chartFontConstants.ts @@ -0,0 +1,16 @@ +import type {TupleToUnion} from 'type-fest'; +import type {ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; + +/** Font families registered on Skia fontMgr and used by Paragraph API multi-font fallback. */ +const CHART_FONT_FAMILY_NAMES = ['ExpensifyNeue', 'ExpensifyMono', 'ExpensifyNewKansas', 'NotoSansSymbols', 'NotoSansSCMonths'] as const; + +type ChartFontFamilyName = TupleToUnion; + +/** Maps fontMgr family names to already-loaded chart typefaces (Noto families load separately). */ +const CHART_FONT_MGR_FROM_TYPEFACES: Record, ChartSkiaTypefaceKey[]> = { + ExpensifyNeue: ['EXP_NEUE', 'EXP_NEUE_BOLD', 'EXP_NEUE_ITALIC', 'EXP_NEUE_BOLD_ITALIC'], + ExpensifyMono: ['MONOSPACE', 'MONOSPACE_BOLD', 'MONOSPACE_ITALIC', 'MONOSPACE_BOLD_ITALIC'], + ExpensifyNewKansas: ['EXP_NEW_KANSAS_MEDIUM', 'EXP_NEW_KANSAS_MEDIUM_ITALIC'], +}; + +export {CHART_FONT_FAMILY_NAMES, CHART_FONT_MGR_FROM_TYPEFACES}; diff --git a/src/components/Charts/utils/chartFontsCache.ts b/src/components/Charts/utils/chartFontsCache.ts new file mode 100644 index 000000000000..3945e4d57550 --- /dev/null +++ b/src/components/Charts/utils/chartFontsCache.ts @@ -0,0 +1,127 @@ +import type {DataModule, SkTypeface} from '@shopify/react-native-skia'; +import {Skia} from '@shopify/react-native-skia'; +import {Image} from 'react-native'; +import type ChartFontsValue from '@components/Charts/types/chartFontsTypes'; +import type {ChartDefaultTypeface, ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; +import Log from '@libs/Log'; +import {CHART_FONT_MGR_SUPPLEMENTAL_ASSETS, CHART_SKIA_TYPEFACE_ASSETS} from './chartFontAssets'; +import {CHART_FONT_MGR_FROM_TYPEFACES} from './chartFontConstants'; + +const EMPTY_CHART_FONTS: ChartFontsValue = { + typefaces: Object.fromEntries((Object.keys(CHART_SKIA_TYPEFACE_ASSETS) as ChartSkiaTypefaceKey[]).map((key) => [key, null])) as ChartDefaultTypeface, + fontMgr: null, +}; + +let cachedChartFonts: ChartFontsValue | null = null; +let loadPromise: Promise | null = null; +const chartFontLoadListeners = new Set<() => void>(); + +function resolveChartFontAsset(source: DataModule | string): string { + if (typeof source === 'string') { + return source; + } + + if (typeof source === 'number') { + return Image.resolveAssetSource(source).uri; + } + + if ('default' in source && typeof source.default === 'string') { + return source.default; + } + + if ('uri' in source && typeof source.uri === 'string') { + return source.uri; + } + + throw new Error('Unsupported chart font asset source'); +} + +function loadTypefaceFromAsset(source: DataModule | string): Promise { + const uri = resolveChartFontAsset(source); + + return Skia.Data.fromURI(uri).then((data) => Skia.Typeface.MakeFreeTypeFaceFromData(data)); +} + +function loadChartSkiaTypefaces(): Promise { + const typefaceKeys = Object.keys(CHART_SKIA_TYPEFACE_ASSETS) as ChartSkiaTypefaceKey[]; + + return Promise.all( + typefaceKeys.map(async (typefaceKey) => { + const typeface = await loadTypefaceFromAsset(CHART_SKIA_TYPEFACE_ASSETS[typefaceKey]); + return [typefaceKey, typeface] as const; + }), + ).then((entries) => Object.fromEntries(entries) as ChartDefaultTypeface); +} + +function buildChartFontsValue(typefaces: ChartDefaultTypeface): Promise { + const fontMgr = Skia.TypefaceFontProvider.Make(); + + for (const [familyName, typefaceKeys] of Object.entries(CHART_FONT_MGR_FROM_TYPEFACES)) { + for (const typefaceKey of typefaceKeys) { + const typeface = typefaces[typefaceKey]; + + if (typeface) { + fontMgr.registerFont(typeface, familyName); + } + } + } + + return Promise.all( + Object.entries(CHART_FONT_MGR_SUPPLEMENTAL_ASSETS).map(async ([familyName, asset]) => { + const typeface = await loadTypefaceFromAsset(asset); + + if (typeface) { + fontMgr.registerFont(typeface, familyName); + } + }), + ).then(() => ({typefaces, fontMgr})); +} + +function loadChartFonts(): Promise { + return loadChartSkiaTypefaces().then(buildChartFontsValue); +} + +function subscribeToChartFonts(listener: () => void): () => void { + chartFontLoadListeners.add(listener); + + return () => { + chartFontLoadListeners.delete(listener); + }; +} + +function getChartFontsSnapshot(): ChartFontsValue { + return cachedChartFonts ?? EMPTY_CHART_FONTS; +} + +function notifyChartFontLoadListeners(): void { + for (const listener of chartFontLoadListeners) { + listener(); + } +} + +function loadChartFontsOnce(): Promise { + if (cachedChartFonts) { + return Promise.resolve(cachedChartFonts); + } + + if (!loadPromise) { + loadPromise = loadChartFonts() + .then((fonts) => { + cachedChartFonts = fonts; + notifyChartFontLoadListeners(); + return fonts; + }) + .catch((error: unknown) => { + Log.hmmm('Chart fonts failed to load', { + error: error instanceof Error ? error.message : String(error), + }); + loadPromise = null; + notifyChartFontLoadListeners(); + return EMPTY_CHART_FONTS; + }); + } + + return loadPromise; +} + +export {getChartFontsSnapshot, loadChartFontsOnce, subscribeToChartFonts}; diff --git a/src/components/Charts/utils/chartWebFont.ts b/src/components/Charts/utils/chartWebFont.ts new file mode 100644 index 000000000000..a569e1424586 --- /dev/null +++ b/src/components/Charts/utils/chartWebFont.ts @@ -0,0 +1,11 @@ +import type {DataModule} from '@shopify/react-native-skia'; + +function chartWebFont(url: string): DataModule { + // We construct a fake ESModule-shaped object because react-native-skia's `useFonts` on web expects + // a DataModule (i.e. the result of a dynamic `require()` call), which always has the shape + // `{ __esModule: true, default: }`. The `__esModule` property uses a double-underscore prefix + // that violates the naming-convention rule, but it is mandated by the library's internal contract. + return {__esModule: true, default: url} as unknown as DataModule; +} + +export default chartWebFont; diff --git a/src/components/Charts/utils/getChartSkiaTypeface.ts b/src/components/Charts/utils/getChartSkiaTypeface.ts new file mode 100644 index 000000000000..c1f0bd1521ee --- /dev/null +++ b/src/components/Charts/utils/getChartSkiaTypeface.ts @@ -0,0 +1,59 @@ +import type {SkTypeface} from '@shopify/react-native-skia'; +import type {TextStyle} from 'react-native'; +import type {ChartDefaultTypeface, ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; +// eslint-disable-next-line no-restricted-imports +import singleFontFamily from '@styles/utils/FontUtils/fontFamily/singleFontFamily'; +import type {ChartLabelFontWeight} from './normalizeChartFontWeight'; +import normalizeChartFontWeight from './normalizeChartFontWeight'; + +type ChartLabelFontStyle = 'normal' | 'italic'; + +function normalizeFontStyle(fontStyle: string | undefined): ChartLabelFontStyle { + return fontStyle === 'italic' ? 'italic' : 'normal'; +} + +function fontWeightMatches(definitionWeight: TextStyle['fontWeight'], labelWeight: ChartLabelFontWeight): boolean { + const numericWeight = Number(definitionWeight); + + if (labelWeight === 'bold') { + return numericWeight >= 600 || definitionWeight === 'bold'; + } + + return numericWeight < 600 && definitionWeight !== 'bold'; +} + +function getChartSkiaTypefaceKey(fontFamily: string | undefined, fontStyle: ChartLabelFontStyle, fontWeight: ChartLabelFontWeight): ChartSkiaTypefaceKey { + const resolvedFontFamily = fontFamily ?? singleFontFamily.EXP_NEUE.fontFamily; + + // Kansas has no bold variant; weight is ignored and Medium is always used. + if (resolvedFontFamily === singleFontFamily.EXP_NEW_KANSAS_MEDIUM.fontFamily) { + return fontStyle === 'italic' ? 'EXP_NEW_KANSAS_MEDIUM_ITALIC' : 'EXP_NEW_KANSAS_MEDIUM'; + } + + const matchingKey = (Object.keys(singleFontFamily) as Array) + .filter((key): key is ChartSkiaTypefaceKey => key !== 'SYSTEM') + .find((key) => { + const definition = singleFontFamily[key]; + return definition.fontFamily === resolvedFontFamily && normalizeFontStyle(definition.fontStyle) === fontStyle && fontWeightMatches(definition.fontWeight, fontWeight); + }); + + return matchingKey ?? 'EXP_NEUE'; +} + +function getChartSkiaTypeface( + typefaces: ChartDefaultTypeface, + { + fontFamily, + fontStyle, + fontWeight, + }: { + fontFamily?: string; + fontStyle?: string; + fontWeight?: string | number; + }, +): SkTypeface | null { + const typefaceKey = getChartSkiaTypefaceKey(fontFamily, normalizeFontStyle(fontStyle), normalizeChartFontWeight(fontWeight)); + return typefaces[typefaceKey]; +} + +export default getChartSkiaTypeface; diff --git a/src/components/Charts/utils.ts b/src/components/Charts/utils/index.ts similarity index 99% rename from src/components/Charts/utils.ts rename to src/components/Charts/utils/index.ts index 6db9a4455453..9fa433e9e6c6 100644 --- a/src/components/Charts/utils.ts +++ b/src/components/Charts/utils/index.ts @@ -1,8 +1,8 @@ import {FontStyle, FontWeight, Skia} from '@shopify/react-native-skia'; import type {SkParagraph, SkParagraphBuilder, SkTypefaceFontProvider} from '@shopify/react-native-skia'; +import type {ChartDataPoint, LabelRotation, PieSlice} from '@components/Charts/types'; +import VictoryTheme, {DIAGONAL_ANGLE_RADIAN_THRESHOLD, ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, MAX_X_AXIS_LABEL_WIDTH, SIN_45} from '@components/Charts/VictoryTheme'; import variables from '@styles/variables'; -import type {ChartDataPoint, LabelRotation, PieSlice} from './types'; -import VictoryTheme, {DIAGONAL_ANGLE_RADIAN_THRESHOLD, ELLIPSIS, LABEL_PADDING, LABEL_ROTATIONS, MAX_X_AXIS_LABEL_WIDTH, SIN_45} from './VictoryTheme'; /** One reusable ParagraphBuilder per fontMgr instance. Auto-GC'd when fontMgr is released. */ const builderCache = new WeakMap(); diff --git a/src/components/Charts/utils/normalizeChartFontWeight.ts b/src/components/Charts/utils/normalizeChartFontWeight.ts new file mode 100644 index 000000000000..f2154fe10a66 --- /dev/null +++ b/src/components/Charts/utils/normalizeChartFontWeight.ts @@ -0,0 +1,18 @@ +type ChartLabelFontWeight = 'normal' | 'bold'; + +function normalizeChartFontWeight(fontWeight: string | number | undefined): ChartLabelFontWeight { + if (fontWeight === 'bold') { + return 'bold'; + } + + const numericWeight = Number(fontWeight); + + if (!Number.isNaN(numericWeight) && numericWeight >= 600) { + return 'bold'; + } + + return 'normal'; +} + +export default normalizeChartFontWeight; +export type {ChartLabelFontWeight}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx index da522351285b..2ceb4d4ac69f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/BaseVictoryChartRenderer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import {ChartFontsProvider} from '@components/Charts/hooks'; import VictoryChartContainer from './components/VictoryChartContainer'; import VictoryChartContent from './components/VictoryChartContent'; import {VictoryChartProvider} from './context/VictoryChartContext'; @@ -6,14 +7,14 @@ import type {VictoryChartRendererProps} from './types'; function BaseVictoryChartRenderer({tnode}: VictoryChartRendererProps) { return ( - - - - - + + + + + + + ); } -BaseVictoryChartRenderer.displayName = 'BaseVictoryChartRenderer'; - export default BaseVictoryChartRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 10cef4f0875c..a50e79bf578f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {CartesianChart} from 'victory-native'; +import {ChartFontsProvider} from '@components/Charts/hooks'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {VictoryChartRenderArgsProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; @@ -25,20 +26,23 @@ function VictoryChartCartesian() { domainPadding={domainPadding} padding={padding} renderOutside={(renderArgs) => ( - - {labelItems.map((labelItem) => ( - - ))} - {legendItems.map((legendItem) => ( - - ))} - + // Chart font context does not propagate across the Skia renderOutside boundary. + + + {labelItems.map((labelItem) => ( + + ))} + {legendItems.map((legendItem) => ( + + ))} + + )} > {(renderArgs) => ( @@ -56,6 +60,4 @@ function VictoryChartCartesian() { ); } -VictoryChartCartesian.displayName = 'VictoryChartCartesian'; - export default VictoryChartCartesian; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCategories.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCategories.tsx index 348230c9c9da..5fdc144c38a8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCategories.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCategories.tsx @@ -24,6 +24,4 @@ function VictoryChartCategories({tnode}: VictoryChartCategoriesProps) { return ; } -VictoryChartCategories.displayName = 'VictoryChartCategories'; - export default VictoryChartCategories; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx index 76e3f963bda1..bc3a2ca25cd6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabel.tsx @@ -1,7 +1,8 @@ import {Skia, Text as SkText} from '@shopify/react-native-skia'; import type {Color, SkFont} from '@shopify/react-native-skia'; import React from 'react'; -import {useChartDefaultTypeface} from '@components/Charts/hooks'; +import {useChartTypefaces} from '@components/Charts/hooks'; +import getChartSkiaTypeface from '@components/Charts/utils/getChartSkiaTypeface'; import type {LabelItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import computeTextAnchorPosition from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeTextAnchorPosition'; @@ -20,15 +21,21 @@ type ProcessedLine = { * Renders floating Skia text labels (from `` nodes) over the chart canvas. * Intended for use inside CartesianChart's `renderOutside` callback. */ -function VictoryChartLabel({x, y, text, color, fontSize, fontWeight, lineHeight, textAnchor = 'start', verticalAnchor = 'start'}: VictoryChartLabelsProps) { - const {regular: regularTypeface, bold: boldTypeface} = useChartDefaultTypeface(); +function VictoryChartLabel({x, y, text, color, fontSize, fontWeight, fontFamily, fontStyle, lineHeight, textAnchor = 'start', verticalAnchor = 'start'}: VictoryChartLabelsProps) { + const typefaces = useChartTypefaces(); const processedLines = text.split('\n').reduce( (acc, line, index) => { const lineColor = color?.[index]; const lineFontSize = fontSize?.[index]; const lineFontWeight = fontWeight?.[index]; + const lineFontFamily = fontFamily?.[index]; + const lineFontStyle = fontStyle?.[index]; const lineLineHeight = lineHeight?.[index]; - const typeface = lineFontWeight === 'bold' ? boldTypeface : regularTypeface; + const typeface = getChartSkiaTypeface(typefaces, { + fontFamily: lineFontFamily, + fontStyle: lineFontStyle, + fontWeight: lineFontWeight, + }); const lineFont = typeface && lineFontSize ? Skia.Font(typeface, lineFontSize) : null; const fontMetrics = lineFont?.getMetrics(); const lineWidth = lineFont?.getGlyphWidths(lineFont.getGlyphIDs(line)).reduce((totalWidth, width) => totalWidth + width, 0) ?? 0; @@ -64,6 +71,4 @@ function VictoryChartLabel({x, y, text, color, fontSize, fontWeight, lineHeight, }); } -VictoryChartLabel.displayName = 'VictoryChartLabel'; - export default VictoryChartLabel; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx index b7de61bca20f..70d654878b68 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLegend.tsx @@ -1,7 +1,8 @@ import {Circle, Skia, Text as SkText} from '@shopify/react-native-skia'; import type {Color, SkFont} from '@shopify/react-native-skia'; import React, {Fragment} from 'react'; -import {useChartDefaultTypeface} from '@components/Charts/hooks'; +import {useChartTypefaces} from '@components/Charts/hooks'; +import getChartSkiaTypeface from '@components/Charts/utils/getChartSkiaTypeface'; import type {LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; type VictoryChartLegendProps = LegendItem; @@ -23,10 +24,10 @@ type ProcessedEntry = { * Intended for use inside CartesianChart's `renderOutside` callback. */ function VictoryChartLegend({x, y, entries, gutter, symbolSpacer}: VictoryChartLegendProps) { - const {regular: regularTypeface, bold: boldTypeface} = useChartDefaultTypeface(); + const typefaces = useChartTypefaces(); const processedEntries = entries.reduce( - (acc, {text, color, fontSize, fontWeight, symbolColor, symbolSize}) => { - const typeface = fontWeight === 'bold' ? boldTypeface : regularTypeface; + (acc, {text, color, fontSize, fontWeight, fontFamily, fontStyle, symbolColor, symbolSize}) => { + const typeface = getChartSkiaTypeface(typefaces, {fontFamily, fontStyle, fontWeight}); const font = typeface && fontSize ? Skia.Font(typeface, fontSize) : null; const fontMetrics = font?.getMetrics(); const lineHeight = fontMetrics ? fontMetrics.ascent + fontMetrics.descent + fontMetrics.leading : 0; @@ -75,6 +76,4 @@ function VictoryChartLegend({x, y, entries, gutter, symbolSpacer}: VictoryChartL }); } -VictoryChartLegend.displayName = 'VictoryChartLegend'; - export default VictoryChartLegend; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx index 0843a623ce02..8226c772ea6a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx @@ -1,7 +1,10 @@ import React from 'react'; +import {useAmbientTRenderEngine} from 'react-native-render-html'; import type {TNode} from 'react-native-render-html'; import {Pie} from 'victory-native'; +import parseVictoryLabelNode from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser'; import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; +import parseComponent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseComponent'; import VictoryChartPieLabel from './VictoryChartPieLabel'; type VictoryChartPieProps = {tnode: TNode}; @@ -10,6 +13,10 @@ type VictoryChartPieProps = {tnode: TNode}; const START_ANGLE = 270; function VictoryChartPie({tnode}: VictoryChartPieProps) { + const renderEngine = useAmbientTRenderEngine(); + const labelComponentNode = parseComponent(tnode.attributes.labelcomponent, renderEngine, 'victorylabel'); + const labelItemTemplate = labelComponentNode ? parseVictoryLabelNode(labelComponentNode).labelItems?.at(0) : undefined; + const innerRadius = tnode.attributes.innerradius !== undefined ? Number(parseAttribute(tnode.attributes.innerradius)) : undefined; const radius = tnode.attributes.radius !== undefined ? Number(parseAttribute(tnode.attributes.radius)) : undefined; const size = radius ? radius * 2 : undefined; @@ -25,6 +32,7 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { )} @@ -32,6 +40,4 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { ); } -VictoryChartPie.displayName = 'VictoryChartPie'; - export default VictoryChartPie; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPieLabel.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPieLabel.tsx index a9ff0dd3ee67..6b99a8361a74 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPieLabel.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPieLabel.tsx @@ -1,28 +1,23 @@ import React from 'react'; -import {useAmbientTRenderEngine} from 'react-native-render-html'; import type {TNode} from 'react-native-render-html'; import type {PieSliceData} from 'victory-native'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; -import parseVictoryLabelNode from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser'; -import type {PolarChartData} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import type {LabelItem, PolarChartData} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; -import parseComponent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseComponent'; import VictoryChartLabel from './VictoryChartLabel'; type VictoryChartPieLabelProps = { tnode: TNode; slice: PieSliceData; + labelItemTemplate?: LabelItem; }; const RADIAN = Math.PI / 180; -function VictoryChartPieLabel({tnode, slice}: VictoryChartPieLabelProps) { +function VictoryChartPieLabel({tnode, slice, labelItemTemplate}: VictoryChartPieLabelProps) { const {data} = useVictoryChartContext(); - const renderEngine = useAmbientTRenderEngine(); - const labelComponentNode = parseComponent(tnode.attributes.labelcomponent, renderEngine, 'victorylabel'); - const labelItem = labelComponentNode ? parseVictoryLabelNode(labelComponentNode).labelItems?.at(0) : undefined; - if (!labelItem) { + if (!labelItemTemplate) { return null; } @@ -35,14 +30,15 @@ function VictoryChartPieLabel({tnode, slice}: VictoryChartPieLabelProps) { const x = slice.center.x + labelRadius * Math.cos(-midAngle * RADIAN); const y = slice.center.y + labelRadius * Math.sin(midAngle * RADIAN); - labelItem.text = text; - labelItem.x = x; - labelItem.y = y; - labelItem.textAnchor = 'middle'; + const labelItem: LabelItem = { + ...labelItemTemplate, + text, + x, + y, + textAnchor: 'middle', + }; return ; } -VictoryChartPieLabel.displayName = 'VictoryChartPieLabel'; - export default VictoryChartPieLabel; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index ebaa6856e2f9..65c7e0233380 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {PolarChart} from 'victory-native'; +import {ChartFontsProvider} from '@components/Charts/hooks'; import {COLOR_KEY, LABEL_KEY, VALUE_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; @@ -20,28 +21,29 @@ function VictoryChartPolar() { valueKey={VALUE_KEY} colorKey={COLOR_KEY} > - {tnode.children.map((child) => ( - - ))} - {labelItems.map((labelItem) => ( - - ))} - {legendItems.map((legendItem) => ( - - ))} + {/* Chart font context does not propagate into polar Skia children. */} + + {tnode.children.map((child) => ( + + ))} + {labelItems.map((labelItem) => ( + + ))} + {legendItems.map((legendItem) => ( + + ))} + ); } -VictoryChartPolar.displayName = 'VictoryChartPolar'; - export default VictoryChartPolar; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index a6ee93bc96a9..332d34825f69 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -1,6 +1,6 @@ import React, {createContext, useContext} from 'react'; import type {TNode} from 'react-native-render-html'; -import {useChartDefaultTypeface} from '@components/Charts/hooks'; +import {useChartTypefaces} from '@components/Charts/hooks'; import {CHART_TYPE, LABEL_KEY, X_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import processVictoryChartTree from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree'; import type {ChartType, ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; @@ -32,8 +32,8 @@ const VictoryChartContext = createContext(null) * Returns null when the chart data is invalid (no data points, or mixed cartesian/polar content). */ function VictoryChartProvider({tnode, children}: {tnode: TNode; children: React.ReactNode}) { - const {regular: regularTypeface} = useChartDefaultTypeface(); - const {data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems} = processVictoryChartTree(tnode, regularTypeface, null); + const typefaces = useChartTypefaces(); + const {data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, categories, labelItems, legendItems} = processVictoryChartTree(tnode, typefaces.EXP_NEUE, null); const {nodeStyles: chartContentStyles, parentNodeStyles: chartContainerStyles} = parseStyles(tnode); const hasCartesianData = Object.values(data).some((entry) => X_KEY in entry); @@ -75,8 +75,6 @@ function VictoryChartProvider({tnode, children}: {tnode: TNode; children: React. return {children}; } -VictoryChartProvider.displayName = 'VictoryChartProvider'; - function useVictoryChartContext(): VictoryChartContextValue { const context = useContext(VictoryChartContext); if (!context) { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts index a244f0b8c32e..78c4b7c61845 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts @@ -1,4 +1,5 @@ import type {TNode} from 'react-native-render-html'; +import normalizeChartFontWeight from '@components/Charts/utils/normalizeChartFontWeight'; import type {LabelItem, PartialProcessNodeResult, RawLabelStyle, TextAnchor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -13,6 +14,8 @@ function parseVictoryLabelNode(tnode: TNode): PartialProcessNodeResult { color: {}, fontSize: {}, fontWeight: {}, + fontFamily: {}, + fontStyle: {}, lineHeight: parseAttribute(tnode.attributes.lineheight), textAnchor: parseAttribute(tnode.attributes.textanchor), verticalAnchor: parseAttribute(tnode.attributes.verticalanchor), @@ -37,7 +40,19 @@ function parseVictoryLabelNode(tnode: TNode): PartialProcessNodeResult { if (textStyle.fontWeight) { labelItem.fontWeight = { ...labelItem.fontWeight, - [index]: Number(textStyle.fontWeight) === 700 ? 'bold' : 'normal', + [index]: normalizeChartFontWeight(textStyle.fontWeight), + }; + } + if (textStyle.fontFamily) { + labelItem.fontFamily = { + ...labelItem.fontFamily, + [index]: textStyle.fontFamily, + }; + } + if (textStyle.fontStyle) { + labelItem.fontStyle = { + ...labelItem.fontStyle, + [index]: textStyle.fontStyle, }; } } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLegendParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLegendParser.ts index ebc83a9ac9fe..6b848196008c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLegendParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLegendParser.ts @@ -1,4 +1,5 @@ import type {TNode} from 'react-native-render-html'; +import normalizeChartFontWeight from '@components/Charts/utils/normalizeChartFontWeight'; import type {LegendItem, LegendItemEntry, PartialProcessNodeResult, RawLegendData, RawLegendStyle} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -13,12 +14,14 @@ function parseVictoryLegendNode(tnode: TNode): PartialProcessNodeResult { const style = parseAttribute(tnode.attributes.style); const color = style?.labels?.fill; const fontSize = style?.labels?.fontSize !== undefined ? Number(style.labels.fontSize) : undefined; - const fontWeight = Number(style?.labels?.fontWeight) === 700 ? 'bold' : undefined; + const fontWeight = style?.labels?.fontWeight !== undefined ? normalizeChartFontWeight(style.labels.fontWeight) : undefined; + const fontFamily = style?.labels?.fontFamily; + const fontStyle = style?.labels?.fontStyle; const entries: LegendItemEntry[] = (parseAttribute(tnode.attributes.data) ?? []).map((entry) => { const text = entry.name; const symbolColor = entry.symbol?.fill; const symbolSize = entry.symbol?.size !== undefined ? Number(entry.symbol.size) : undefined; - return {text, color, fontSize, fontWeight, symbolColor, symbolSize}; + return {text, color, fontSize, fontWeight, fontFamily, fontStyle, symbolColor, symbolSize}; }); const legendItem: LegendItem = {x, y, entries, gutter, symbolSpacer}; return {legendItems: [legendItem]}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 51f52d6656c3..4797b16ceb7b 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts @@ -36,6 +36,8 @@ type RawLabelStyle = { fill?: Color; fontSize?: string | number; fontWeight?: string | number; + fontFamily?: string; + fontStyle?: string; }; type RawLegendStyle = { @@ -43,6 +45,8 @@ type RawLegendStyle = { fill?: Color; fontSize?: string | number; fontWeight?: string | number; + fontFamily?: string; + fontStyle?: string; }; }; @@ -81,6 +85,12 @@ type LabelItem = { /** Font weight (per line) */ fontWeight?: Record; + /** Font family (per line) */ + fontFamily?: Record; + + /** Font style (per line) */ + fontStyle?: Record; + /** Line height (per line) */ lineHeight?: Record; @@ -104,6 +114,12 @@ type LegendItemEntry = { /** Font weight */ fontWeight?: 'normal' | 'bold'; + /** Font family */ + fontFamily?: string; + + /** Font style */ + fontStyle?: string; + /** The color of the symbol */ symbolColor?: Color; diff --git a/tests/unit/components/Charts/VictoryTheme.test.ts b/tests/unit/components/Charts/VictoryTheme.test.ts index 44f821ce0a26..78525af7b963 100644 --- a/tests/unit/components/Charts/VictoryTheme.test.ts +++ b/tests/unit/components/Charts/VictoryTheme.test.ts @@ -1,3 +1,4 @@ +import {CHART_FONT_FAMILY_NAMES} from '@components/Charts/utils/chartFontConstants'; import type VictoryThemeType from '@components/Charts/VictoryTheme'; import colors from '@styles/theme/colors'; @@ -108,7 +109,7 @@ describe('VictoryTheme', () => { describe('static configuration values', () => { it('exposes the expected font families in order', () => { const VictoryTheme = loadVictoryTheme(); - expect(VictoryTheme.fontFamilies).toEqual(['ExpensifyNeue', 'NotoSansSymbols', 'NotoSansSCMonths']); + expect(VictoryTheme.fontFamilies).toEqual([...CHART_FONT_FAMILY_NAMES]); }); it('exposes axis values', () => { diff --git a/tests/unit/getChartSkiaTypefaceTest.ts b/tests/unit/getChartSkiaTypefaceTest.ts new file mode 100644 index 000000000000..c627d98a480f --- /dev/null +++ b/tests/unit/getChartSkiaTypefaceTest.ts @@ -0,0 +1,53 @@ +import type {SkTypeface} from '@shopify/react-native-skia'; +import type {ChartDefaultTypeface, ChartSkiaTypefaceKey} from '@components/Charts/types/chartSkiaTypefaceTypes'; +import {CHART_SKIA_TYPEFACE_ASSETS} from '@components/Charts/utils/chartFontAssets'; +import getChartSkiaTypeface from '@components/Charts/utils/getChartSkiaTypeface'; + +const CHART_SKIA_TYPEFACE_KEYS = Object.keys(CHART_SKIA_TYPEFACE_ASSETS) as ChartSkiaTypefaceKey[]; + +function makeTypefaces(): ChartDefaultTypeface { + return Object.fromEntries(CHART_SKIA_TYPEFACE_KEYS.map((key) => [key, {id: key} as unknown as SkTypeface])) as ChartDefaultTypeface; +} + +describe('getChartSkiaTypeface', () => { + const typefaces = makeTypefaces(); + + it('should resolve numeric bold weight to the bold typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontWeight: 700}); + expect(typeface).toBe(typefaces.EXP_NEUE_BOLD); + }); + + it('should resolve string bold weight to the bold typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontWeight: 'bold'}); + expect(typeface).toBe(typefaces.EXP_NEUE_BOLD); + }); + + it('should resolve normal weight to the regular typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontWeight: 400}); + expect(typeface).toBe(typefaces.EXP_NEUE); + }); + + it('should resolve semibold numeric weight to the bold typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontWeight: 600}); + expect(typeface).toBe(typefaces.EXP_NEUE_BOLD); + }); + + it('should resolve medium numeric weight to the regular typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontWeight: 500}); + expect(typeface).toBe(typefaces.EXP_NEUE); + }); + + it('should resolve Expensify New Kansas by font family', () => { + const typeface = getChartSkiaTypeface(typefaces, {fontFamily: 'Expensify New Kansas'}); + expect(typeface).toBe(typefaces.EXP_NEW_KANSAS_MEDIUM); + }); + + it('should resolve italic Expensify Neue bold to the bold italic typeface', () => { + const typeface = getChartSkiaTypeface(typefaces, { + fontFamily: 'Expensify Neue', + fontStyle: 'italic', + fontWeight: 'bold', + }); + expect(typeface).toBe(typefaces.EXP_NEUE_BOLD_ITALIC); + }); +});