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();
+ });
+});