diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx index 44029eb5a7e3..356d30cb4cfa 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx @@ -14,17 +14,18 @@ import {Bar} from 'victory-native'; type VictoryChartBarProps = {tnode: TNode}; function VictoryChartBar({tnode}: VictoryChartBarProps) { - const {points, chartBounds} = useVictoryChartRenderArgs(); + const {points, chartBounds, pixelScale} = useVictoryChartRenderArgs(); const yKey = getYKey(tnode); const {nodeStyles} = parseStyles(tnode); + const barWidth = parseAttributeAsNumber(tnode.attributes.barwidth); return ( ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx index 02113b73b1f5..bed5e363d880 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx @@ -18,7 +18,7 @@ type VictoryChartBarGroupProps = { }; function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) { - const {points, chartBounds} = useVictoryChartRenderArgs(); + const {points, chartBounds, pixelScale} = useVictoryChartRenderArgs(); const barChildren = tnode.children.filter((child) => child.tagName === 'victorybar'); const firstBarChild = barChildren.at(0); @@ -26,10 +26,11 @@ function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) return null; } - const roundedCorners = parseCornerRadius(firstBarChild?.attributes?.cornerradius ?? ''); - const barWidth = parseAttributeAsNumber(firstBarChild.attributes.barwidth); + const roundedCorners = parseCornerRadius(firstBarChild?.attributes?.cornerradius ?? '', pixelScale); + const rawBarWidth = parseAttributeAsNumber(firstBarChild.attributes.barwidth); + const barWidth = rawBarWidth === undefined ? undefined : rawBarWidth * pixelScale; const betweenGroupPadding = barWidth - ? parseOffset(tnode.attributes.offset, chartBounds, barChildren.length, barWidth, points[getYKey(firstBarChild)].length, isHorizontal ?? false) + ? parseOffset(tnode.attributes.offset, chartBounds, barChildren.length, barWidth, points[getYKey(firstBarChild)].length, isHorizontal ?? false, pixelScale) : undefined; return ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index 336760a5f44c..dacd648af166 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -37,7 +37,7 @@ type VictoryChartCartesianProps = { * Labels and legend overlays are handled internally via `renderOutside`. */ function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { - const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); + const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); const designWidth = getChartDesignWidth(explicitSize, chartContentStyles.width); @@ -68,7 +68,7 @@ function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryCh {...getChartLayoutModeProps(explicitSize, headless)} renderOutside={(renderArgs) => { const overlayContent = ( - + {labelItems.map((labelItem) => ( + {tnode.children.map((child) => ( { + // Ignore layout changes while the modal is closing — re-measuring mid-animation + // would rescale the chart and cause a visible flicker. if (!isVisible) { return; } @@ -69,6 +76,22 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr // Uniform scale that fits the chart's (clipped) design box inside the available modal area (may be > 1). const scale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1; + // The fitted (displayed) size of the chart inside the modal. + const targetWidth = (designWidth ?? 0) * scale; + const targetHeight = (designHeight ?? 0) * scale; + const clippedTargetHeight = (effectiveDesignHeight ?? 0) * scale; + + // The chart's intrinsic render size: drawn larger than the fitted size (like a 2x image asset) + // so that pinch-zooming reveals native resolution instead of magnified raster pixels. + // Capped so the canvas never exceeds a safe texture size. + const MAX_CANVAS_DIMENSION = 2048; + // 2x headroom covers typical pinch-zoom depth without paying for a larger render surface. + const MAX_ZOOM_HEADROOM = 2; + const zoomHeadroom = Math.max(1, Math.min(MAX_ZOOM_HEADROOM, MAX_CANVAS_DIMENSION / Math.max(targetWidth, targetHeight, 1))); + const renderWidth = targetWidth * zoomHeadroom; + const renderHeight = targetHeight * zoomHeadroom; + const clippedRenderHeight = clippedTargetHeight * zoomHeadroom; + // Visual styles parsed from the chart HTML — resolved and applied the same way // VictoryChartContainerFixed does inline, so the expanded chart keeps the same // (theme-aware) background and rounding. @@ -94,7 +117,7 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr onBackButtonPress={onClose} onCloseButtonPress={onClose} /> - {/* Padding lives on the outer view; the inner view is measured so the scale never + {/* Padding lives on the outer view; the inner view is measured so the fit scale never exceeds the actual content area and the side gutters are preserved. */} {isMeasured && (hasDesignDimensions && effectiveDesignHeight !== undefined ? ( - // Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. - - {/* Fixed design-size box so the fluid chart renders at design size, then scaled uniformly. */} + {/* Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. */} - {/* The Skia canvas is removed as soon as closing starts: WebGL canvases can - flash white when re-composited during the close animation (visible on dark - themes). The card box stays so the modal animates out looking intact. */} - {isVisible && } + + {/* The Skia canvas is removed as soon as closing starts: WebGL canvases can + flash white when re-composited during the close animation (visible on dark + themes). The card box stays so the modal animates out looking intact. */} + {isVisible && ( + + + + )} + - + ) : ( // Charts without design dimensions have no design-based label coordinates, so fluid // rendering is safe. Background/rounding are still applied so the expanded chart diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx index 41964ca9be24..b70f8ab14ce6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx @@ -12,14 +12,14 @@ import {Line} from 'victory-native'; type VictoryChartLineProps = {tnode: TNode}; function VictoryChartLine({tnode}: VictoryChartLineProps) { - const {points} = useVictoryChartRenderArgs(); + const {points, pixelScale} = useVictoryChartRenderArgs(); const yKey = getYKey(tnode); const {nodeStyles} = parseStyles(tnode); return ( ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx index 5cc2f662b0c5..0a86e0458c36 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx @@ -14,6 +14,7 @@ import convertAngleToArcLength from '@components/HTMLEngineProvider/HTMLRenderer import {parseAttributeAsNumber, parseAttributeAsStringArray} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; import parseComponent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseComponent'; import resolveChartThemeColor from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; +import {scaleLabelItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; import useTheme from '@hooks/useTheme'; @@ -44,17 +45,23 @@ const LEFT_COLUMN_TOP_PADDING = 24; const EDGE_PADDING = 32; function VictoryChartPie({tnode}: VictoryChartPieProps) { - const {data, chartContainerStyles, chartContentStyles} = useVictoryChartContext(); + const {data, chartContainerStyles, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const typefaces = useChartTypefaces(); const renderEngine = useAmbientTRenderEngine(); const labelComponentNode = parseComponent(tnode.attributes.labelcomponent, renderEngine, 'victorylabel', HTMLContentModel.textual); - const baseLabelItem = labelComponentNode ? parseVictoryLabelNode(labelComponentNode).labelItems?.at(0) : undefined; + const rawBaseLabelItem = labelComponentNode ? parseVictoryLabelNode(labelComponentNode).labelItems?.at(0) : undefined; + // All pie geometry is parsed from raw pixel attributes, so it must follow the context's pixel + // scale for the expanded chart to render proportionally at its larger native size. + const baseLabelItem = rawBaseLabelItem && pixelScale !== 1 ? scaleLabelItem(rawBaseLabelItem, pixelScale) : rawBaseLabelItem; const pieLabels = parseAttributeAsStringArray(tnode.attributes.labels); - const labelRadius = parseAttributeAsNumber(tnode.attributes.labelradius); - const innerRadius = parseAttributeAsNumber(tnode.attributes.innerradius); + const rawLabelRadius = parseAttributeAsNumber(tnode.attributes.labelradius); + const labelRadius = rawLabelRadius === undefined ? undefined : rawLabelRadius * pixelScale; + const rawInnerRadius = parseAttributeAsNumber(tnode.attributes.innerradius); + const innerRadius = rawInnerRadius === undefined ? undefined : rawInnerRadius * pixelScale; const padAngle = parseAttributeAsNumber(tnode.attributes.padangle); - const radius = parseAttributeAsNumber(tnode.attributes.radius); + const rawRadius = parseAttributeAsNumber(tnode.attributes.radius); + const radius = rawRadius === undefined ? undefined : rawRadius * pixelScale; const effectiveLabelRadius = labelRadius ?? radius; const size = radius ? radius * 2 : undefined; const angularStrokeWidth = padAngle && radius ? 2 * convertAngleToArcLength(padAngle, radius) : 0; @@ -62,10 +69,15 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { const angularStrokeColor = resolvedBgColor ?? theme.cardBG; const labelIndicatorNode = parseComponent(tnode.attributes.labelindicator, renderEngine, 'shiftedlinesegment', HTMLContentModel.block); const labelIndicatorStyles = labelIndicatorNode ? parseShiftedLineSegmentNode(labelIndicatorNode) : undefined; - const {xShift: labelIndicatorXShift, yShift: labelIndicatorYShift, strokeWidth: labelIndicatorStrokeWidth} = labelIndicatorStyles ?? {}; + const {xShift: rawIndicatorXShift, yShift: rawIndicatorYShift, strokeWidth: rawIndicatorStrokeWidth} = labelIndicatorStyles ?? {}; + const labelIndicatorXShift = rawIndicatorXShift === undefined ? undefined : rawIndicatorXShift * pixelScale; + const labelIndicatorYShift = rawIndicatorYShift === undefined ? undefined : rawIndicatorYShift * pixelScale; + const labelIndicatorStrokeWidth = rawIndicatorStrokeWidth === undefined ? undefined : rawIndicatorStrokeWidth * pixelScale; const labelIndicatorStroke = resolveChartThemeColor(labelIndicatorStyles?.stroke, theme); - const labelIndicatorInnerOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorinneroffset); - const labelIndicatorOuterOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorouteroffset); + const rawIndicatorInnerOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorinneroffset); + const labelIndicatorInnerOffset = rawIndicatorInnerOffset === undefined ? undefined : rawIndicatorInnerOffset * pixelScale; + const rawIndicatorOuterOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorouteroffset); + const labelIndicatorOuterOffset = rawIndicatorOuterOffset === undefined ? undefined : rawIndicatorOuterOffset * pixelScale; const customLabelByDataLabel: Record = {}; const sliceValues: PieSliceValue[] = []; @@ -86,12 +98,16 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { const rowHeight = computeLabelBlockHeight(baseLabelItem, typefaces); const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined; - const bottom = designHeight ? Math.min(designHeight * (POLAR_CONTAINER_HEIGHT_RATIO - 0.5) - rowHeight / 2 - EDGE_PADDING, effectiveLabelRadius) : effectiveLabelRadius; - const topFor = (titleSafeTop: number) => - designHeight ? Math.max(-Math.min(designHeight / 2, effectiveLabelRadius), titleSafeTop + rowHeight / 2 - designHeight / 2) : -effectiveLabelRadius; + // Layout constants are design-space pixels, so they scale with the chart's pixel scale. + const edgePadding = EDGE_PADDING * pixelScale; + const scaledTitleSafeTop = TITLE_SAFE_TOP * pixelScale; + const scaledLeftColumnTopPadding = LEFT_COLUMN_TOP_PADDING * pixelScale; + const bottom = designHeight ? Math.min(designHeight * (POLAR_CONTAINER_HEIGHT_RATIO - 0.5) - rowHeight / 2 - edgePadding, effectiveLabelRadius) : effectiveLabelRadius; + const topFor = (columnTitleSafeTop: number) => + designHeight ? Math.max(-Math.min(designHeight / 2, effectiveLabelRadius), columnTitleSafeTop + rowHeight / 2 - designHeight / 2) : -effectiveLabelRadius; const plotBounds = { - left: {top: topFor(TITLE_SAFE_TOP + LEFT_COLUMN_TOP_PADDING), bottom}, - right: {top: topFor(TITLE_SAFE_TOP), bottom}, + left: {top: topFor(scaledTitleSafeTop + scaledLeftColumnTopPadding), bottom}, + right: {top: topFor(scaledTitleSafeTop), bottom}, }; const textRadius = computeTextRadiusBySide({ slices, @@ -100,11 +116,11 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { typefaces, labelRadius: effectiveLabelRadius, designWidth, - edgePadding: EDGE_PADDING, + edgePadding, }); return computePieLabelLayout({slices, rowHeight, labelRadius: effectiveLabelRadius, textRadius, plotBounds}); - }, [sliceValues, baseLabelItem, effectiveLabelRadius, typefaces, chartContentStyles.height, chartContentStyles.width, customLabelByDataLabel]); + }, [sliceValues, baseLabelItem, effectiveLabelRadius, typefaces, chartContentStyles.height, chartContentStyles.width, customLabelByDataLabel, pixelScale]); return ( ['nodeStyles']; chartContainerStyles: ReturnType['parentNodeStyles']; type: ChartType; + + /** + * Uniform factor already applied to the pixel-space values in this context (1 for inline charts). + * Series components that parse raw pixel attributes from the tnode (bar width, corner radius, + * stroke width) must multiply them by this factor so they scale with the rest of the chart. + */ + pixelScale: number; }; const VictoryChartContext = createContext(null); @@ -73,11 +81,31 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC chartContentStyles: effectiveChartContentStyles, chartContainerStyles, type, + pixelScale: 1, }; return {children}; } +type VictoryChartScaledProviderProps = { + /** Uniform factor to scale all pixel-space chart config by (may be > 1) */ + scale: number; + + /** Chart sub-tree to re-provide the scaled context to */ + children: React.ReactNode; +}; + +/** + * Re-provides the current chart context with every pixel-space value scaled by a uniform factor. + * Used by the expand modal to re-render the chart natively at a larger size (sharp Skia output) + * while keeping labels, legends, axes, and paddings proportionally identical to the inline chart. + */ +function VictoryChartScaledProvider({scale, children}: VictoryChartScaledProviderProps) { + const value = useVictoryChartContext(); + const scaledValue = useMemo(() => scaleVictoryChartContextValue(value, scale), [value, scale]); + return {children}; +} + function useVictoryChartContext(): VictoryChartContextValue { const context = useContext(VictoryChartContext); if (!context) { @@ -86,4 +114,5 @@ function useVictoryChartContext(): VictoryChartContextValue { return context; } -export {VictoryChartProvider, useVictoryChartContext}; +export {VictoryChartProvider, VictoryChartScaledProvider, useVictoryChartContext}; +export type {VictoryChartContextValue}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx index 1bb006417c36..af005d19b900 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx @@ -4,19 +4,28 @@ import type {CartesianChartRenderArg} from 'victory-native'; import React, {createContext, useContext} from 'react'; -const VictoryChartRenderArgsContext = createContext | null>(null); +type VictoryChartRenderArgs = CartesianChartRenderArg & { + /** + * Uniform factor applied to the chart's pixel-space config (1 for inline charts). It travels + * through this context because series components render inside the chart's canvas, where the + * outer VictoryChartContext does not propagate. + */ + pixelScale: number; +}; + +const VictoryChartRenderArgsContext = createContext(null); /** * Makes the CartesianChart render-prop arguments available to series sub-components * (VictoryChartBar, VictoryChartLine) rendered inside the chart's children callback. */ -function VictoryChartRenderArgsProvider({value, children}: {value: CartesianChartRenderArg; children: React.ReactNode}) { +function VictoryChartRenderArgsProvider({value, children}: {value: VictoryChartRenderArgs; children: React.ReactNode}) { return {children}; } VictoryChartRenderArgsProvider.displayName = 'VictoryChartRenderArgsProvider'; -function useVictoryChartRenderArgs(): CartesianChartRenderArg { +function useVictoryChartRenderArgs(): VictoryChartRenderArgs { const context = useContext(VictoryChartRenderArgsContext); if (!context) { throw new Error('useVictoryChartRenderArgs must be used within VictoryChartRenderArgsProvider'); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts index ba2841e226e2..5c2904aeb68f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts @@ -6,15 +6,17 @@ import parseAttribute from './parseAttribute'; /** * Translate VictoryChart's `cornerRadius` attribute into victory-native's `roundedCorners` shape. + * `pixelScale` multiplies every radius, so expanded charts rendered at a larger native size keep + * their corners proportional to the inline chart. */ -function parseCornerRadius(attribute: string): RoundedCorners | undefined { +function parseCornerRadius(attribute: string, pixelScale = 1): RoundedCorners | undefined { const cornerRadius = parseAttribute(attribute); if (typeof cornerRadius === 'number') { return { - topLeft: cornerRadius, - topRight: cornerRadius, - bottomLeft: cornerRadius, - bottomRight: cornerRadius, + topLeft: cornerRadius * pixelScale, + topRight: cornerRadius * pixelScale, + bottomLeft: cornerRadius * pixelScale, + bottomRight: cornerRadius * pixelScale, }; } if (lodashIsObject(cornerRadius)) { @@ -42,7 +44,12 @@ function parseCornerRadius(attribute: string): RoundedCorners | undefined { } else if ('bottom' in cornerRadius) { bottomRight = Number(cornerRadius.bottom); } - return {topLeft, topRight, bottomLeft, bottomRight}; + return { + topLeft: topLeft === undefined ? undefined : topLeft * pixelScale, + topRight: topRight === undefined ? undefined : topRight * pixelScale, + bottomLeft: bottomLeft === undefined ? undefined : bottomLeft * pixelScale, + bottomRight: bottomRight === undefined ? undefined : bottomRight * pixelScale, + }; } return undefined; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseOffset.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseOffset.ts index 01010cd64784..46f85348f395 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseOffset.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseOffset.ts @@ -5,8 +5,9 @@ import {parseAttributeAsNumber} from './parseAttribute'; /** * Translate VictoryChart's `offset` attribute into victory-native's `betweenGroupPadding` percentage. */ -function parseOffset(attribute: string, chartBounds: ChartBounds, groupCount: number, barWidth: number, pointsCount: number, isHorizontal: boolean): number { - const offset = parseAttributeAsNumber(attribute) ?? 0; +function parseOffset(attribute: string, chartBounds: ChartBounds, groupCount: number, barWidth: number, pointsCount: number, isHorizontal: boolean, pixelScale = 1): number { + // The offset attribute is a pixel gap between bars, so it scales with the chart's pixel scale. + const offset = (parseAttributeAsNumber(attribute) ?? 0) * pixelScale; const boundSize = isHorizontal ? chartBounds.top - chartBounds.bottom : chartBounds.right - chartBounds.left; const groupWidth = barWidth + offset * (groupCount - 1); const betweenGroupPadding = 1 - groupWidth * (pointsCount / boundSize); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts new file mode 100644 index 000000000000..86b05a090abe --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -0,0 +1,126 @@ +import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {LabelItem, LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +import type {SkFont} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; + +/** + * Scales every pixel-space value of a parsed chart context by a uniform factor, so the chart can be + * re-rendered natively at a larger target size (sharp Skia output) instead of raster-upscaling the + * design-size render. Data-space values (data points, domains, tick values) are left untouched — + * the chart's axes map them into the larger canvas automatically. + */ + +function scaleRecordValues(record: Record | undefined, scale: number): Record | undefined { + if (!record) { + return record; + } + return Object.fromEntries(Object.entries(record).map(([key, fontValue]) => [key, fontValue * scale])); +} + +function scaleLabelItem(labelItem: LabelItem, scale: number): LabelItem { + return { + ...labelItem, + x: labelItem.x * scale, + y: labelItem.y * scale, + // lineHeight is a multiplier of the font size, so it needs no scaling. + fontSize: scaleRecordValues(labelItem.fontSize, scale), + }; +} + +function scaleLegendItem(legendItem: LegendItem, scale: number): LegendItem { + return { + ...legendItem, + x: legendItem.x * scale, + y: legendItem.y * scale, + gutter: legendItem.gutter === undefined ? undefined : legendItem.gutter * scale, + symbolSpacer: legendItem.symbolSpacer === undefined ? undefined : legendItem.symbolSpacer * scale, + entries: legendItem.entries.map((entry) => ({ + ...entry, + fontSize: entry.fontSize === undefined ? undefined : entry.fontSize * scale, + symbolSize: entry.symbolSize === undefined ? undefined : entry.symbolSize * scale, + })), + }; +} + +type SidedPixelValues = {left?: number; right?: number; top?: number; bottom?: number}; + +function scaleSidedPixelValues(sides: SidedPixelValues, scale: number): SidedPixelValues { + return { + left: sides.left === undefined ? undefined : sides.left * scale, + right: sides.right === undefined ? undefined : sides.right * scale, + top: sides.top === undefined ? undefined : sides.top * scale, + bottom: sides.bottom === undefined ? undefined : sides.bottom * scale, + }; +} + +/** Padding can be a plain number or a per-side object — scale every numeric part. */ +function scalePadding(padding: VictoryChartContextValue['padding'], scale: number): VictoryChartContextValue['padding'] { + if (padding === undefined) { + return undefined; + } + if (typeof padding === 'number') { + return padding * scale; + } + return scaleSidedPixelValues(padding, scale); +} + +/** Domain padding can be a plain number or a per-side object — scale every numeric part. */ +function scaleDomainPadding(domainPadding: VictoryChartContextValue['domainPadding'], scale: number): VictoryChartContextValue['domainPadding'] { + if (domainPadding === undefined) { + return undefined; + } + if (typeof domainPadding === 'number') { + return domainPadding * scale; + } + return scaleSidedPixelValues(domainPadding, scale); +} + +/** Rebuilds a Skia font at the scaled size; the original font object is left untouched. */ +function scaleFont(font: SkFont | null | undefined, scale: number): SkFont | null | undefined { + if (!font) { + return font; + } + const typeface = font.getTypeface(); + if (!typeface) { + return font; + } + return Skia.Font(typeface, font.getSize() * scale); +} + +function scaleAxis(axis: TAxis, scale: number): TAxis { + if (!axis) { + return axis; + } + return { + ...axis, + lineWidth: axis.lineWidth === undefined ? undefined : axis.lineWidth * scale, + labelOffset: axis.labelOffset === undefined ? undefined : axis.labelOffset * scale, + font: scaleFont(axis.font, scale), + }; +} + +function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: number): VictoryChartContextValue { + if (scale === 1) { + return value; + } + + const designWidth = typeof value.chartContentStyles.width === 'number' ? value.chartContentStyles.width * scale : value.chartContentStyles.width; + const designHeight = typeof value.chartContentStyles.height === 'number' ? value.chartContentStyles.height * scale : value.chartContentStyles.height; + + return { + ...value, + xAxis: scaleAxis(value.xAxis, scale), + yAxis: value.yAxis?.map((axis) => scaleAxis(axis, scale)), + domainPadding: scaleDomainPadding(value.domainPadding, scale), + padding: scalePadding(value.padding, scale), + labelItems: value.labelItems.map((labelItem) => scaleLabelItem(labelItem, scale)), + legendItems: value.legendItems.map((legendItem) => scaleLegendItem(legendItem, scale)), + chartContentStyles: {...value.chartContentStyles, width: designWidth, height: designHeight}, + pixelScale: value.pixelScale * scale, + }; +} + +export default scaleVictoryChartContextValue; +export {scaleLabelItem}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 14a493687db7..0eb390f3e869 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6573,9 +6573,6 @@ const staticStyles = (theme: ThemeColors) => chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, - chartExpandedContent: { - transformOrigin: 'top left', - }, chartContent: { height: CHART_CONTENT_MIN_HEIGHT, }, diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx new file mode 100644 index 000000000000..3fb5c6db4f93 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention -- test-only: chart context mocks are narrowed from minimal literals, and per-line font maps are keyed by numeric line index */ +import {render, screen} from '@testing-library/react-native'; + +import {CHART_TYPE} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext, VictoryChartProvider, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import Text from '@components/Text'; + +import type {TNode} from 'react-native-render-html'; + +import React from 'react'; + +const tnode = {attributes: {width: '680', height: '340'}, children: []} as unknown as TNode; + +const processedResult = { + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: undefined, + yAxis: undefined, + domain: undefined, + domainPadding: 20, + padding: 16, + leftAxisLabelPadding: undefined, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}}], + legendItems: [], +} as unknown as ProcessNodeResult; + +/** Serializes the parts of the context under test so assertions can read them from the rendered output. */ +function ContextProbe() { + const {padding, domainPadding, labelItems, chartContentStyles, pixelScale} = useVictoryChartContext(); + return ( + + {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height, pixelScale})} + + ); +} + +function getProbedContext(): Record { + return JSON.parse(screen.getByTestId('contextProbe').props.children as string) as Record; +} + +describe('VictoryChartScaledProvider', () => { + it('provides pixel-space values scaled by the given factor', () => { + render( + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 32, + domainPadding: 40, + firstLabel: {x: 680, y: 48, fontSize: {0: 28}}, + width: 1360, + height: 680, + pixelScale: 2, + }); + }); + + it('provides the unscaled context for scale 1', () => { + render( + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 16, + domainPadding: 20, + firstLabel: {x: 340, y: 24}, + pixelScale: 1, + }); + }); +}); diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts new file mode 100644 index 000000000000..c64c9f9d3f4e --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention -- test-only: chart context mocks are narrowed from minimal literals, and per-line font maps are keyed by numeric line index */ +import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; + +import type {TNode} from 'react-native-render-html'; + +const baseValue = { + tnode: {} as TNode, + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: {tickCount: 3, tickValues: [1, 2, 3], lineWidth: 1, labelOffset: 8, font: null}, + yAxis: [{tickCount: 4, tickValues: [0, 10, 20, 30], lineWidth: 2, labelOffset: 4, font: null}], + domain: {y: [0, 40]}, + domainPadding: {left: 20, right: 20}, + padding: 16, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}, lineHeight: {0: 1.2}}], + legendItems: [{x: 100, y: 200, gutter: 8, symbolSpacer: 4, entries: [{text: 'A', fontSize: 12, symbolSize: 6}]}], + chartContentStyles: {width: 680, height: 340}, + chartContainerStyles: {}, + type: 'cartesian', + pixelScale: 1, +} as unknown as VictoryChartContextValue; + +describe('scaleVictoryChartContextValue', () => { + it('returns the same value for scale 1', () => { + expect(scaleVictoryChartContextValue(baseValue, 1)).toBe(baseValue); + }); + + it('scales pixel-space values by the given factor', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.labelItems.at(0)).toMatchObject({x: 680, y: 48, fontSize: {0: 28}}); + expect(scaled.legendItems.at(0)).toMatchObject({x: 200, y: 400, gutter: 16, symbolSpacer: 8}); + expect(scaled.legendItems.at(0)?.entries.at(0)).toMatchObject({fontSize: 24, symbolSize: 12}); + expect(scaled.padding).toBe(32); + expect(scaled.domainPadding).toEqual({left: 40, right: 40}); + expect(scaled.chartContentStyles).toMatchObject({width: 1360, height: 680}); + expect(scaled.xAxis).toMatchObject({lineWidth: 2, labelOffset: 16}); + expect(scaled.pixelScale).toBe(2); + expect(scaled.yAxis?.at(0)).toMatchObject({lineWidth: 4, labelOffset: 8}); + }); + + it('leaves data-space values untouched', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.data).toEqual(baseValue.data); + expect(scaled.domain).toEqual(baseValue.domain); + expect(scaled.xAxis).toMatchObject({tickCount: 3, tickValues: [1, 2, 3]}); + expect(scaled.yAxis?.at(0)).toMatchObject({tickValues: [0, 10, 20, 30]}); + }); + + it('does not scale line-height multipliers', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + expect(scaled.labelItems.at(0)?.lineHeight).toEqual({0: 1.2}); + }); + + it('returns axis fonts unchanged when they have no typeface', () => { + const fakeFont = {getTypeface: () => null, getSize: () => 12}; + const value = {...baseValue, xAxis: {...(baseValue.xAxis as Record), font: fakeFont}} as unknown as typeof baseValue; + const scaled = scaleVictoryChartContextValue(value, 2); + expect((scaled.xAxis as Record).font).toBe(fakeFont); + }); + + it('handles missing optional fields without throwing', () => { + const value = { + ...baseValue, + xAxis: undefined, + yAxis: undefined, + domainPadding: undefined, + padding: undefined, + labelItems: [{x: 1, y: 2, text: 'bare'}], + legendItems: [{x: 1, y: 2, entries: [{text: 'A'}]}], + chartContentStyles: {}, + } as unknown as typeof baseValue; + const scaled = scaleVictoryChartContextValue(value, 3); + expect(scaled.labelItems.at(0)).toMatchObject({x: 3, y: 6}); + expect(scaled.legendItems.at(0)?.entries.at(0)).toMatchObject({text: 'A'}); + expect(scaled.xAxis).toBeUndefined(); + expect(scaled.padding).toBeUndefined(); + }); +});