diff --git a/src/components/Charts/VictoryTheme.ts b/src/components/Charts/VictoryTheme.ts index 78c83379b0d0..d6c9b50b6d8a 100644 --- a/src/components/Charts/VictoryTheme.ts +++ b/src/components/Charts/VictoryTheme.ts @@ -4,7 +4,7 @@ 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']; +const CHART_FONT_FAMILIES = ['ExpensifyNeue', 'ExpensifyNewKansas', 'NotoSansSymbols', 'NotoSansSCMonths']; /** * Expensify Chart Color Palette. diff --git a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts index 04cd0344d420..384aa02f6afa 100644 --- a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts +++ b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.native.ts @@ -9,6 +9,7 @@ function useChartFontManager(): SkTypefaceFontProvider | null { require('@assets/fonts/native/ExpensifyNeue-Italic.otf') as DataModule, require('@assets/fonts/native/ExpensifyNeue-BoldItalic.otf') as DataModule, ], + ExpensifyNewKansas: [require('@assets/fonts/native/ExpensifyNewKansas-Medium.otf') as DataModule, require('@assets/fonts/native/ExpensifyNewKansas-MediumItalic.otf') as DataModule], NotoSansSymbols: [require('@assets/fonts/NotoSans-Symbols.ttf') as DataModule], NotoSansSCMonths: [require('@assets/fonts/NotoSansSC-Months.ttf') as DataModule], }); @@ -17,7 +18,10 @@ function useChartFontManager(): SkTypefaceFontProvider | null { 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}; + const newKansas = useTypeface(require('@assets/fonts/native/ExpensifyNewKansas-Medium.otf') as DataModule); + const newKansasItalic = useTypeface(require('@assets/fonts/native/ExpensifyNewKansas-MediumItalic.otf') as DataModule); + + return {regular, bold, newKansas, newKansasItalic}; } export {useChartDefaultTypeface}; diff --git a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts index e0c8a8305fed..6f1a38c8c459 100644 --- a/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts +++ b/src/components/Charts/hooks/useChartFontManager/useChartFontManager.ts @@ -17,6 +17,10 @@ function useChartFontManager(): SkTypefaceFontProvider | null { webFont(require('@assets/fonts/web/ExpensifyNeue-Italic.woff2') as string), webFont(require('@assets/fonts/web/ExpensifyNeue-BoldItalic.woff2') as string), ], + ExpensifyNewKansas: [ + webFont(require('@assets/fonts/web/ExpensifyNewKansas-Medium.woff2') as string), + webFont(require('@assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2') as string), + ], NotoSansSymbols: [webFont(require('@assets/fonts/NotoSans-Symbols.ttf') as string)], NotoSansSCMonths: [webFont(require('@assets/fonts/NotoSansSC-Months.ttf') as string)], }); @@ -25,7 +29,10 @@ function useChartFontManager(): SkTypefaceFontProvider | null { 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}; + const newKansas = useTypeface(webFont(require('@assets/fonts/web/ExpensifyNewKansas-Medium.woff2') as string)); + const newKansasItalic = useTypeface(webFont(require('@assets/fonts/web/ExpensifyNewKansas-MediumItalic.woff2') as string)); + + return {regular, bold, newKansas, newKansasItalic}; } export {useChartDefaultTypeface}; diff --git a/src/components/Charts/types.ts b/src/components/Charts/types.ts index 2ed86d19ec06..964c1e34b17f 100644 --- a/src/components/Charts/types.ts +++ b/src/components/Charts/types.ts @@ -1,4 +1,4 @@ -import type {SkParagraph} from '@shopify/react-native-skia'; +import type {SkParagraph, SkTypeface} from '@shopify/react-native-skia'; import type {ValueOf} from 'type-fest'; import type {LABEL_ROTATIONS} from './VictoryTheme'; @@ -71,4 +71,17 @@ type LabelRotation = ValueOf; type ParagraphWithWidth = {para: SkParagraph | null; width: number}; -export type {ChartDataPoint, ChartProps, CartesianChartProps, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback}; +type ChartLabelFontStyle = { + fontFamily?: string; + fontWeight?: string | number; + fontStyle?: string; +}; + +type ChartTypefaces = { + regular: SkTypeface | null; + bold: SkTypeface | null; + newKansas: SkTypeface | null; + newKansasItalic: SkTypeface | null; +}; + +export type {ChartDataPoint, ChartProps, CartesianChartProps, ChartLabelFontStyle, ChartTypefaces, LabelRotation, ParagraphWithWidth, PieSlice, UnitPosition, UnitWithFallback}; diff --git a/src/components/Charts/utils/getChartLabelTypeface.ts b/src/components/Charts/utils/getChartLabelTypeface.ts new file mode 100644 index 000000000000..61f82cc04a53 --- /dev/null +++ b/src/components/Charts/utils/getChartLabelTypeface.ts @@ -0,0 +1,60 @@ +import type {SkTypeface} from '@shopify/react-native-skia'; +import type {ChartLabelFontStyle, ChartTypefaces} from '@components/Charts/types'; + +/** + * Normalizes Victory/HTML font family names to Skia-registered family keys. + */ +function normalizeChartFontFamily(fontFamily?: string): 'ExpensifyNeue' | 'ExpensifyNewKansas' | undefined { + if (!fontFamily) { + return undefined; + } + + const normalized = fontFamily.replace(/\s+/g, '').toLowerCase(); + + if (normalized === 'expensifynewkansas') { + return 'ExpensifyNewKansas'; + } + + if (normalized === 'expensifyneue') { + return 'ExpensifyNeue'; + } + + return undefined; +} + +/** + * Returns true when a label style should use the bold chart typeface. + */ +function isBoldFontWeight(fontWeight?: string | number): boolean { + if (fontWeight === 'bold') { + return true; + } + + const numericWeight = Number(fontWeight); + return Number.isFinite(numericWeight) && numericWeight >= 700; +} + +/** + * Resolves the Skia typeface for a chart label style. + */ +function getChartLabelTypeface(typefaces: ChartTypefaces, style?: ChartLabelFontStyle): SkTypeface | null { + const fontFamily = normalizeChartFontFamily(style?.fontFamily); + const isBold = isBoldFontWeight(style?.fontWeight); + const isItalic = style?.fontStyle === 'italic'; + + if (fontFamily === 'ExpensifyNewKansas') { + if (isItalic) { + return typefaces.newKansasItalic ?? typefaces.newKansas ?? typefaces.regular; + } + + return typefaces.newKansas ?? typefaces.regular; + } + + if (isBold) { + return typefaces.bold ?? typefaces.regular; + } + + return typefaces.regular; +} + +export {getChartLabelTypeface, isBoldFontWeight, normalizeChartFontFamily}; diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 9b1f38d1196a..d525fb231b45 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -228,6 +228,10 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim tagName: 'victorygroup', contentModel: HTMLContentModel.block, }), + victorypie: HTMLElementModel.fromCustomModel({ + tagName: 'victorypie', + contentModel: HTMLContentModel.block, + }), }), [ styles.taskTitleMenuItem, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabels.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabels.tsx index 6e4aaf831c0a..3c3f9a8f95cd 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabels.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLabels.tsx @@ -1,7 +1,7 @@ -import {Skia, Text as SkText} from '@shopify/react-native-skia'; import React from 'react'; import {useChartDefaultTypeface} from '@components/Charts/hooks'; import type {LabelItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import renderVictoryChartLabelElements from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderVictoryChartLabelElements'; type VictoryChartLabelsProps = { labelItems: LabelItem[]; @@ -9,28 +9,11 @@ type VictoryChartLabelsProps = { /** * Renders floating Skia text labels (from `` nodes) over the chart canvas. - * Intended for use inside CartesianChart's `renderOutside` callback. */ function VictoryChartLabels({labelItems}: VictoryChartLabelsProps) { - const {regular: regularTypeface, bold: boldTypeface} = useChartDefaultTypeface(); - return ( - <> - {labelItems.map(({x, y, text, color, fontSize, fontWeight}) => { - const typeface = fontWeight === 'bold' ? boldTypeface : regularTypeface; - const font = typeface ? Skia.Font(typeface, fontSize) : null; - return ( - - ); - })} - - ); + const typefaces = useChartDefaultTypeface(); + + return renderVictoryChartLabelElements({labelItems, typefaces}); } VictoryChartLabels.displayName = 'VictoryChartLabels'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index 72a8edd94690..3464c80a2216 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -1,14 +1,101 @@ -import {useEffect} from 'react'; -import Log from '@libs/Log'; +import {Canvas, Group} from '@shopify/react-native-skia'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; +import {StyleSheet, View} from 'react-native'; +import {Pie, PolarChart} from 'victory-native'; +import {useChartDefaultTypeface} from '@components/Charts/hooks'; +import {POLAR_COLOR_KEY, POLAR_LABEL_KEY, POLAR_VALUE_KEY} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {getPieChartPadAngleLayout} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout'; +import renderPieSliceAngularInsetElements from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceAngularInsetElements'; +import renderAllPieSliceLabelElements from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceLabelElements'; +import renderVictoryChartLabelElements from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderVictoryChartLabelElements'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + chart: { + flex: 1, + }, + labelOverlay: StyleSheet.absoluteFill, +}); /** - * Renders the PolarChart with data drawn from context. + * Renders the PolarChart with pie data drawn from context. */ function VictoryChartPolar() { - useEffect(() => Log.warn('Trying to render unsupported polar charts'), []); + const {polarData, pieConfig, labelItems, chartContentStyles} = useVictoryChartContext(); + const typefaces = useChartDefaultTypeface(); + const [canvasSize, setCanvasSize] = useState(() => ({ + width: typeof chartContentStyles.width === 'number' ? chartContentStyles.width : 0, + height: typeof chartContentStyles.height === 'number' ? chartContentStyles.height : 0, + })); + + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const {width, height} = event.nativeEvent.layout; + setCanvasSize({width, height}); + }, []); + + const overlayLabelElements = useMemo(() => { + if (!pieConfig || polarData.length === 0 || canvasSize.width <= 0 || canvasSize.height <= 0) { + return []; + } + + const floatingLabels = renderVictoryChartLabelElements({labelItems, typefaces}); + const sliceLabels = renderAllPieSliceLabelElements({ + polarData, + pieConfig, + canvasWidth: canvasSize.width, + canvasHeight: canvasSize.height, + typefaces, + }); + const angularInsets = renderPieSliceAngularInsetElements({ + polarData, + pieConfig, + canvasWidth: canvasSize.width, + canvasHeight: canvasSize.height, + }); + + return [...floatingLabels, ...sliceLabels, ...angularInsets]; + }, [canvasSize.height, canvasSize.width, labelItems, pieConfig, polarData, typefaces]); + + if (!pieConfig || polarData.length === 0) { + return null; + } + + const pieSize = pieConfig.radius ? pieConfig.radius * 2 : undefined; + const {circleSweepDegrees, startAngle} = getPieChartPadAngleLayout(pieConfig.padAngle, polarData.length); - // Support for polar chars will be added in a follow up https://github.com/Expensify/App/issues/90546 - return null; + return ( + + + + + {canvasSize.width > 0 && canvasSize.height > 0 && ( + + {overlayLabelElements} + + )} + + ); } VictoryChartPolar.displayName = 'VictoryChartPolar'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants.ts index 67cd247e8c6f..1a96e7c158db 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants.ts @@ -1,9 +1,13 @@ const X_KEY = 'x'; const Y_KEY_PREFIX = 'y'; +const POLAR_LABEL_KEY = 'label'; +const POLAR_VALUE_KEY = 'y'; +const POLAR_COLOR_KEY = 'color'; + const CHART_TYPE = { CARTESIAN: 'cartesian', POLAR: 'polar', } as const; -export {X_KEY, Y_KEY_PREFIX, CHART_TYPE}; +export {X_KEY, Y_KEY_PREFIX, POLAR_LABEL_KEY, POLAR_VALUE_KEY, POLAR_COLOR_KEY, CHART_TYPE}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index 329e8f690e12..3e68ed66658f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -13,6 +13,8 @@ type VictoryChartContextValue = { yKeys: ProcessNodeResult['yKeys']; xAxis: ProcessNodeResult['xAxis']; yAxis: ProcessNodeResult['yAxis']; + polarData: ProcessNodeResult['polarData']; + pieConfig: ProcessNodeResult['pieConfig']; labelItems: ProcessNodeResult['labelItems']; legendItems: ProcessNodeResult['legendItems']; chartContentStyles: ReturnType['nodeStyles']; @@ -28,11 +30,11 @@ const VictoryChartContext = createContext(null) */ function VictoryChartProvider({tnode, children}: {tnode: TNode; children: React.ReactNode}) { const {regular: regularTypeface} = useChartDefaultTypeface(); - const {data, xKey, yKeys, xAxis, yAxis, labelItems, legendItems} = processVictoryChartTree(tnode, regularTypeface); + const {data, xKey, yKeys, xAxis, yAxis, polarData, pieConfig, labelItems, legendItems} = processVictoryChartTree(tnode, regularTypeface); const {nodeStyles: chartContentStyles, parentNodeStyles: chartContainerStyles} = parseStyles(tnode); const hasCartesianData = Object.keys(data).length > 0; - const hasPolarData = false; + const hasPolarData = polarData.length > 0; let type: ChartType | null = null; // XNOR Check. There must be one and only one valid chart @@ -55,6 +57,8 @@ function VictoryChartProvider({tnode, children}: {tnode: TNode; children: React. yKeys, xAxis, yAxis, + polarData, + pieConfig, labelItems, legendItems, chartContentStyles, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/parserRegistry.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/parserRegistry.ts index d20acff2a2cc..465095bd3d69 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/parserRegistry.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/parserRegistry.ts @@ -2,6 +2,7 @@ import type {NodeParser} from '@components/HTMLEngineProvider/HTMLRenderers/Vict import parseVictoryAxisNode from './victoryAxisParser'; import parseVictoryLabelNode from './victoryLabelParser'; import parseVictoryLegendNode from './victoryLegendParser'; +import parseVictoryPieNode from './victoryPieParser'; import parseVictorySeriesNode from './victorySeriesParser'; /** @@ -14,6 +15,7 @@ const PARSER_REGISTRY: Partial> = { victoryaxis: parseVictoryAxisNode, victorylabel: parseVictoryLabelNode, victorylegend: parseVictoryLegendNode, + victorypie: parseVictoryPieNode, }; export default PARSER_REGISTRY; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts index a5f1762d94e8..0fc5139f6ca2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/processVictoryChartTree.ts @@ -16,6 +16,8 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null): Pro let yAxis: ProcessNodeResult['yAxis']; const labelItems: ProcessNodeResult['labelItems'] = []; const legendItems: ProcessNodeResult['legendItems'] = []; + let polarData: ProcessNodeResult['polarData'] = []; + let pieConfig: ProcessNodeResult['pieConfig']; const parser = PARSER_REGISTRY[tnode.tagName ?? '']; if (parser) { @@ -32,6 +34,12 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null): Pro if (result.yAxis?.length) { yAxis = [...(yAxis ?? []), ...result.yAxis]; } + if (result.polarData?.length) { + polarData = result.polarData; + } + if (result.pieConfig) { + pieConfig = result.pieConfig; + } if (result.labelItems) { labelItems.push(...result.labelItems); } @@ -50,11 +58,17 @@ function processVictoryChartTree(tnode: TNode, typeface: SkTypeface | null): Pro if (childResult.yAxis?.length) { yAxis = [...(yAxis ?? []), ...childResult.yAxis]; } + if (childResult.polarData.length) { + polarData = childResult.polarData; + } + if (childResult.pieConfig) { + pieConfig = childResult.pieConfig; + } labelItems.push(...childResult.labelItems); legendItems.push(...childResult.legendItems); } - return {data, xKey: X_KEY, yKeys, xAxis, yAxis, labelItems, legendItems}; + return {data, xKey: X_KEY, yKeys, xAxis, yAxis, polarData, pieConfig, labelItems, legendItems}; } export default processVictoryChartTree; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts index 37420261b889..91963ff794ea 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryLabelParser.ts @@ -1,6 +1,45 @@ import type {TNode} from 'react-native-render-html'; import type {LabelItem, PartialProcessNodeResult, RawLabelStyle} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; +import unescapeLabelText from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/unescapeLabelText'; + +type TextAnchor = NonNullable; +type VerticalAnchor = NonNullable; + +function parseTextAnchor(textAnchor: string | undefined): TextAnchor | undefined { + if (textAnchor === 'middle' || textAnchor === 'end') { + return textAnchor; + } + if (textAnchor === 'start') { + return 'start'; + } + return undefined; +} + +function parseVerticalAnchor(verticalAnchor: string | undefined): VerticalAnchor | undefined { + if (verticalAnchor === 'middle' || verticalAnchor === 'end') { + return verticalAnchor; + } + if (verticalAnchor === 'start') { + return 'start'; + } + return undefined; +} + +function parseLabelStyle(style: RawLabelStyle | RawLabelStyle[] | undefined): Pick { + if (Array.isArray(style)) { + return { + styles: style, + }; + } + + return { + color: style?.fill, + fontSize: style?.fontSize !== undefined ? Number(style.fontSize) : undefined, + fontWeight: Number(style?.fontWeight) === 700 ? 'bold' : undefined, + fontFamily: style?.fontFamily, + }; +} /** * Parse label config from a `` node. @@ -8,12 +47,21 @@ import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/Victory function parseVictoryLabelNode(tnode: TNode): PartialProcessNodeResult { const x = parseAttribute(tnode.attributes.x) ?? 0; const y = parseAttribute(tnode.attributes.y) ?? 0; - const text = parseAttribute(tnode.attributes.text) ?? ''; - const style = parseAttribute(tnode.attributes.style); - const color = style?.fill; - const fontSize = style?.fontSize !== undefined ? Number(style.fontSize) : undefined; - const fontWeight = Number(style?.fontWeight) === 700 ? 'bold' : undefined; - const labelItem: LabelItem = {x, y, text, color, fontSize, fontWeight}; + const text = unescapeLabelText(parseAttribute(tnode.attributes.text) ?? ''); + const style = parseAttribute(tnode.attributes.style); + const lineHeight = parseAttribute(tnode.attributes.lineheight); + const textAnchor = parseTextAnchor(parseAttribute(tnode.attributes.textanchor)); + const verticalAnchor = parseVerticalAnchor(parseAttribute(tnode.attributes.verticalanchor)); + const parsedStyle = parseLabelStyle(style); + const labelItem: LabelItem = { + x, + y, + text, + lineHeight, + textAnchor, + verticalAnchor, + ...parsedStyle, + }; return {labelItems: [labelItem]}; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryPieParser.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryPieParser.ts new file mode 100644 index 000000000000..0ecfa500d8b7 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/parsers/victoryPieParser.ts @@ -0,0 +1,85 @@ +import type {Color} from '@shopify/react-native-skia'; +import type {TNode} from 'react-native-render-html'; +import type {PartialProcessNodeResult, PieChartConfig, PolarChartDatum, RawChartData, RawLabelStyle} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import parseAttribute from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; +import parseColorScale, {getPieSliceColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseColorScale'; +import parseEmbeddedComponentAttributes from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseEmbeddedComponent'; +import unescapeLabelText from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/unescapeLabelText'; + +type PieSliceStyle = { + data?: { + stroke?: Color; + strokeWidth?: string | number; + }; +}; + +/** + * Parse label component config from a `` embedded in the `labelcomponent` attribute. + */ +function parseLabelComponent(labelComponent: string | undefined): Pick { + if (!labelComponent) { + return {}; + } + + const attributes = parseEmbeddedComponentAttributes(labelComponent); + const lineHeights = parseAttribute(attributes.lineheight); + const styles = parseAttribute(attributes.style); + + return { + labelComponentLineHeights: lineHeights, + labelComponentStyles: styles, + }; +} + +/** + * Parse label indicator config from a `` embedded in the `labelindicator` attribute. + */ +function parseLabelIndicator(labelIndicator: string | undefined): Pick { + if (!labelIndicator) { + return {}; + } + + const attributes = parseEmbeddedComponentAttributes(labelIndicator); + const style = parseAttribute<{stroke?: Color; strokeWidth?: string | number}>(attributes.style); + + return { + labelIndicatorDy: parseAttribute(attributes.dy), + labelIndicatorStroke: style?.stroke, + labelIndicatorStrokeWidth: style?.strokeWidth !== undefined ? Number(style.strokeWidth) : undefined, + }; +} + +/** + * Parse data and styling config from a `` node. + */ +function parseVictoryPieNode(tnode: TNode): PartialProcessNodeResult { + const points = parseAttribute(tnode.attributes.data) ?? []; + const labels = parseAttribute(tnode.attributes.labels) ?? []; + const colorScale = parseColorScale(tnode.attributes.colorscale); + const style = parseAttribute(tnode.attributes.style); + + const polarData: PolarChartDatum[] = points.map((point, index) => ({ + x: String(point.x), + y: point.y, + label: unescapeLabelText(labels.at(index) ?? String(point.x)), + color: getPieSliceColor(colorScale, index), + })); + + const pieConfig: PieChartConfig = { + innerRadius: parseAttribute(tnode.attributes.innerradius) ?? 0, + radius: parseAttribute(tnode.attributes.radius), + padAngle: parseAttribute(tnode.attributes.padangle) ?? 0, + labelRadius: parseAttribute(tnode.attributes.labelradius), + colorScale, + strokeColor: style?.data?.stroke, + strokeWidth: style?.data?.strokeWidth !== undefined ? Number(style.data.strokeWidth) : undefined, + labelIndicatorInnerOffset: parseAttribute(tnode.attributes.labelindicatorinneroffset), + labelIndicatorOuterOffset: parseAttribute(tnode.attributes.labelindicatorouteroffset), + ...parseLabelComponent(tnode.attributes.labelcomponent), + ...parseLabelIndicator(tnode.attributes.labelindicator), + }; + + return {polarData, pieConfig}; +} + +export default parseVictoryPieNode; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types.ts index 7fb426a6ef1c..0d150121df65 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 = { @@ -72,6 +74,45 @@ type LabelItem = { /** Font weight */ fontWeight?: 'normal' | 'bold'; + + /** Font family */ + fontFamily?: string; + + /** Horizontal text alignment relative to x */ + textAnchor?: 'start' | 'middle' | 'end'; + + /** Vertical text alignment relative to y */ + verticalAnchor?: 'start' | 'middle' | 'end'; + + /** Per-line line-height multipliers for multi-line labels */ + lineHeight?: number[]; + + /** Per-line styles for multi-line labels */ + styles?: RawLabelStyle[]; +}; + +type PolarChartDatum = { + x: string; + y: number; + label: string; + color: Color; +}; + +type PieChartConfig = { + innerRadius: number; + radius?: number; + padAngle: number; + labelRadius?: number; + colorScale: Color[]; + strokeColor?: Color; + strokeWidth?: number; + labelIndicatorInnerOffset?: number; + labelIndicatorOuterOffset?: number; + labelIndicatorDy?: number; + labelIndicatorStroke?: Color; + labelIndicatorStrokeWidth?: number; + labelComponentLineHeights?: number[]; + labelComponentStyles?: RawLabelStyle[]; }; type LegendItemEntry = { @@ -121,6 +162,8 @@ type ProcessNodeResult = { yKeys: YKey[]; xAxis: CartesianChartProps['xAxis']; yAxis: CartesianChartProps['yAxis']; + polarData: PolarChartDatum[]; + pieConfig?: PieChartConfig; labelItems: LabelItem[]; legendItems: LegendItem[]; }; @@ -146,6 +189,8 @@ export type { LabelItem, LegendItemEntry, LegendItem, + PolarChartDatum, + PieChartConfig, ProcessNodeResult, PartialProcessNodeResult, NodeParser, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries.ts new file mode 100644 index 000000000000..a45aba2b9172 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries.ts @@ -0,0 +1,49 @@ +import type {PieChartConfig, PolarChartDatum} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import {getPieChartPadAngleLayout} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout'; + +type PieSliceGeometry = { + label: string; + center: {x: number; y: number}; + radius: number; + startAngle: number; + endAngle: number; +}; + +/** + * Computes pie slice geometry to match victory-native's PieChart layout, including `padAngle`. + */ +function computePieSliceGeometries(polarData: PolarChartDatum[], pieConfig: PieChartConfig, canvasWidth: number, canvasHeight: number): PieSliceGeometry[] { + if (polarData.length === 0 || canvasWidth <= 0 || canvasHeight <= 0) { + return []; + } + + const pieSize = pieConfig.radius ? pieConfig.radius * 2 : Math.min(canvasWidth, canvasHeight); + const radius = pieSize / 2; + const center = {x: canvasWidth / 2, y: canvasHeight / 2}; + const totalValue = polarData.reduce((sum, datum) => sum + datum.y, 0); + + if (totalValue <= 0) { + return []; + } + + const {circleSweepDegrees, startAngle} = getPieChartPadAngleLayout(pieConfig.padAngle, polarData.length); + let currentAngle = startAngle; + + return polarData.map((datum) => { + const sweepAngle = (datum.y / totalValue) * circleSweepDegrees; + const sliceStartAngle = currentAngle; + const sliceEndAngle = currentAngle + sweepAngle; + currentAngle = sliceEndAngle; + + return { + label: datum.label, + center, + radius, + startAngle: sliceStartAngle, + endAngle: sliceEndAngle, + }; + }); +} + +export type {PieSliceGeometry}; +export default computePieSliceGeometries; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout.ts new file mode 100644 index 000000000000..aa43ccc1b39a --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout.ts @@ -0,0 +1,36 @@ +const CIRCLE_SWEEP_DEGREES = 360; + +type PieChartPadAngleLayout = { + circleSweepDegrees: number; + startAngle: number; + padAngle: number; +}; + +/** + * Maps Victory `padAngle` (degrees of separation per slice) to victory-native `Pie.Chart` props. + */ +function getPieChartPadAngleLayout(padAngle: number, sliceCount: number): PieChartPadAngleLayout { + if (padAngle <= 0 || sliceCount === 0) { + return { + circleSweepDegrees: CIRCLE_SWEEP_DEGREES, + startAngle: 0, + padAngle: 0, + }; + } + + return { + circleSweepDegrees: CIRCLE_SWEEP_DEGREES - padAngle * sliceCount, + startAngle: padAngle / 2, + padAngle, + }; +} + +/** + * Converts a pad angle in degrees to a stroke width at the given pie radius. + */ +function getAngularInsetStrokeWidth(padAngleDegrees: number, radius: number): number { + return ((padAngleDegrees * Math.PI) / 180) * radius; +} + +export {getPieChartPadAngleLayout, getAngularInsetStrokeWidth}; +export type {PieChartPadAngleLayout}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieLabelIndicatorGeometry.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieLabelIndicatorGeometry.ts new file mode 100644 index 000000000000..401d19404e2d --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieLabelIndicatorGeometry.ts @@ -0,0 +1,90 @@ +import type {PieChartConfig} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +type Point = { + x: number; + y: number; +}; + +type PieLabelIndicatorGeometry = { + start: Point; + elbow: Point; + label: Point; +}; + +/** + * Computes label indicator line geometry to match VictoryPie's LineSegment / ShiftedLineSegment layout. + * + * @see victory-pie getLabelIndicatorPropsForLineSegment + * - inner offset starts at average(innerRadius, outerRadius) + labelIndicatorInnerOffset + * - outer offset ends at labelRadius - labelIndicatorOuterOffset + * - optional dy shifts the outer elbow point perpendicular to the radial direction + */ +function getPieLabelIndicatorGeometry({ + center, + innerRadius, + outerRadius, + labelRadius, + labelX, + labelY, + pieConfig, +}: { + center: Point; + innerRadius: number; + outerRadius: number; + labelRadius: number; + labelX: number; + labelY: number; + pieConfig: PieChartConfig; +}): PieLabelIndicatorGeometry | null { + const labelIndicatorInnerOffset = pieConfig.labelIndicatorInnerOffset; + const labelIndicatorOuterOffset = pieConfig.labelIndicatorOuterOffset; + + if (labelIndicatorInnerOffset === undefined && labelIndicatorOuterOffset === undefined && pieConfig.labelIndicatorStroke === undefined) { + return null; + } + + const middleRadius = (innerRadius + outerRadius) / 2; + const startRadius = middleRadius + (labelIndicatorInnerOffset ?? 0); + const endRadius = labelRadius - (labelIndicatorOuterOffset ?? 0); + + const deltaX = labelX - center.x; + const deltaY = labelY - center.y; + const distanceToLabel = Math.hypot(deltaX, deltaY); + + if (distanceToLabel <= 0) { + return null; + } + + const unitX = deltaX / distanceToLabel; + const unitY = deltaY / distanceToLabel; + + const start = { + x: center.x + unitX * startRadius, + y: center.y + unitY * startRadius, + }; + + const elbow = { + x: center.x + unitX * endRadius, + y: center.y + unitY * endRadius, + }; + + const shiftDy = pieConfig.labelIndicatorDy ?? 0; + + if (shiftDy !== 0) { + // ShiftedLineSegment applies dy perpendicular to the radial segment. + elbow.x += -unitY * shiftDy; + elbow.y += unitX * shiftDy; + } + + return { + start, + elbow, + label: { + x: labelX, + y: labelY, + }, + }; +} + +export type {PieLabelIndicatorGeometry, Point}; +export default getPieLabelIndicatorGeometry; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseColorScale.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseColorScale.ts new file mode 100644 index 000000000000..4a3aa3f733e2 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseColorScale.ts @@ -0,0 +1,63 @@ +import type {Color} from '@shopify/react-native-skia'; +import JSON5 from 'json5'; +import parseAttribute from './parseAttribute'; + +/** + * Returns true when a value is a usable color string. + */ +function isValidColor(color: unknown): color is Color { + return typeof color === 'string' && color.trim().length > 0 && color.trim() !== 'transparent'; +} + +/** + * Parses a Victory `colorscale` attribute into a color array. + * Preserves array indices so each slice maps to its corresponding scale entry. + */ +function parseColorScale(attribute: string | undefined): Color[] { + if (!attribute) { + return []; + } + + const parsed = parseAttribute(attribute); + + if (Array.isArray(parsed)) { + return parsed; + } + + if (typeof parsed === 'string') { + try { + const reparsed = JSON5.parse(parsed); + if (Array.isArray(reparsed)) { + return reparsed; + } + } catch { + return []; + } + } + + return []; +} + +/** + * Returns the slice fill color from the Victory `colorscale` prop. + */ +function getPieSliceColor(colorScale: Color[], index: number): Color { + const directColor = colorScale.at(index); + + if (isValidColor(directColor)) { + return directColor; + } + + if (colorScale.length > 0) { + const wrappedColor = colorScale.at(index % colorScale.length); + + if (isValidColor(wrappedColor)) { + return wrappedColor; + } + } + + return '#000000'; +} + +export {getPieSliceColor, isValidColor}; +export default parseColorScale; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseEmbeddedComponent.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseEmbeddedComponent.ts new file mode 100644 index 000000000000..14d1c7e11855 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseEmbeddedComponent.ts @@ -0,0 +1,16 @@ +/** + * Extract attribute key/value pairs from an embedded HTML component string. + * Example: `` + */ +function parseEmbeddedComponentAttributes(componentString: string): Record { + const attributes: Record = {}; + const attributePattern = /([\w-]+)="([^"]*)"/g; + let match = attributePattern.exec(componentString); + while (match !== null) { + attributes[match[1]] = match[2]; + match = attributePattern.exec(componentString); + } + return attributes; +} + +export default parseEmbeddedComponentAttributes; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceAngularInsetElements.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceAngularInsetElements.tsx new file mode 100644 index 000000000000..75357e5036f3 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceAngularInsetElements.tsx @@ -0,0 +1,85 @@ +import {PaintStyle, Path, Skia} from '@shopify/react-native-skia'; +import React from 'react'; +import type {PieChartConfig, PolarChartDatum} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import computePieSliceGeometries from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries'; +import {getAngularInsetStrokeWidth} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieChartPadAngleLayout'; + +const RADIAN = Math.PI / 180; + +type RenderPieSliceAngularInsetElementsParams = { + polarData: PolarChartDatum[]; + pieConfig: PieChartConfig; + canvasWidth: number; + canvasHeight: number; +}; + +function translateInnerRadius(innerRadius: number, radius: number): number { + return innerRadius >= radius ? 0 : innerRadius; +} + +function pointOnCircumference(center: {x: number; y: number}, circumferenceRadius: number, angleRadians: number) { + return { + x: center.x + circumferenceRadius * Math.cos(angleRadians), + y: center.y + circumferenceRadius * Math.sin(angleRadians), + }; +} + +function createAngularInsetPath(center: {x: number; y: number}, radius: number, innerRadius: number, startAngle: number, endAngle: number) { + const path = Skia.Path.Make(); + const startRadians = startAngle * RADIAN; + const endRadians = endAngle * RADIAN; + + if (innerRadius > 0) { + const startInner = pointOnCircumference(center, innerRadius, startRadians); + const startOuter = pointOnCircumference(center, radius, startRadians); + const endInner = pointOnCircumference(center, innerRadius, endRadians); + const endOuter = pointOnCircumference(center, radius, endRadians); + + path.moveTo(startInner.x, startInner.y); + path.lineTo(startOuter.x, startOuter.y); + path.moveTo(endInner.x, endInner.y); + path.lineTo(endOuter.x, endOuter.y); + } else { + const startPoint = pointOnCircumference(center, radius, startRadians); + const endPoint = pointOnCircumference(center, radius, endRadians); + + path.moveTo(center.x, center.y); + path.lineTo(startPoint.x, startPoint.y); + path.moveTo(center.x, center.y); + path.lineTo(endPoint.x, endPoint.y); + } + + return path; +} + +/** + * Renders pad-angle separators in the label overlay canvas. + */ +function renderPieSliceAngularInsetElements({polarData, pieConfig, canvasWidth, canvasHeight}: RenderPieSliceAngularInsetElementsParams): React.ReactElement[] { + if (pieConfig.padAngle <= 0) { + return []; + } + + const slices = computePieSliceGeometries(polarData, pieConfig, canvasWidth, canvasHeight); + const padColor = pieConfig.strokeColor ?? '#FFFFFF'; + const strokeWidth = getAngularInsetStrokeWidth(pieConfig.padAngle, slices.at(0)?.radius ?? 0); + const insetPaint = Skia.Paint(); + insetPaint.setColor(Skia.Color(padColor)); + insetPaint.setStyle(PaintStyle.Stroke); + insetPaint.setStrokeWidth(strokeWidth); + + return slices.flatMap((slice, index) => { + const innerRadius = translateInnerRadius(pieConfig.innerRadius, slice.radius); + const insetPath = createAngularInsetPath(slice.center, slice.radius, innerRadius, slice.startAngle, slice.endAngle); + + return [ + , + ]; + }); +} + +export default renderPieSliceAngularInsetElements; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceLabelElements.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceLabelElements.tsx new file mode 100644 index 000000000000..0a3b117696d8 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderPieSliceLabelElements.tsx @@ -0,0 +1,122 @@ +import {Line, Skia, Text as SkText} from '@shopify/react-native-skia'; +import React from 'react'; +import type {ChartTypefaces} from '@components/Charts/types'; +import {getChartLabelTypeface} from '@components/Charts/utils/getChartLabelTypeface'; +import type {PieChartConfig, PolarChartDatum} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import computePieSliceGeometries from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries'; +import type {PieSliceGeometry} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computePieSliceGeometries'; +import getPieLabelIndicatorGeometry from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getPieLabelIndicatorGeometry'; + +type RenderPieSliceLabelElementsParams = { + slice: PieSliceGeometry; + pieConfig: PieChartConfig; + typefaces: ChartTypefaces; + sliceIndex: number; +}; + +const RADIAN = Math.PI / 180; +const DEFAULT_LABEL_COLORS = ['#002E22', '#76847E']; + +function renderPieSliceLabelElements({slice, pieConfig, typefaces, sliceIndex}: RenderPieSliceLabelElementsParams): React.ReactElement[] { + const midAngle = (slice.startAngle + slice.endAngle) / 2; + const labelRadius = pieConfig.labelRadius ?? slice.radius * 1.2; + const labelX = slice.center.x + labelRadius * Math.cos(-midAngle * RADIAN); + const labelY = slice.center.y + labelRadius * Math.sin(midAngle * RADIAN); + const isLeftSide = midAngle > 90 && midAngle < 270; + const textAnchor = isLeftSide ? 'end' : 'start'; + + const lines = slice.label.split('\n'); + const lineHeights = pieConfig.labelComponentLineHeights ?? []; + const lineStyles = pieConfig.labelComponentStyles ?? []; + const elements: React.ReactElement[] = []; + const lineMetrics: Array<{width: number; height: number; fontSize: number}> = []; + + for (const [index, line] of lines.entries()) { + const style = lineStyles.at(index); + const fontSize = style?.fontSize !== undefined ? Number(style.fontSize) : 11; + const typeface = getChartLabelTypeface(typefaces, style); + const font = typeface ? Skia.Font(typeface, fontSize) : null; + const lineHeightMultiplier = lineHeights.at(index) ?? 1.2; + const lineHeight = fontSize * lineHeightMultiplier; + const width = font?.getGlyphWidths(font.getGlyphIDs(line)).reduce((acc, glyphWidth) => acc + glyphWidth, 0) ?? 0; + lineMetrics.push({width, height: lineHeight, fontSize}); + } + + const totalHeight = lineMetrics.reduce((acc, metric) => acc + metric.height, 0); + const currentY = labelY - totalHeight / 2; + const labelIndicator = getPieLabelIndicatorGeometry({ + center: slice.center, + innerRadius: pieConfig.innerRadius, + outerRadius: slice.radius, + labelRadius, + labelX, + labelY, + pieConfig, + }); + + if (labelIndicator) { + elements.push( + , + ); + } + + for (const [index, line] of lines.entries()) { + const style = lineStyles.at(index); + const fontSize = lineMetrics.at(index)?.fontSize ?? 11; + const typeface = getChartLabelTypeface(typefaces, style); + const font = typeface ? Skia.Font(typeface, fontSize) : null; + + if (font) { + const color = style?.fill ?? DEFAULT_LABEL_COLORS.at(index) ?? DEFAULT_LABEL_COLORS.at(0); + const lineWidth = lineMetrics.at(index)?.width ?? 0; + const lineOffset = lineMetrics.slice(0, index).reduce((acc, metric) => acc + metric.height, 0); + const textX = textAnchor === 'end' ? labelX - lineWidth : labelX; + + elements.push( + , + ); + } + } + + return elements; +} + +type RenderAllPieSliceLabelElementsParams = { + polarData: PolarChartDatum[]; + pieConfig: PieChartConfig; + canvasWidth: number; + canvasHeight: number; + typefaces: ChartTypefaces; +}; + +/** + * Renders all external pie slice labels in a Skia overlay canvas. + */ +function renderAllPieSliceLabelElements({polarData, pieConfig, canvasWidth, canvasHeight, typefaces}: RenderAllPieSliceLabelElementsParams): React.ReactElement[] { + const slices = computePieSliceGeometries(polarData, pieConfig, canvasWidth, canvasHeight); + + return slices.flatMap((slice, sliceIndex) => + renderPieSliceLabelElements({ + slice, + pieConfig, + typefaces, + sliceIndex, + }), + ); +} + +export {renderPieSliceLabelElements, renderAllPieSliceLabelElements}; +export default renderAllPieSliceLabelElements; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderVictoryChartLabelElements.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderVictoryChartLabelElements.tsx new file mode 100644 index 000000000000..3adb6447a1f7 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/renderVictoryChartLabelElements.tsx @@ -0,0 +1,124 @@ +import {Skia, Text as SkText} from '@shopify/react-native-skia'; +import React from 'react'; +import type {ChartTypefaces} from '@components/Charts/types'; +import {getChartLabelTypeface, isBoldFontWeight} from '@components/Charts/utils/getChartLabelTypeface'; +import type {LabelItem, RawLabelStyle} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +type RenderVictoryChartLabelElementsParams = { + labelItems: LabelItem[]; + typefaces: ChartTypefaces; +}; + +function getLineStyle(labelItem: LabelItem, lineIndex: number): Pick & Pick { + const style = labelItem.styles?.at(lineIndex); + if (!style) { + return { + color: labelItem.color, + fontSize: labelItem.fontSize, + fontWeight: labelItem.fontWeight, + fontFamily: labelItem.fontFamily, + }; + } + + return { + color: style.fill, + fontSize: style.fontSize !== undefined ? Number(style.fontSize) : labelItem.fontSize, + fontWeight: isBoldFontWeight(style.fontWeight) ? 'bold' : labelItem.fontWeight, + fontFamily: style.fontFamily ?? labelItem.fontFamily, + fontStyle: style.fontStyle, + }; +} + +function getTextX(anchorX: number, lineWidth: number, textAnchor: LabelItem['textAnchor']): number { + if (textAnchor === 'middle') { + return anchorX - lineWidth / 2; + } + if (textAnchor === 'end') { + return anchorX - lineWidth; + } + return anchorX; +} + +type LineLayout = { + text: string; + width: number; + height: number; + fontSize: number; + color?: RawLabelStyle['fill']; + fontFamily?: string; + fontStyle?: string; + fontWeight?: LabelItem['fontWeight']; +}; + +function buildLineLayouts(labelItem: LabelItem, typefaces: ChartTypefaces): LineLayout[] { + const lines = labelItem.text.split('\n'); + + return lines.map((line, index) => { + const {color, fontSize = 11, fontWeight, fontFamily, fontStyle} = getLineStyle(labelItem, index); + const typeface = getChartLabelTypeface(typefaces, {fontFamily, fontWeight, fontStyle}); + const font = typeface ? Skia.Font(typeface, fontSize) : null; + const lineHeightMultiplier = labelItem.lineHeight?.at(index) ?? 1.2; + const height = fontSize * lineHeightMultiplier; + const width = font?.getGlyphWidths(font.getGlyphIDs(line)).reduce((acc, glyphWidth) => acc + glyphWidth, 0) ?? 0; + + return { + text: line, + width, + height, + fontSize, + color, + fontFamily, + fontStyle, + fontWeight, + }; + }); +} + +/** + * Returns Skia Text elements for floating `` nodes. + * Kept as a plain element factory so labels render reliably inside Skia canvases. + */ +function renderVictoryChartLabelElements({labelItems, typefaces}: RenderVictoryChartLabelElementsParams): React.ReactElement[] { + return labelItems.flatMap((labelItem, labelIndex) => { + const {x, y, textAnchor, verticalAnchor} = labelItem; + const lineLayouts = buildLineLayouts(labelItem, typefaces); + const totalHeight = lineLayouts.reduce((acc, line) => acc + line.height, 0); + let currentY = y; + + if (verticalAnchor === 'middle') { + currentY = y - totalHeight / 2; + } else if (verticalAnchor === 'end') { + currentY = y - totalHeight; + } + + return lineLayouts.flatMap((line, index) => { + const typeface = getChartLabelTypeface(typefaces, { + fontFamily: line.fontFamily, + fontWeight: line.fontWeight, + fontStyle: line.fontStyle, + }); + const font = typeface ? Skia.Font(typeface, line.fontSize) : null; + + if (!font) { + return []; + } + + const textX = getTextX(x, line.width, textAnchor); + const lineOffset = lineLayouts.slice(0, index).reduce((acc, previousLine) => acc + previousLine.height, 0); + const textY = currentY + lineOffset + line.fontSize; + + return [ + , + ]; + }); + }); +} + +export default renderVictoryChartLabelElements; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/unescapeLabelText.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/unescapeLabelText.ts new file mode 100644 index 000000000000..a2f40fe1b772 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/unescapeLabelText.ts @@ -0,0 +1,8 @@ +/** + * Normalizes label text from HTML attributes, converting escaped newlines to real line breaks. + */ +function unescapeLabelText(text: string): string { + return text.replace(/\\n/g, '\n'); +} + +export default unescapeLabelText;