Skip to content
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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();
Expand All @@ -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;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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.
<View
style={[
StyleUtils.getWidthAndHeightStyle(designWidth * scale, effectiveDesignHeight * scale),
typeof borderRadius === 'number' && isPolar && StyleUtils.getBorderRadiusStyle(borderRadius),
styles.overflowHidden,
]}
// Pinch/double-tap zoom and pan, matching the image attachment viewer.
<MultiGestureCanvas
isActive={isVisible}
canvasSize={availableSize}
contentSize={{width: targetWidth, height: clippedTargetHeight}}
isUsedInCarousel={false}
isPagerScrollEnabled={isPagerScrollEnabled}
>
{/* 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. */}
<View
style={[
chartContentStyles,
StyleUtils.getWidthAndHeightStyle(designWidth, designHeight),
backgroundColor !== undefined && StyleUtils.getBackgroundColorStyle(backgroundColor),
typeof borderRadius === 'number' && StyleUtils.getBorderRadiusStyle(borderRadius),
StyleUtils.getWidthAndHeightStyle(targetWidth, clippedTargetHeight),
typeof borderRadius === 'number' && isPolar && StyleUtils.getBorderRadiusStyle(borderRadius),
styles.overflowHidden,
styles.chartExpandedContent,
StyleUtils.getTransformScaleStyle(scale),
]}
>
{/* 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 && <VictoryChartContent />}
{/* 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. */}
<View
style={[
StyleUtils.getWidthAndHeightStyle(contentBoxWidth, contentBoxHeight),
backgroundColor !== undefined && StyleUtils.getBackgroundColorStyle(backgroundColor),
typeof borderRadius === 'number' && StyleUtils.getBorderRadiusStyle(borderRadius),
styles.overflowHidden,
styles.chartExpandedContent,
StyleUtils.getTransformScaleStyle(contentBoxScale),
]}
>
{/* 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 ? (
<VictoryChartContent />
) : (
<VictoryChartScaledProvider scale={scale * zoomHeadroom}>
<VictoryChartContent
explicitSize={{width: renderWidth, height: renderHeight}}
headless={false}
/>
</VictoryChartScaledProvider>
))}
</View>
</View>
</View>
</MultiGestureCanvas>
) : (
// Charts without design dimensions have no design-based label coordinates, so fluid
// rendering is safe. Background/rounding are still applied so the expanded chart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -78,6 +79,24 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC
return <VictoryChartContext.Provider value={contextValue}>{children}</VictoryChartContext.Provider>;
}

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 <VictoryChartContext.Provider value={scaledValue}>{children}</VictoryChartContext.Provider>;
}

function useVictoryChartContext(): VictoryChartContextValue {
const context = useContext(VictoryChartContext);
if (!context) {
Expand All @@ -86,4 +105,5 @@ function useVictoryChartContext(): VictoryChartContextValue {
return context;
}

export {VictoryChartProvider, useVictoryChartContext};
export {VictoryChartProvider, VictoryChartScaledProvider, useVictoryChartContext};
export type {VictoryChartContextValue};
Original file line number Diff line number Diff line change
@@ -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<number, number> | undefined, scale: number): Record<number, number> | 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<TAxis extends {lineWidth?: number; labelOffset?: number; font?: SkFont | null} | undefined>(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;
Loading
Loading