diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx
index 66ac91776a76..61a5c2466ecb 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,24 @@ 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;
+
+ // 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.
@@ -103,32 +126,53 @@ 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 && }
+ {/* 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 ? (
+
+ ) : (
+
+
+
+ ))}
+
-
+
) : (
// 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 f51afbfbd0f9..d230db122936 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;
@@ -78,6 +79,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) {
@@ -86,4 +105,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..f5b01717bacd
--- /dev/null
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts
@@ -0,0 +1,124 @@
+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},
+ };
+}
+
+export default scaleVictoryChartContextValue;
diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx
new file mode 100644
index 000000000000..bb1d9bdb5175
--- /dev/null
+++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx
@@ -0,0 +1,83 @@
+/* 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} = useVictoryChartContext();
+ return {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height})};
+}
+
+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,
+ });
+ });
+
+ it('provides the unscaled context for scale 1', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(getProbedContext()).toMatchObject({
+ padding: 16,
+ domainPadding: 20,
+ firstLabel: {x: 340, y: 24},
+ });
+ });
+});
diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts
new file mode 100644
index 000000000000..11e690b55059
--- /dev/null
+++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts
@@ -0,0 +1,82 @@
+/* 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',
+} 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});
+ });
+
+ 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();
+ });
+});