From 10d2c708028f3ad530cc5498af37e037465733b4 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Mon, 3 Aug 2026 19:51:27 +0500 Subject: [PATCH 1/7] feat: render expanded chart at native resolution and add pinch-zoom via MultiGestureCanvas --- .../components/VictoryChartExpandModal.tsx | 74 ++++++++----- .../context/VictoryChartContext.tsx | 24 ++++- .../utils/scaleVictoryChartContextValue.ts | 102 ++++++++++++++++++ src/styles/index.ts | 3 - .../scaleVictoryChartContextValueTest.ts | 56 ++++++++++ 5 files changed, 228 insertions(+), 31 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts create mode 100644 tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 66ac91776a76..908550c66b97 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,8 +1,9 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; -import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {useVictoryChartContext, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import Modal from '@components/Modal'; +import MultiGestureCanvas from '@components/MultiGestureCanvas'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -16,6 +17,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; +import {useSharedValue} from 'react-native-reanimated'; import VictoryChartContent from './VictoryChartContent'; @@ -31,12 +33,11 @@ type VictoryChartExpandModalProps = { * Centered full-screen modal that re-renders the current chart scaled up to the viewport. * Must be rendered inside a VictoryChartProvider so VictoryChartContent can read the parsed chart context. * - * The chart is rendered at its design size and uniformly transform-scaled to fit the modal — - * the same technique the inline scaled container uses to shrink charts. This keeps the canvas - * and the absolutely-positioned label/legend overlays (whose coordinates are design-based) - * perfectly aligned, so the expanded chart looks identical to the inline one, only larger. - * Rendering fluidly instead would resize only the canvas and leave labels at design coordinates, - * misplacing them (and potentially overlaying the header, blocking the back button). + * The chart is re-rendered natively at the target size through VictoryChartScaledProvider, which + * scales every pixel-space value (labels, legends, axes, paddings) by the same uniform factor — + * so the expanded chart is a sharp Skia render that looks identical to the inline one, only larger. + * The chart is wrapped in MultiGestureCanvas, giving it the same pinch/double-tap zoom and pan + * gestures as image attachments. */ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalProps) { const styles = useThemeStyles(); @@ -46,8 +47,12 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const {shouldUseNarrowLayout} = useResponsiveLayout(); const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); const [availableSize, setAvailableSize] = useState({width: 0, height: 0}); + // No pager wraps this canvas, so scrolling never needs to be handed back to one. + const isPagerScrollEnabled = useSharedValue(false); const onContainerLayout = (event: LayoutChangeEvent) => { + // 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 +74,11 @@ 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; + // Target render size: the chart is drawn natively at these dimensions for a sharp result. + const targetWidth = (designWidth ?? 0) * scale; + const targetHeight = (designHeight ?? 0) * scale; + const clippedTargetHeight = (effectiveDesignHeight ?? 0) * scale; + // 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. @@ -103,32 +113,44 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr > {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/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index c3afd5446121..6161b80690c7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -2,10 +2,11 @@ import type {ChartType, LabelItem, LegendItem, ProcessNodeResult} from '@compone import computeAdjustedOverlayY from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeAdjustedOverlayY'; import computeDynamicChartHeight from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeDynamicChartHeight'; import parseStyles from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseStyles'; +import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; import type {TNode} from 'react-native-render-html'; -import React, {createContext, useContext} from 'react'; +import React, {createContext, useContext, useMemo} from 'react'; type VictoryChartContextValue = { tnode: TNode; @@ -76,6 +77,24 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC return {children}; } +type VictoryChartScaledProviderProps = { + /** Uniform factor to scale all pixel-space chart config by (may be > 1) */ + scale: number; + + 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) { @@ -84,4 +103,5 @@ function useVictoryChartContext(): VictoryChartContextValue { return context; } -export {VictoryChartProvider, useVictoryChartContext}; +export {VictoryChartProvider, VictoryChartScaledProvider, useVictoryChartContext}; +export type {VictoryChartContextValue}; 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..ca90acb54470 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -0,0 +1,102 @@ +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, + })), + }; +} + +/** Padding/domainPadding can be a plain number or a per-side object — scale every numeric part. */ +function scalePaddingLike(padding: T, scale: number): T { + if (typeof padding === 'number') { + return (padding * scale) as T; + } + if (padding && typeof padding === 'object') { + return Object.fromEntries(Object.entries(padding).map(([side, sideValue]) => [side, typeof sideValue === 'number' ? sideValue * scale : sideValue])) as T; + } + return padding; +} + +/** 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: scalePaddingLike(value.domainPadding, scale), + padding: scalePaddingLike(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}, + }; +} + +export default scaleVictoryChartContextValue; diff --git a/src/styles/index.ts b/src/styles/index.ts index d5d4801e024d..6bbaabe70df9 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6385,9 +6385,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/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts new file mode 100644 index 000000000000..d5f9879b7205 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -0,0 +1,56 @@ +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', +} 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.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}); + }); +}); From 626e6c8123d19bbaabd0b44c6ead678546a47474 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Tue, 4 Aug 2026 04:50:23 +0500 Subject: [PATCH 2/7] fix: remove unsafe type assertions from scale util and lint-exempt test mocks --- .../components/VictoryChartExpandModal.tsx | 40 +++++++--- .../utils/scaleVictoryChartContextValue.ts | 38 +++++++-- src/styles/index.ts | 3 + .../VictoryChartScaledProviderTest.tsx | 80 +++++++++++++++++++ .../scaleVictoryChartContextValueTest.ts | 26 ++++++ 5 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 908550c66b97..61a5c2466ecb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -79,6 +79,19 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const targetHeight = (designHeight ?? 0) * scale; const clippedTargetHeight = (effectiveDesignHeight ?? 0) * scale; + // Cartesian charts render with zoom headroom: the canvas is drawn larger than the fitted size and + // displayed scaled down, so pinch-zooming stays sharp up to the headroom factor instead of + // magnifying raster pixels immediately. Capped so the canvas never exceeds a safe texture size. + const MAX_CANVAS_DIMENSION = 2048; + const zoomHeadroom = Math.max(1, Math.min(2, MAX_CANVAS_DIMENSION / Math.max(targetWidth, targetHeight, 1))); + const renderWidth = targetWidth * zoomHeadroom; + const renderHeight = targetHeight * zoomHeadroom; + + // Polar charts render at design size and are transform-scaled; cartesian charts render natively with headroom. + const contentBoxWidth = isPolar ? (designWidth ?? 0) : renderWidth; + const contentBoxHeight = isPolar ? (designHeight ?? 0) : renderHeight; + const contentBoxScale = isPolar ? scale : 1 / 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. @@ -129,25 +142,34 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr styles.overflowHidden, ]} > + {/* Cartesian charts are re-rendered natively at the target size (sharp Skia output) via the + scaled context. Polar charts keep the uniform transform-scale of the design-size render + instead: their geometry (radius, label layout) is parsed from HTML attributes in the pie + components, so a scaled context alone cannot resize them consistently. */} {/* 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 && ( - - - - )} + {isVisible && + (isPolar ? ( + + ) : ( + + + + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts index ca90acb54470..f5b01717bacd 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -44,15 +44,37 @@ function scaleLegendItem(legendItem: LegendItem, scale: number): LegendItem { }; } -/** Padding/domainPadding can be a plain number or a per-side object — scale every numeric part. */ -function scalePaddingLike(padding: T, scale: number): T { +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) as T; + 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 (padding && typeof padding === 'object') { - return Object.fromEntries(Object.entries(padding).map(([side, sideValue]) => [side, typeof sideValue === 'number' ? sideValue * scale : sideValue])) as T; + if (typeof domainPadding === 'number') { + return domainPadding * scale; } - return padding; + return scaleSidedPixelValues(domainPadding, scale); } /** Rebuilds a Skia font at the scaled size; the original font object is left untouched. */ @@ -91,8 +113,8 @@ function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: n ...value, xAxis: scaleAxis(value.xAxis, scale), yAxis: value.yAxis?.map((axis) => scaleAxis(axis, scale)), - domainPadding: scalePaddingLike(value.domainPadding, scale), - padding: scalePaddingLike(value.padding, 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}, diff --git a/src/styles/index.ts b/src/styles/index.ts index 899c5d7d6358..1293f915659e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6467,6 +6467,9 @@ 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..95ab7fa10dbe --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -0,0 +1,80 @@ +/* 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 type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {useVictoryChartContext, VictoryChartProvider, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +import type {TNode} from 'react-native-render-html'; + +import React from 'react'; +import {Text} from 'react-native'; + +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; + +let capturedValue: VictoryChartContextValue | undefined; + +function ContextProbe() { + capturedValue = useVictoryChartContext(); + return probe; +} + +describe('VictoryChartScaledProvider', () => { + beforeEach(() => { + capturedValue = undefined; + }); + + it('provides pixel-space values scaled by the given factor', () => { + render( + + + + + , + ); + + expect(screen.getByText('probe')).toBeOnTheScreen(); + expect(capturedValue?.padding).toBe(32); + expect(capturedValue?.domainPadding).toBe(40); + expect(capturedValue?.labelItems.at(0)).toMatchObject({x: 680, y: 48, fontSize: {0: 28}}); + expect(capturedValue?.chartContentStyles).toMatchObject({width: 1360, height: 680}); + }); + + it('provides the unscaled context for scale 1', () => { + render( + + + + + , + ); + + expect(capturedValue?.padding).toBe(16); + expect(capturedValue?.labelItems.at(0)).toMatchObject({x: 340, y: 24}); + }); +}); diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts index d5f9879b7205..11e690b55059 100644 --- a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -1,3 +1,4 @@ +/* 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'; @@ -53,4 +54,29 @@ describe('scaleVictoryChartContextValue', () => { 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(); + }); }); From 76a70accc3045165b3c07168aee0966b209c6786 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Tue, 4 Aug 2026 04:57:58 +0500 Subject: [PATCH 3/7] fix: make scaled provider test React Compiler compliant --- .../VictoryChartScaledProviderTest.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx index 95ab7fa10dbe..59fae37db5fb 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -2,7 +2,6 @@ import {render, screen} from '@testing-library/react-native'; import {CHART_TYPE} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; -import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartContext, VictoryChartProvider, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import type {ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; @@ -29,18 +28,17 @@ const processedResult = { legendItems: [], } as unknown as ProcessNodeResult; -let capturedValue: VictoryChartContextValue | undefined; - +/** Serializes the parts of the context under test so assertions can read them from the rendered output. */ function ContextProbe() { - capturedValue = useVictoryChartContext(); - return probe; + const {padding, domainPadding, labelItems, chartContentStyles} = useVictoryChartContext(); + return {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height})}; } -describe('VictoryChartScaledProvider', () => { - beforeEach(() => { - capturedValue = undefined; - }); +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(screen.getByText('probe')).toBeOnTheScreen(); - expect(capturedValue?.padding).toBe(32); - expect(capturedValue?.domainPadding).toBe(40); - expect(capturedValue?.labelItems.at(0)).toMatchObject({x: 680, y: 48, fontSize: {0: 28}}); - expect(capturedValue?.chartContentStyles).toMatchObject({width: 1360, height: 680}); + expect(getProbedContext()).toMatchObject({ + padding: 32, + domainPadding: 40, + firstLabel: {x: 680, y: 48, fontSize: {0: 28}}, + width: 1360, + height: 680, + }); }); it('provides the unscaled context for scale 1', () => { @@ -74,7 +74,10 @@ describe('VictoryChartScaledProvider', () => { , ); - expect(capturedValue?.padding).toBe(16); - expect(capturedValue?.labelItems.at(0)).toMatchObject({x: 340, y: 24}); + expect(getProbedContext()).toMatchObject({ + padding: 16, + domainPadding: 20, + firstLabel: {x: 340, y: 24}, + }); }); }); From 663d7492af12799a73d472e878e4edc915e7b80e Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Tue, 4 Aug 2026 16:19:13 +0500 Subject: [PATCH 4/7] fix: use app Text component in scaled provider test --- .../HTMLEngineProvider/VictoryChartScaledProviderTest.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx index 59fae37db5fb..bb1d9bdb5175 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -4,11 +4,11 @@ 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'; -import {Text} from 'react-native'; const tnode = {attributes: {width: '680', height: '340'}, children: []} as unknown as TNode; From 6ee04e5688288612f5f42f911686857d54e4c4bd Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Wed, 5 Aug 2026 02:26:00 +0500 Subject: [PATCH 5/7] fix: scale series pixel attributes (bar width, corner radius, stroke width) in expanded charts --- .../components/VictoryChartBar.tsx | 7 +++++-- .../components/VictoryChartBarGroup.tsx | 9 ++++++--- .../components/VictoryChartExpandModal.tsx | 5 ++++- .../components/VictoryChartLine.tsx | 4 +++- .../context/VictoryChartContext.tsx | 9 +++++++++ .../utils/parseCornerRadius.ts | 19 +++++++++++++------ .../VictoryChartRenderer/utils/parseOffset.ts | 5 +++-- .../utils/scaleVictoryChartContextValue.ts | 1 + .../VictoryChartScaledProviderTest.tsx | 10 ++++++++-- .../scaleVictoryChartContextValueTest.ts | 2 ++ 10 files changed, 54 insertions(+), 17 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx index 44029eb5a7e3..4bd8ac21b8db 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx @@ -1,5 +1,6 @@ import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; import VictoryTheme from '@components/Charts/VictoryTheme'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartRenderArgs} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -15,16 +16,18 @@ type VictoryChartBarProps = {tnode: TNode}; function VictoryChartBar({tnode}: VictoryChartBarProps) { const {points, chartBounds} = useVictoryChartRenderArgs(); + const {pixelScale} = useVictoryChartContext(); 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..94f6ba9687b3 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx @@ -1,5 +1,6 @@ import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; import VictoryTheme from '@components/Charts/VictoryTheme'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartRenderArgs} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -19,6 +20,7 @@ type VictoryChartBarGroupProps = { function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) { const {points, chartBounds} = useVictoryChartRenderArgs(); + const {pixelScale} = useVictoryChartContext(); const barChildren = tnode.children.filter((child) => child.tagName === 'victorybar'); const firstBarChild = barChildren.at(0); @@ -26,10 +28,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/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 61a5c2466ecb..72887e0f1f1f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -83,7 +83,10 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr // displayed scaled down, so pinch-zooming stays sharp up to the headroom factor instead of // magnifying raster pixels immediately. Capped so the canvas never exceeds a safe texture size. const MAX_CANVAS_DIMENSION = 2048; - const zoomHeadroom = Math.max(1, Math.min(2, MAX_CANVAS_DIMENSION / Math.max(targetWidth, targetHeight, 1))); + // Cap the headroom so the canvas is never drawn more than 2x larger than the fitted size — enough + // for 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; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx index 41964ca9be24..e9a17340ac25 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx @@ -1,4 +1,5 @@ import VictoryTheme from '@components/Charts/VictoryTheme'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartRenderArgs} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import parseCurveType from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCurveType'; @@ -13,13 +14,14 @@ type VictoryChartLineProps = {tnode: TNode}; function VictoryChartLine({tnode}: VictoryChartLineProps) { const {points} = useVictoryChartRenderArgs(); + const {pixelScale} = useVictoryChartContext(); const yKey = getYKey(tnode); const {nodeStyles} = parseStyles(tnode); return ( ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index d230db122936..60aba9f08066 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -26,6 +26,13 @@ type VictoryChartContextValue = { chartContentStyles: ReturnType['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); @@ -74,6 +81,7 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC chartContentStyles: effectiveChartContentStyles, chartContainerStyles, type, + pixelScale: 1, }; return {children}; @@ -83,6 +91,7 @@ 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; }; 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 index f5b01717bacd..6a038fb98450 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -118,6 +118,7 @@ function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: n 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, }; } diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx index bb1d9bdb5175..3fb5c6db4f93 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -30,8 +30,12 @@ const processedResult = { /** Serializes the parts of the context under test so assertions can read them from the rendered output. */ function ContextProbe() { - const {padding, domainPadding, labelItems, chartContentStyles} = useVictoryChartContext(); - return {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height})}; + 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 { @@ -58,6 +62,7 @@ describe('VictoryChartScaledProvider', () => { firstLabel: {x: 680, y: 48, fontSize: {0: 28}}, width: 1360, height: 680, + pixelScale: 2, }); }); @@ -78,6 +83,7 @@ describe('VictoryChartScaledProvider', () => { 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 index 11e690b55059..c64c9f9d3f4e 100644 --- a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -21,6 +21,7 @@ const baseValue = { chartContentStyles: {width: 680, height: 340}, chartContainerStyles: {}, type: 'cartesian', + pixelScale: 1, } as unknown as VictoryChartContextValue; describe('scaleVictoryChartContextValue', () => { @@ -38,6 +39,7 @@ describe('scaleVictoryChartContextValue', () => { 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}); }); From 412304aaaf870c0423482d6da05d6c909fc2a7aa Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Wed, 5 Aug 2026 05:33:26 +0500 Subject: [PATCH 6/7] refactor: adopt Lightbox pattern for expanded chart - render once at high-res, let MultiGestureCanvas own all transforms --- .../components/VictoryChartExpandModal.tsx | 67 ++++++++----------- .../components/VictoryChartPie.tsx | 46 ++++++++----- .../utils/scaleVictoryChartContextValue.ts | 1 + src/styles/index.ts | 3 - 4 files changed, 60 insertions(+), 57 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 72887e0f1f1f..10f5c8392c24 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -30,14 +30,16 @@ type VictoryChartExpandModalProps = { }; /** - * Centered full-screen modal that re-renders the current chart scaled up to the viewport. + * Centered full-screen modal that presents the current chart scaled up to the viewport, with the + * same pinch/double-tap zoom and pan gestures as the image attachment viewer. * Must be rendered inside a VictoryChartProvider so VictoryChartContent can read the parsed chart context. * - * The chart is re-rendered natively at the target size through VictoryChartScaledProvider, which - * scales every pixel-space value (labels, legends, axes, paddings) by the same uniform factor — - * so the expanded chart is a sharp Skia render that looks identical to the inline one, only larger. - * The chart is wrapped in MultiGestureCanvas, giving it the same pinch/double-tap zoom and pan - * gestures as image attachments. + * This mirrors the Lightbox pattern exactly: the chart is rendered ONCE at a fixed high resolution + * (like a high-res image asset — via VictoryChartScaledProvider, which scales every pixel-space + * value uniformly) and handed to MultiGestureCanvas at that intrinsic size. The canvas computes the + * fit scale itself and owns the single transform for fitting, centering, and zooming — no manual + * transforms of our own, since nested transforms rasterize the inner layer and blur it on native. + * Zooming in reveals the native resolution, so the chart stays sharp up to the headroom factor. */ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalProps) { const styles = useThemeStyles(); @@ -74,26 +76,21 @@ 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; - // Target render size: the chart is drawn natively at these dimensions for a sharp result. + // 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; - // Cartesian charts render with zoom headroom: the canvas is drawn larger than the fitted size and - // displayed scaled down, so pinch-zooming stays sharp up to the headroom factor instead of - // magnifying raster pixels immediately. Capped so the canvas never exceeds a safe texture size. + // 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; - // Cap the headroom so the canvas is never drawn more than 2x larger than the fitted size — enough - // for typical pinch-zoom depth without paying for a larger render surface. + // 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; - - // Polar charts render at design size and are transform-scaled; cartesian charts render natively with headroom. - const contentBoxWidth = isPolar ? (designWidth ?? 0) : renderWidth; - const contentBoxHeight = isPolar ? (designHeight ?? 0) : renderHeight; - const contentBoxScale = isPolar ? scale : 1 / 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 @@ -120,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 ? ( - // Pinch/double-tap zoom and pan, matching the image attachment viewer. + // Pinch/double-tap zoom and pan, matching the image attachment viewer. The canvas + // receives the chart at its intrinsic (high-res) size and fits it itself. {/* Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. */} - {/* Cartesian charts are re-rendered natively at the target size (sharp Skia output) via the - scaled context. Polar charts keep the uniform transform-scale of the design-size render - instead: their geometry (radius, label layout) is parsed from HTML attributes in the pie - components, so a scaled context alone cannot resize them consistently. */} {/* 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 && - (isPolar ? ( - - ) : ( - - - - ))} + {isVisible && ( + + + + )} 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 ( chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, - chartExpandedContent: { - transformOrigin: 'top left', - }, chartContent: { height: CHART_CONTENT_MIN_HEIGHT, }, From 1b1d3e393f02319f4adb770873f8253a14f3a17f Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Thu, 13 Aug 2026 23:15:01 +0500 Subject: [PATCH 7/7] fix: pass pixelScale through render-args context - chart context does not cross the Skia canvas boundary --- .../components/VictoryChartBar.tsx | 4 +--- .../components/VictoryChartBarGroup.tsx | 4 +--- .../components/VictoryChartCartesian.tsx | 6 +++--- .../components/VictoryChartLine.tsx | 4 +--- .../context/VictoryChartRenderArgsContext.tsx | 15 ++++++++++++--- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx index 4bd8ac21b8db..356d30cb4cfa 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx @@ -1,6 +1,5 @@ import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; import VictoryTheme from '@components/Charts/VictoryTheme'; -import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartRenderArgs} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -15,8 +14,7 @@ import {Bar} from 'victory-native'; type VictoryChartBarProps = {tnode: TNode}; function VictoryChartBar({tnode}: VictoryChartBarProps) { - const {points, chartBounds} = useVictoryChartRenderArgs(); - const {pixelScale} = useVictoryChartContext(); + const {points, chartBounds, pixelScale} = useVictoryChartRenderArgs(); const yKey = getYKey(tnode); const {nodeStyles} = parseStyles(tnode); const barWidth = parseAttributeAsNumber(tnode.attributes.barwidth); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx index 94f6ba9687b3..bed5e363d880 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx @@ -1,6 +1,5 @@ import BAR_INNER_PADDING from '@components/Charts/barChartConstants'; import VictoryTheme from '@components/Charts/VictoryTheme'; -import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {useVictoryChartRenderArgs} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext'; import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getYKey'; import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; @@ -19,8 +18,7 @@ type VictoryChartBarGroupProps = { }; function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) { - const {points, chartBounds} = useVictoryChartRenderArgs(); - const {pixelScale} = useVictoryChartContext(); + const {points, chartBounds, pixelScale} = useVictoryChartRenderArgs(); const barChildren = tnode.children.filter((child) => child.tagName === 'victorybar'); const firstBarChild = barChildren.at(0); 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) => ( | 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');