From 10d2c708028f3ad530cc5498af37e037465733b4 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Mon, 3 Aug 2026 19:51:27 +0500 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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'); From 09143b9010e14ab46e70b4f016548dfe5f641887 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Thu, 20 Aug 2026 02:15:42 +0500 Subject: [PATCH 08/18] fix: pass shared typeface into font scaling - CanvasKit forbids reusing a font's raw typeface pointer --- .../context/VictoryChartContext.tsx | 6 ++++- .../utils/scaleVictoryChartContextValue.ts | 26 +++++++++---------- .../scaleVictoryChartContextValueTest.ts | 4 +-- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index 60aba9f08066..37fed8519318 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -1,3 +1,5 @@ +import {useChartTypefaces} from '@components/Charts/context/ChartFontsContext'; +import getVictoryChartTreeTypeface from '@components/Charts/utils/getVictoryChartTreeTypeface'; import type {ChartType, LabelItem, LegendItem, ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; import computeAdjustedOverlayY from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeAdjustedOverlayY'; import computeDynamicChartHeight from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeDynamicChartHeight'; @@ -102,7 +104,9 @@ type VictoryChartScaledProviderProps = { */ function VictoryChartScaledProvider({scale, children}: VictoryChartScaledProviderProps) { const value = useVictoryChartContext(); - const scaledValue = useMemo(() => scaleVictoryChartContextValue(value, scale), [value, scale]); + const typefaces = useChartTypefaces(); + const typeface = getVictoryChartTreeTypeface(typefaces); + const scaledValue = useMemo(() => scaleVictoryChartContextValue(value, scale, typeface), [value, scale, typeface]); return {children}; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts index 86b05a090abe..552d379215b0 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -1,7 +1,7 @@ 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 type {SkFont, SkTypeface} from '@shopify/react-native-skia'; import {Skia} from '@shopify/react-native-skia'; @@ -77,19 +77,19 @@ function scaleDomainPadding(domainPadding: VictoryChartContextValue['domainPaddi 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) { +/** + * Rebuilds a Skia font at the scaled size using the chart's shared typeface; the original font + * object is left untouched. The typeface must be passed in rather than read via `font.getTypeface()` + * because CanvasKit (web) returns a raw pointer there that cannot be passed back into `Skia.Font`. + */ +function scaleFont(font: SkFont | null | undefined, scale: number, typeface: SkTypeface | null): SkFont | null | undefined { + if (!font || !typeface) { return font; } return Skia.Font(typeface, font.getSize() * scale); } -function scaleAxis(axis: TAxis, scale: number): TAxis { +function scaleAxis(axis: TAxis, scale: number, typeface: SkTypeface | null): TAxis { if (!axis) { return axis; } @@ -97,11 +97,11 @@ function scaleAxis scaleAxis(axis, scale)), + xAxis: scaleAxis(value.xAxis, scale, typeface), + yAxis: value.yAxis?.map((axis) => scaleAxis(axis, scale, typeface)), domainPadding: scaleDomainPadding(value.domainPadding, scale), padding: scalePadding(value.padding, scale), labelItems: value.labelItems.map((labelItem) => scaleLabelItem(labelItem, scale)), diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts index c64c9f9d3f4e..69422d6cfa31 100644 --- a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -57,8 +57,8 @@ describe('scaleVictoryChartContextValue', () => { 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}; + it('returns axis fonts unchanged when no shared typeface is provided', () => { + const fakeFont = {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); From 511021c6c9e15a9d3758a89d8dd4afe0c38659fd Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Thu, 20 Aug 2026 02:24:27 +0500 Subject: [PATCH 09/18] fix: pass shared typeface into font scaling and wrap scaled provider test with fonts context --- .../VictoryChartScaledProviderTest.tsx | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx index 3fb5c6db4f93..65bfcba3d9d0 100644 --- a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -1,6 +1,8 @@ /* 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 {ChartFontsContext} from '@components/Charts/context/ChartFontsContext'; +import type ChartFontsValue from '@components/Charts/types/chartFontsTypes'; 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'; @@ -28,6 +30,8 @@ const processedResult = { legendItems: [], } as unknown as ProcessNodeResult; +const chartFontsValue = {typefaces: {}, fontManager: null} as unknown as ChartFontsValue; + /** 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(); @@ -45,15 +49,17 @@ function getProbedContext(): Record { describe('VictoryChartScaledProvider', () => { it('provides pixel-space values scaled by the given factor', () => { render( - - - - - , + + + + + + + , ); expect(getProbedContext()).toMatchObject({ @@ -68,15 +74,17 @@ describe('VictoryChartScaledProvider', () => { it('provides the unscaled context for scale 1', () => { render( - - - - - , + + + + + + + , ); expect(getProbedContext()).toMatchObject({ From 59077dd30fe11bf58a0561d4163c3cf12d3a34c3 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 22 Aug 2026 05:44:44 +0500 Subject: [PATCH 10/18] feat: mirror image attachment zoom on desktop web - click to zoom with scroll pan, pinch on touch devices --- src/CONST/index.ts | 1 + .../components/VictoryChartExpandModal.tsx | 101 ++-------- .../BaseVictoryChartExpandedContent.tsx | 51 +++++ .../ExpandedChartBox.tsx | 78 ++++++++ .../index.native.tsx | 1 + .../VictoryChartExpandedContent/index.tsx | 179 ++++++++++++++++++ .../VictoryChartExpandedContent/types.ts | 11 ++ .../useExpandedChartLayout.ts | 93 +++++++++ 8 files changed, 428 insertions(+), 87 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cb83432efc8e..cceb427948b0 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8669,6 +8669,7 @@ const CONST = { IMAGE: 'HTMLRenderer-Image', PRE: 'HTMLRenderer-Pre', VICTORY_CHART_EXPAND_BUTTON: 'HTMLRenderer-VictoryChartExpandButton', + VICTORY_CHART_ZOOM: 'HTMLRenderer-VictoryChartZoom', TABLE_ROW: 'HTMLRenderer-TableRow', }, RECEIPT: { diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 10f5c8392c24..d3033b4898a1 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,9 +1,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; -import {useVictoryChartContext, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {useVictoryChartContext} 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'; @@ -17,9 +15,9 @@ 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'; +import VictoryChartExpandedContent from './VictoryChartExpandedContent'; type VictoryChartExpandModalProps = { /** Whether the modal is visible */ @@ -30,16 +28,10 @@ type VictoryChartExpandModalProps = { }; /** - * 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. - * - * 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. + * Centered full-screen modal that presents the current chart scaled up to the viewport, with + * platform-appropriate zoom mirroring the image attachment viewer: pinch/double-tap on touch + * devices, click + scroll on desktop web. + * Must be rendered inside a VictoryChartProvider so the chart can read the parsed chart context. */ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalProps) { const styles = useThemeStyles(); @@ -47,10 +39,8 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const theme = useTheme(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); + const {chartContentStyles, chartContainerStyles} = 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 @@ -63,38 +53,10 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr setAvailableSize((prev) => (prev.width === width && prev.height === height ? prev : {width, height})); }; - const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined; - const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; - const hasDesignDimensions = !!designWidth && !!designHeight; + const hasDesignDimensions = typeof chartContentStyles.width === 'number' && typeof chartContentStyles.height === 'number'; const isMeasured = availableSize.width > 0 && availableSize.height > 0; - // Match the inline container: polar charts are clipped to hide the dead space at the - // bottom of their design canvas, so the expanded chart centers the same way inline does. - const isPolar = type === CHART_TYPE.POLAR; - const effectiveDesignHeight = designHeight !== undefined && isPolar ? designHeight * POLAR_CONTAINER_HEIGHT_RATIO : designHeight; - - // 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. + // Visual styles for the fluid fallback, resolved the same way the inline container resolves them. const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme); const borderRadius = chartContainerStyles.borderRadius; @@ -125,46 +87,11 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr onLayout={onContainerLayout} > {isMeasured && - (hasDesignDimensions && effectiveDesignHeight !== undefined ? ( - // 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. */} - - - {/* 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 && ( - - - - )} - - - + (hasDesignDimensions ? ( + ) : ( // 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/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx new file mode 100644 index 000000000000..cdb09b2e2974 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx @@ -0,0 +1,51 @@ +import MultiGestureCanvas from '@components/MultiGestureCanvas'; + +import React from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +import type {VictoryChartExpandedContentProps} from './types'; + +import ExpandedChartBox from './ExpandedChartBox'; +import useExpandedChartLayout from './useExpandedChartLayout'; + +/** + * Touch-device zoom for the expanded chart, mirroring the Lightbox/image-attachment pattern: the + * chart is rendered ONCE at a fixed high resolution (like a 2x image asset) and handed to + * MultiGestureCanvas at that intrinsic size. The canvas computes the fit scale itself and owns the + * single transform for fitting, centering, and pinch/double-tap zooming — no manual transforms of + * our own, since nested transforms rasterize the inner layer and blur it on native. + */ +function BaseVictoryChartExpandedContent({availableSize, isVisible}: VictoryChartExpandedContentProps) { + const {hasLayout, fitScale, zoomHeadroom, renderWidth, renderHeight, clippedRenderHeight, backgroundColor, borderRadius, isPolar} = useExpandedChartLayout(availableSize); + // No pager wraps this canvas, so scrolling never needs to be handed back to one. + const isPagerScrollEnabled = useSharedValue(false); + + if (!hasLayout) { + return null; + } + + return ( + + + + ); +} + +BaseVictoryChartExpandedContent.displayName = 'BaseVictoryChartExpandedContent'; + +export default BaseVictoryChartExpandedContent; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx new file mode 100644 index 000000000000..ce429001ca30 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -0,0 +1,78 @@ +import {VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ColorValue} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +import VictoryChartContent from '../VictoryChartContent'; + +type ExpandedChartBoxProps = { + /** Rendered chart width in pixels */ + width: number; + + /** Rendered chart height in pixels (full design canvas) */ + height: number; + + /** Visible height in pixels — smaller than `height` for polar charts, whose dead bottom space is clipped */ + clippedHeight: number; + + /** Uniform factor the chart's pixel-space config is scaled by for this render size */ + providerScale: number; + + /** Whether the chart canvas should render — removed while the modal is closing to avoid a white flash */ + isVisible: boolean; + + /** Theme-resolved container background parsed from the chart HTML */ + backgroundColor: ColorValue | undefined; + + /** Container corner radius parsed from the chart HTML */ + borderRadius: number | undefined; + + /** Whether the chart is polar — its clip container keeps the rounded corners */ + isPolar: boolean; +}; + +/** + * The expanded chart rendered natively at the given size: an outer clip box (hides polar dead + * space), an inner card with the chart's themed background/rounding, and the chart itself + * re-rendered through VictoryChartScaledProvider so every pixel-space value matches the size. + */ +function ExpandedChartBox({width, height, clippedHeight, providerScale, isVisible, backgroundColor, borderRadius, isPolar}: ExpandedChartBoxProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + return ( + + + {/* 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 && ( + + + + )} + + + ); +} + +ExpandedChartBox.displayName = 'ExpandedChartBox'; + +export default ExpandedChartBox; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx new file mode 100644 index 000000000000..b40b8fcc4bda --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx @@ -0,0 +1 @@ +export {default} from './BaseVictoryChartExpandedContent'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx new file mode 100644 index 000000000000..59ee7dbb2ac8 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -0,0 +1,179 @@ +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; + +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; + +import CONST from '@src/CONST'; + +import type {SyntheticEvent} from 'react'; +import type {GestureResponderEvent, View as RNView} from 'react-native'; + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {VictoryChartExpandedContentProps} from './types'; + +import BaseVictoryChartExpandedContent from './BaseVictoryChartExpandedContent'; +import ExpandedChartBox from './ExpandedChartBox'; +import useExpandedChartLayout from './useExpandedChartLayout'; + +/** + * Desktop-web zoom for the expanded chart, mirroring the image attachment viewer (ImageView): + * a zoom-in/zoom-out cursor, click to zoom into the clicked spot, mouse scroll (or drag while + * zoomed) to pan — instead of the touch pinch/double-tap gestures. + * + * Because desktop zoom is a binary state, the chart is re-rendered natively at each state's exact + * size, so it is pixel-crisp both fitted and zoomed. + */ +function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryChartExpandedContentProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const scrollableRef = useRef(null); + const layout = useExpandedChartLayout(availableSize); + + const [isZoomed, setIsZoomed] = useState(false); + const [zoomDelta, setZoomDelta] = useState<{offsetX: number; offsetY: number}>(); + const [isDragging, setIsDragging] = useState(false); + const [isMouseDown, setIsMouseDown] = useState(false); + const [initialScrollLeft, setInitialScrollLeft] = useState(0); + const [initialScrollTop, setInitialScrollTop] = useState(0); + const [initialX, setInitialX] = useState(0); + const [initialY, setInitialY] = useState(0); + + const onContainerPressIn = (e: GestureResponderEvent) => { + const {pageX, pageY} = e.nativeEvent; + setIsMouseDown(true); + setInitialX(pageX); + setInitialY(pageY); + setInitialScrollLeft(scrollableRef.current?.scrollLeft ?? 0); + setInitialScrollTop(scrollableRef.current?.scrollTop ?? 0); + }; + + const onContainerPress = (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => { + if (!isZoomed && !isDragging) { + if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { + const {offsetX, offsetY} = e.nativeEvent; + // Center the clicked spot in the zoomed view: map the fitted-space point into + // zoomed space and scroll so it sits mid-viewport (clamped to the start edges). + setZoomDelta({ + offsetX: Math.max(0, offsetX * layout.zoomHeadroom - availableSize.width / 2), + offsetY: Math.max(0, offsetY * layout.zoomHeadroom - availableSize.height / 2), + }); + } else { + setZoomDelta({offsetX: 0, offsetY: 0}); + } + } + + if (isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } else { + setIsZoomed(!isZoomed); + setIsMouseDown(false); + } + }; + + const trackPointerPosition = useCallback( + (event: MouseEvent) => { + // Whether the pointer is released inside the scrollable chart area + const isInsideChartView = scrollableRef.current?.contains(event.target as Node); + if (!isInsideChartView && isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } + }, + [isDragging, isMouseDown, isZoomed], + ); + + const trackMovement = useCallback( + (event: MouseEvent) => { + if (!isZoomed) { + return; + } + if (isDragging && isMouseDown && scrollableRef.current) { + const moveX = initialX - event.x; + const moveY = initialY - event.y; + scrollableRef.current.scrollLeft = initialScrollLeft + moveX; + scrollableRef.current.scrollTop = initialScrollTop + moveY; + } + setIsDragging(isMouseDown); + }, + [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed], + ); + + useEffect(() => { + if (!isZoomed || !zoomDelta || !scrollableRef.current) { + return; + } + scrollableRef.current.scrollLeft = zoomDelta.offsetX; + scrollableRef.current.scrollTop = zoomDelta.offsetY; + }, [zoomDelta, isZoomed]); + + useEffect(() => { + document.addEventListener('mousemove', trackMovement); + document.addEventListener('mouseup', trackPointerPosition); + return () => { + document.removeEventListener('mousemove', trackMovement); + document.removeEventListener('mouseup', trackPointerPosition); + }; + }, [trackMovement, trackPointerPosition]); + + if (!layout.hasLayout) { + return null; + } + + return ( + + {/* Fills the viewport so the fitted chart centers. Centering is dropped while zoomed: + flex-centering content larger than the scrollport pushes its start edges before the + scroll origin, making the top/left of the chart unreachable. */} + + + + + + + ); +} + +DesktopVictoryChartExpandedContent.displayName = 'DesktopVictoryChartExpandedContent'; + +/** + * On touch devices the expanded chart zooms like the Lightbox (pinch/double-tap via + * MultiGestureCanvas); on desktop web it zooms like the image attachment viewer (click + scroll). + */ +function VictoryChartExpandedContent(props: VictoryChartExpandedContentProps) { + if (canUseTouchScreenUtil()) { + // eslint-disable-next-line react/jsx-props-no-spreading + return ; + } + // eslint-disable-next-line react/jsx-props-no-spreading + return ; +} + +VictoryChartExpandedContent.displayName = 'VictoryChartExpandedContent'; + +export default VictoryChartExpandedContent; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts new file mode 100644 index 000000000000..be0ad897c2d5 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts @@ -0,0 +1,11 @@ +import type {Dimensions} from '@src/types/utils/Layout'; + +type VictoryChartExpandedContentProps = { + /** The measured area available to the expanded chart inside the modal */ + availableSize: Dimensions; + + /** Whether the modal is visible — the chart canvas is removed while closing to avoid a white flash */ + isVisible: boolean; +}; + +export type {VictoryChartExpandedContentProps}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts new file mode 100644 index 000000000000..6fd9fcfde879 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts @@ -0,0 +1,93 @@ +import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; + +import useTheme from '@hooks/useTheme'; + +import type {Dimensions} from '@src/types/utils/Layout'; + +import type {ColorValue} from 'react-native'; + +// The zoomed render is capped so the canvas never exceeds a safe texture size. +const MAX_CANVAS_DIMENSION = 2048; +// 2x headroom covers typical zoom depth without paying for a larger render surface. +const MAX_ZOOM_HEADROOM = 2; + +type ExpandedChartLayout = { + /** Whether the chart has numeric design dimensions and the available area has been measured */ + hasLayout: boolean; + + /** Uniform scale that fits the chart's (clipped) design box inside the available area (may be > 1) */ + fitScale: number; + + /** The fitted (displayed) size of the chart */ + targetWidth: number; + targetHeight: number; + clippedTargetHeight: number; + + /** The zoomed render size of the chart (fitted size × headroom) */ + zoomHeadroom: number; + renderWidth: number; + renderHeight: number; + clippedRenderHeight: number; + + /** Theme-resolved container visuals parsed from the chart HTML */ + backgroundColor: ColorValue | undefined; + borderRadius: number | undefined; + + /** Whether the chart is polar (pie), whose container is clipped to hide dead canvas space */ + isPolar: boolean; +}; + +/** + * Computes the fitted and zoomed render sizes for the expanded chart from the chart's design + * dimensions and the available modal area, shared by every platform's zoom implementation. + */ +function useExpandedChartLayout(availableSize: Dimensions): ExpandedChartLayout { + const theme = useTheme(); + const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); + + const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined; + const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; + const hasDesignDimensions = !!designWidth && !!designHeight; + const isMeasured = availableSize.width > 0 && availableSize.height > 0; + + // Match the inline container: polar charts are clipped to hide the dead space at the + // bottom of their design canvas, so the expanded chart centers the same way inline does. + const isPolar = type === CHART_TYPE.POLAR; + const effectiveDesignHeight = designHeight !== undefined && isPolar ? designHeight * POLAR_CONTAINER_HEIGHT_RATIO : designHeight; + + const fitScale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1; + + const targetWidth = (designWidth ?? 0) * fitScale; + const targetHeight = (designHeight ?? 0) * fitScale; + const clippedTargetHeight = (effectiveDesignHeight ?? 0) * fitScale; + + 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 the same way VictoryChartContainerFixed + // does inline, so the expanded chart keeps the same (theme-aware) background and rounding. + const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme); + const borderRadius = typeof chartContainerStyles.borderRadius === 'number' ? chartContainerStyles.borderRadius : undefined; + + return { + hasLayout: hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured, + fitScale, + targetWidth, + targetHeight, + clippedTargetHeight, + zoomHeadroom, + renderWidth, + renderHeight, + clippedRenderHeight, + backgroundColor, + borderRadius, + isPolar, + }; +} + +export default useExpandedChartLayout; +export type {ExpandedChartLayout}; From bc1a6642cd52529b783a626bb1eb71f388f00fe3 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Sat, 22 Aug 2026 05:58:56 +0500 Subject: [PATCH 11/18] fix: resolve eslint errors - alias import, default exports, safe node check --- .../BaseVictoryChartExpandedContent.tsx | 2 +- .../VictoryChartExpandedContent/ExpandedChartBox.tsx | 3 +-- .../VictoryChartExpandedContent/index.native.tsx | 4 +++- .../components/VictoryChartExpandedContent/index.tsx | 8 +++----- .../components/VictoryChartExpandedContent/types.ts | 2 +- .../VictoryChartExpandedContent/useExpandedChartLayout.ts | 1 - 6 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx index cdb09b2e2974..2efb9db27a91 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx @@ -3,7 +3,7 @@ import MultiGestureCanvas from '@components/MultiGestureCanvas'; import React from 'react'; import {useSharedValue} from 'react-native-reanimated'; -import type {VictoryChartExpandedContentProps} from './types'; +import type VictoryChartExpandedContentProps from './types'; import ExpandedChartBox from './ExpandedChartBox'; import useExpandedChartLayout from './useExpandedChartLayout'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx index ce429001ca30..ab884d5a3ddd 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -1,3 +1,4 @@ +import VictoryChartContent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent'; import {VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -8,8 +9,6 @@ import type {ColorValue} from 'react-native'; import React from 'react'; import {View} from 'react-native'; -import VictoryChartContent from '../VictoryChartContent'; - type ExpandedChartBoxProps = { /** Rendered chart width in pixels */ width: number; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx index b40b8fcc4bda..d87dc175d292 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx @@ -1 +1,3 @@ -export {default} from './BaseVictoryChartExpandedContent'; +import BaseVictoryChartExpandedContent from './BaseVictoryChartExpandedContent'; + +export default BaseVictoryChartExpandedContent; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx index 59ee7dbb2ac8..8a66e5aa077c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -14,7 +14,7 @@ import type {GestureResponderEvent, View as RNView} from 'react-native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {VictoryChartExpandedContentProps} from './types'; +import type VictoryChartExpandedContentProps from './types'; import BaseVictoryChartExpandedContent from './BaseVictoryChartExpandedContent'; import ExpandedChartBox from './ExpandedChartBox'; @@ -80,7 +80,7 @@ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryC const trackPointerPosition = useCallback( (event: MouseEvent) => { // Whether the pointer is released inside the scrollable chart area - const isInsideChartView = scrollableRef.current?.contains(event.target as Node); + const isInsideChartView = event.target instanceof Node && scrollableRef.current?.contains(event.target); if (!isInsideChartView && isZoomed && isDragging && isMouseDown) { setIsDragging(false); setIsMouseDown(false); @@ -132,7 +132,7 @@ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryC style={[styles.flex1, styles.w100, styles.overflowAuto, styles.pRelative]} > {/* Fills the viewport so the fitted chart centers. Centering is dropped while zoomed: - flex-centering content larger than the scrollport pushes its start edges before the + flex-centering content larger than the scroll viewport pushes its start edges before the scroll origin, making the top/left of the chart unreachable. */} - {/* Explicitly paint the modal surface: during the close animation the unpainted modal base - can flash through as white, which is clearly visible on dark themes. */} - + {/* GestureHandlerRootView is required for MultiGestureCanvas gestures to work inside a + modal on Android, which hosts modals in a separate native window — the same reason + the attachment modal wraps its content in one. It also explicitly paints the modal + surface: during the close animation the unpainted modal base can flash through as + white, which is clearly visible on dark themes. */} + {/* Header matches the attachment modal: back button on narrow layouts, close button on the right otherwise. */} - + ); } From 9a7a3104b5c1af308d298b81c6ac29897f3043d3 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Thu, 3 Sep 2026 03:51:25 +0500 Subject: [PATCH 13/18] refactor: share click-zoom-pan hook with ImageView and fix review bugs - blank close, zoom flicker, browser back --- .../components/VictoryChartBar.tsx | 3 +- .../components/VictoryChartBarGroup.tsx | 3 +- .../components/VictoryChartExpandModal.tsx | 21 +-- .../BaseVictoryChartExpandedContent.tsx | 7 +- .../ExpandedChartBox.tsx | 37 ++-- .../VictoryChartExpandedContent/index.tsx | 167 ++++++---------- .../VictoryChartExpandedContent/types.ts | 5 +- .../useExpandedChartLayout.ts | 4 +- .../components/VictoryChartPie.tsx | 17 +- .../utils/parseCornerRadius.ts | 10 +- .../VictoryChartRenderer/utils/scalePixels.ts | 11 ++ .../utils/scaleVictoryChartContextValue.ts | 38 ++-- src/components/ImageView/index.tsx | 142 ++------------ src/hooks/useClickZoomPan.ts | 178 ++++++++++++++++++ src/styles/utils/index.ts | 12 ++ 15 files changed, 346 insertions(+), 309 deletions(-) create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels.ts create mode 100644 src/hooks/useClickZoomPan.ts diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx index 356d30cb4cfa..678da76a5587 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBar.tsx @@ -5,6 +5,7 @@ import getYKey from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRe import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseAttribute'; import parseCornerRadius from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius'; import parseStyles from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseStyles'; +import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import type {TNode} from 'react-native-render-html'; @@ -25,7 +26,7 @@ function VictoryChartBar({tnode}: VictoryChartBarProps) { color={nodeStyles.fill ?? VictoryTheme.colors.default} innerPadding={BAR_INNER_PADDING} roundedCorners={parseCornerRadius(tnode.attributes.cornerradius, pixelScale)} - barWidth={barWidth === undefined ? undefined : barWidth * pixelScale} + barWidth={scalePixels(barWidth, pixelScale)} /> ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx index bed5e363d880..312a9d7b1a64 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx @@ -6,6 +6,7 @@ import {parseAttributeAsNumber} from '@components/HTMLEngineProvider/HTMLRendere import parseCornerRadius from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius'; import parseOffset from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseOffset'; import parseStyles from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseStyles'; +import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import type {TNode} from 'react-native-render-html'; @@ -28,7 +29,7 @@ function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) const roundedCorners = parseCornerRadius(firstBarChild?.attributes?.cornerradius ?? '', pixelScale); const rawBarWidth = parseAttributeAsNumber(firstBarChild.attributes.barwidth); - const barWidth = rawBarWidth === undefined ? undefined : rawBarWidth * pixelScale; + const barWidth = scalePixels(rawBarWidth, pixelScale); const betweenGroupPadding = barWidth ? parseOffset(tnode.attributes.offset, chartBounds, barChildren.length, barWidth, points[getYKey(firstBarChild)].length, isHorizontal ?? false, pixelScale) : undefined; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index ce3ee079cf05..3cfd82f61e67 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,6 +1,4 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; -import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import Modal from '@components/Modal'; import useLocalize from '@hooks/useLocalize'; @@ -19,6 +17,7 @@ import {GestureHandlerRootView} from 'react-native-gesture-handler'; import VictoryChartContent from './VictoryChartContent'; import VictoryChartExpandedContent from './VictoryChartExpandedContent'; +import useExpandedChartLayout from './VictoryChartExpandedContent/useExpandedChartLayout'; type VictoryChartExpandModalProps = { /** Whether the modal is visible */ @@ -40,8 +39,8 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const theme = useTheme(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {chartContentStyles, chartContainerStyles} = useVictoryChartContext(); const [availableSize, setAvailableSize] = useState({width: 0, height: 0}); + const layout = useExpandedChartLayout(availableSize); const onContainerLayout = (event: LayoutChangeEvent) => { // Ignore layout changes while the modal is closing — re-measuring mid-animation @@ -54,18 +53,15 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr setAvailableSize((prev) => (prev.width === width && prev.height === height ? prev : {width, height})); }; - const hasDesignDimensions = typeof chartContentStyles.width === 'number' && typeof chartContentStyles.height === 'number'; const isMeasured = availableSize.width > 0 && availableSize.height > 0; - // Visual styles for the fluid fallback, resolved the same way the inline container resolves them. - const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme); - const borderRadius = chartContainerStyles.borderRadius; - return ( {/* GestureHandlerRootView is required for MultiGestureCanvas gestures to work inside a @@ -91,10 +87,11 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr onLayout={onContainerLayout} > {isMeasured && - (hasDesignDimensions ? ( + (layout.hasLayout ? ( ) : ( // Charts without design dimensions have no design-based label coordinates, so fluid @@ -104,12 +101,12 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr style={[ styles.w100, styles.flex1, - backgroundColor !== undefined && StyleUtils.getBackgroundColorStyle(backgroundColor), - typeof borderRadius === 'number' && StyleUtils.getBorderRadiusStyle(borderRadius), + layout.backgroundColor !== undefined && StyleUtils.getBackgroundColorStyle(layout.backgroundColor), + layout.borderRadius !== undefined && StyleUtils.getBorderRadiusStyle(layout.borderRadius), styles.overflowHidden, ]} > - {isVisible && } + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx index 2efb9db27a91..230dcac7a5da 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx @@ -15,7 +15,7 @@ import useExpandedChartLayout from './useExpandedChartLayout'; * single transform for fitting, centering, and pinch/double-tap zooming — no manual transforms of * our own, since nested transforms rasterize the inner layer and blur it on native. */ -function BaseVictoryChartExpandedContent({availableSize, isVisible}: VictoryChartExpandedContentProps) { +function BaseVictoryChartExpandedContent({availableSize, isVisible, onSwipeDown}: VictoryChartExpandedContentProps) { const {hasLayout, fitScale, zoomHeadroom, renderWidth, renderHeight, clippedRenderHeight, backgroundColor, borderRadius, isPolar} = useExpandedChartLayout(availableSize); // No pager wraps this canvas, so scrolling never needs to be handed back to one. const isPagerScrollEnabled = useSharedValue(false); @@ -29,15 +29,18 @@ function BaseVictoryChartExpandedContent({availableSize, isVisible}: VictoryChar isActive={isVisible} canvasSize={availableSize} contentSize={{width: renderWidth, height: clippedRenderHeight}} + // Zooming past the rendered resolution would upscale pixels and blur the chart — cap + // the zoom at the headroom the chart was actually rendered with. + zoomRange={{max: zoomHeadroom}} isUsedInCarousel={false} isPagerScrollEnabled={isPagerScrollEnabled} + onSwipeDown={onSwipeDown} > - {/* 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 && ( - - - - )} + + + ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx index 8a66e5aa077c..909d51f64521 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -1,5 +1,6 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import useClickZoomPan from '@hooks/useClickZoomPan'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -8,10 +9,9 @@ import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabiliti import CONST from '@src/CONST'; -import type {SyntheticEvent} from 'react'; -import type {GestureResponderEvent, View as RNView} from 'react-native'; +import type {View as RNView} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import type VictoryChartExpandedContentProps from './types'; @@ -23,10 +23,11 @@ import useExpandedChartLayout from './useExpandedChartLayout'; /** * Desktop-web zoom for the expanded chart, mirroring the image attachment viewer (ImageView): * a zoom-in/zoom-out cursor, click to zoom into the clicked spot, mouse scroll (or drag while - * zoomed) to pan — instead of the touch pinch/double-tap gestures. + * zoomed) to pan — via the same useClickZoomPan hook the image viewer uses. * - * Because desktop zoom is a binary state, the chart is re-rendered natively at each state's exact - * size, so it is pixel-crisp both fitted and zoomed. + * Like a high-resolution image, the chart is rendered ONCE at the zoomed size and displayed + * downscaled while fitted (crisp both ways), so toggling zoom only changes CSS — the Skia canvas + * never re-renders and there is no flicker. */ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryChartExpandedContentProps) { const styles = useThemeStyles(); @@ -35,125 +36,75 @@ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryC const scrollableRef = useRef(null); const layout = useExpandedChartLayout(availableSize); - const [isZoomed, setIsZoomed] = useState(false); - const [zoomDelta, setZoomDelta] = useState<{offsetX: number; offsetY: number}>(); - const [isDragging, setIsDragging] = useState(false); - const [isMouseDown, setIsMouseDown] = useState(false); - const [initialScrollLeft, setInitialScrollLeft] = useState(0); - const [initialScrollTop, setInitialScrollTop] = useState(0); - const [initialX, setInitialX] = useState(0); - const [initialY, setInitialY] = useState(0); - - const onContainerPressIn = (e: GestureResponderEvent) => { - const {pageX, pageY} = e.nativeEvent; - setIsMouseDown(true); - setInitialX(pageX); - setInitialY(pageY); - setInitialScrollLeft(scrollableRef.current?.scrollLeft ?? 0); - setInitialScrollTop(scrollableRef.current?.scrollTop ?? 0); - }; - - const onContainerPress = (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => { - if (!isZoomed && !isDragging) { - if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { - const {offsetX, offsetY} = e.nativeEvent; - // Center the clicked spot in the zoomed view: map the fitted-space point into - // zoomed space and scroll so it sits mid-viewport (clamped to the start edges). - setZoomDelta({ - offsetX: Math.max(0, offsetX * layout.zoomHeadroom - availableSize.width / 2), - offsetY: Math.max(0, offsetY * layout.zoomHeadroom - availableSize.height / 2), - }); - } else { - setZoomDelta({offsetX: 0, offsetY: 0}); - } - } + // On large displays the fitted chart can already use all the zoom headroom, in which case + // clicking could not enlarge anything — hide the zoom affordance entirely. + const canZoom = layout.zoomHeadroom > 1; - if (isZoomed && isDragging && isMouseDown) { - setIsDragging(false); - setIsMouseDown(false); - } else { - setIsZoomed(!isZoomed); - setIsMouseDown(false); - } - }; - - const trackPointerPosition = useCallback( - (event: MouseEvent) => { - // Whether the pointer is released inside the scrollable chart area - const isInsideChartView = event.target instanceof Node && scrollableRef.current?.contains(event.target); - if (!isInsideChartView && isZoomed && isDragging && isMouseDown) { - setIsDragging(false); - setIsMouseDown(false); - } - }, - [isDragging, isMouseDown, isZoomed], - ); - - const trackMovement = useCallback( - (event: MouseEvent) => { - if (!isZoomed) { - return; - } - if (isDragging && isMouseDown && scrollableRef.current) { - const moveX = initialX - event.x; - const moveY = initialY - event.y; - scrollableRef.current.scrollLeft = initialScrollLeft + moveX; - scrollableRef.current.scrollTop = initialScrollTop + moveY; - } - setIsDragging(isMouseDown); - }, - [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed], - ); + const {isZoomed, isDragging, onContainerPressIn, onContainerPress, resetZoom} = useClickZoomPan({ + scrollableRef, + containerSize: availableSize, + zoomFactor: layout.zoomHeadroom, + }); + // The modal stays mounted after closing so it reopens fast — reset the zoom so it never + // reopens in a stale zoomed state (the touch path resets via MultiGestureCanvas.isActive). useEffect(() => { - if (!isZoomed || !zoomDelta || !scrollableRef.current) { + if (isVisible) { return; } - scrollableRef.current.scrollLeft = zoomDelta.offsetX; - scrollableRef.current.scrollTop = zoomDelta.offsetY; - }, [zoomDelta, isZoomed]); - - useEffect(() => { - document.addEventListener('mousemove', trackMovement); - document.addEventListener('mouseup', trackPointerPosition); - return () => { - document.removeEventListener('mousemove', trackMovement); - document.removeEventListener('mouseup', trackPointerPosition); - }; - }, [trackMovement, trackPointerPosition]); + resetZoom(); + }, [isVisible, resetZoom]); if (!layout.hasLayout) { return null; } + const chartBox = ( + + {/* The chart is always rendered at the zoomed size; while fitted it is displayed + downscaled — like a 2x image asset — so zooming never re-renders the canvas. */} + + + + + ); + return ( {/* Fills the viewport so the fitted chart centers. Centering is dropped while zoomed: - flex-centering content larger than the scroll viewport pushes its start edges before the - scroll origin, making the top/left of the chart unreachable. */} + flex-centering content larger than the scroll viewport pushes its start edges before + the scroll origin, making the top/left of the chart unreachable. */} - - - + {canZoom ? ( + + {chartBox} + + ) : ( + chartBox + )} ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts index 6b96db60cef4..5e29f3fa9d89 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts @@ -4,8 +4,11 @@ type VictoryChartExpandedContentProps = { /** The measured area available to the expanded chart inside the modal */ availableSize: Dimensions; - /** Whether the modal is visible — the chart canvas is removed while closing to avoid a white flash */ + /** Whether the modal is visible — gestures are deactivated and zoom state is reset while closed */ isVisible: boolean; + + /** Called when the user swipes the chart down on touch devices, matching the attachment viewer */ + onSwipeDown?: () => void; }; export default VictoryChartExpandedContentProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts index cb26a4901bdd..648bafa23d8f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts @@ -8,7 +8,9 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {ColorValue} from 'react-native'; -// The zoomed render is capped so the canvas never exceeds a safe texture size. +// The zoom headroom shrinks (down to 1, i.e. no zoom) once the fitted render approaches this +// size, so zooming never allocates an excessively large canvas. The fitted render itself is +// never reduced — the chart must at least fill the viewport. const MAX_CANVAS_DIMENSION = 2048; // 2x headroom covers typical zoom depth without paying for a larger render surface. const MAX_ZOOM_HEADROOM = 2; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx index 0a86e0458c36..84bf11d2cbff 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 scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import {scaleLabelItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; import useTheme from '@hooks/useTheme'; @@ -56,12 +57,12 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { const baseLabelItem = rawBaseLabelItem && pixelScale !== 1 ? scaleLabelItem(rawBaseLabelItem, pixelScale) : rawBaseLabelItem; const pieLabels = parseAttributeAsStringArray(tnode.attributes.labels); const rawLabelRadius = parseAttributeAsNumber(tnode.attributes.labelradius); - const labelRadius = rawLabelRadius === undefined ? undefined : rawLabelRadius * pixelScale; + const labelRadius = scalePixels(rawLabelRadius, pixelScale); const rawInnerRadius = parseAttributeAsNumber(tnode.attributes.innerradius); - const innerRadius = rawInnerRadius === undefined ? undefined : rawInnerRadius * pixelScale; + const innerRadius = scalePixels(rawInnerRadius, pixelScale); const padAngle = parseAttributeAsNumber(tnode.attributes.padangle); const rawRadius = parseAttributeAsNumber(tnode.attributes.radius); - const radius = rawRadius === undefined ? undefined : rawRadius * pixelScale; + const radius = scalePixels(rawRadius, pixelScale); const effectiveLabelRadius = labelRadius ?? radius; const size = radius ? radius * 2 : undefined; const angularStrokeWidth = padAngle && radius ? 2 * convertAngleToArcLength(padAngle, radius) : 0; @@ -70,14 +71,14 @@ function VictoryChartPie({tnode}: VictoryChartPieProps) { const labelIndicatorNode = parseComponent(tnode.attributes.labelindicator, renderEngine, 'shiftedlinesegment', HTMLContentModel.block); const labelIndicatorStyles = labelIndicatorNode ? parseShiftedLineSegmentNode(labelIndicatorNode) : undefined; 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 labelIndicatorXShift = scalePixels(rawIndicatorXShift, pixelScale); + const labelIndicatorYShift = scalePixels(rawIndicatorYShift, pixelScale); + const labelIndicatorStrokeWidth = scalePixels(rawIndicatorStrokeWidth, pixelScale); const labelIndicatorStroke = resolveChartThemeColor(labelIndicatorStyles?.stroke, theme); const rawIndicatorInnerOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorinneroffset); - const labelIndicatorInnerOffset = rawIndicatorInnerOffset === undefined ? undefined : rawIndicatorInnerOffset * pixelScale; + const labelIndicatorInnerOffset = scalePixels(rawIndicatorInnerOffset, pixelScale); const rawIndicatorOuterOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorouteroffset); - const labelIndicatorOuterOffset = rawIndicatorOuterOffset === undefined ? undefined : rawIndicatorOuterOffset * pixelScale; + const labelIndicatorOuterOffset = scalePixels(rawIndicatorOuterOffset, pixelScale); const customLabelByDataLabel: Record = {}; const sliceValues: PieSliceValue[] = []; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts index 5c2904aeb68f..8782e2d727c5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts @@ -1,3 +1,5 @@ +import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; + import type {RoundedCorners} from 'victory-native'; import lodashIsObject from 'lodash/isObject'; @@ -45,10 +47,10 @@ function parseCornerRadius(attribute: string, pixelScale = 1): RoundedCorners | bottomRight = Number(cornerRadius.bottom); } 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, + topLeft: scalePixels(topLeft, pixelScale), + topRight: scalePixels(topRight, pixelScale), + bottomLeft: scalePixels(bottomLeft, pixelScale), + bottomRight: scalePixels(bottomRight, pixelScale), }; } return undefined; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels.ts new file mode 100644 index 000000000000..1f690d8c629a --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels.ts @@ -0,0 +1,11 @@ +/** + * Scales a pixel-space value by a uniform factor, passing `undefined` through — the shared + * primitive for re-rendering a chart's parsed pixel attributes at a different size. + */ +function scalePixels(value: number, scale: number): number; +function scalePixels(value: number | undefined, scale: number): number | undefined; +function scalePixels(value: number | undefined, scale: number): number | undefined { + return value === undefined ? undefined : value * scale; +} + +export default scalePixels; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts index 552d379215b0..19cfe5308e2c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -1,5 +1,6 @@ import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import type {LabelItem, LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import type {SkFont, SkTypeface} from '@shopify/react-native-skia'; @@ -34,12 +35,12 @@ function scaleLegendItem(legendItem: LegendItem, scale: number): LegendItem { ...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, + gutter: scalePixels(legendItem.gutter, scale), + symbolSpacer: scalePixels(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, + fontSize: scalePixels(entry.fontSize, scale), + symbolSize: scalePixels(entry.symbolSize, scale), })), }; } @@ -48,15 +49,15 @@ type SidedPixelValues = {left?: number; right?: number; top?: number; bottom?: n 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, + left: scalePixels(sides.left, scale), + right: scalePixels(sides.right, scale), + top: scalePixels(sides.top, scale), + bottom: scalePixels(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'] { +/** (Domain) padding can be a plain number or a per-side object — scale every numeric part. */ +function scalePadding(padding: number | SidedPixelValues | undefined, scale: number): number | SidedPixelValues | undefined { if (padding === undefined) { return undefined; } @@ -66,17 +67,6 @@ function scalePadding(padding: VictoryChartContextValue['padding'], scale: numbe 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 using the chart's shared typeface; the original font * object is left untouched. The typeface must be passed in rather than read via `font.getTypeface()` @@ -95,8 +85,8 @@ function scaleAxis scaleAxis(axis, scale, typeface)), - domainPadding: scaleDomainPadding(value.domainPadding, scale), + domainPadding: scalePadding(value.domainPadding, scale), padding: scalePadding(value.padding, scale), labelItems: value.labelItems.map((labelItem) => scaleLabelItem(labelItem, scale)), legendItems: value.legendItems.map((legendItem) => scaleLegendItem(legendItem, scale)), diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx index 8e6a947cf00b..33118971cfa1 100644 --- a/src/components/ImageView/index.tsx +++ b/src/components/ImageView/index.tsx @@ -6,6 +6,7 @@ import Lightbox from '@components/Lightbox'; import LoadingIndicator from '@components/LoadingIndicator'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import useClickZoomPan from '@hooks/useClickZoomPan'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -16,10 +17,9 @@ import {isLocalFile} from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; import type {Dimensions} from '@src/types/utils/Layout'; -import type {SyntheticEvent} from 'react'; -import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import type ImageViewProps from './types'; @@ -32,8 +32,6 @@ function calculateZoomScale(containerSize: Dimensions, imageSize: Dimensions) { return Math.min(containerSize.width / imageSize.width, containerSize.height / imageSize.height); } -type ZoomDelta = {offsetX: number; offsetY: number}; - function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageViewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -42,20 +40,19 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV const canUseTouchScreen = canUseTouchScreenUtil(); const [isLoading, setIsLoading] = useState(true); - const [isZoomed, setIsZoomed] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [isMouseDown, setIsMouseDown] = useState(false); - const [initialScrollLeft, setInitialScrollLeft] = useState(0); - const [initialScrollTop, setInitialScrollTop] = useState(0); - const [initialX, setInitialX] = useState(0); - const [initialY, setInitialY] = useState(0); - const [containerSize, setContainerSize] = useState({width: 0, height: 0}); const [imageSize, setImageSize] = useState({width: 0, height: 0}); - const [zoomDelta, setZoomDelta] = useState(); const zoomScale = calculateZoomScale(containerSize, imageSize); + // The image is displayed at `zoomScale` of its natural size, so a displayed point maps into + // the zoomed (natural-size) render by the inverse of that scale. + const {isZoomed, isDragging, onContainerPressIn, onContainerPress, resetZoom} = useClickZoomPan({ + scrollableRef, + containerSize, + zoomFactor: zoomScale > 0 ? 1 / zoomScale : 0, + }); + const onContainerLayoutChanged = (e: LayoutChangeEvent) => { setContainerSize(e.nativeEvent.layout); }; @@ -68,7 +65,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV setImageSize({width: 0, height: 0}); setIsLoading(true); - setIsZoomed(false); + resetZoom(); }; const imageLoad = ({nativeEvent: size}: ImageOnLoadEvent) => { @@ -79,119 +76,6 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV setIsLoading(false); }; - const onContainerPressIn = (e: GestureResponderEvent) => { - const {pageX, pageY} = e.nativeEvent; - setIsMouseDown(true); - setInitialX(pageX); - setInitialY(pageY); - setInitialScrollLeft(scrollableRef.current?.scrollLeft ?? 0); - setInitialScrollTop(scrollableRef.current?.scrollTop ?? 0); - }; - - /** - * Convert touch point to zoomed point - * @param x point when click zoom - * @param y point when click zoom - * @returns converted touch point - */ - const getScrollOffset = (x: number, y: number) => { - let offsetX = 0; - let offsetY = 0; - - // Container size bigger than clicked position offset - if (x <= containerSize.width / 2) { - offsetX = 0; - } else if (x > containerSize.width / 2) { - // Minus half of container size because we want to be center clicked position - offsetX = x - containerSize.width / 2; - } - if (y <= containerSize.height / 2) { - offsetY = 0; - } else if (y > containerSize.height / 2) { - // Minus half of container size because we want to be center clicked position - offsetY = y - containerSize.height / 2; - } - return {offsetX, offsetY}; - }; - - const onContainerPress = (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => { - if (!isZoomed && !isDragging) { - if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { - const {offsetX, offsetY} = e.nativeEvent; - - // Dividing clicked positions by the zoom scale to get coordinates - // so that once we zoom we will scroll to the clicked location. - const delta = getScrollOffset(offsetX / zoomScale, offsetY / zoomScale); - setZoomDelta(delta); - } else { - setZoomDelta({offsetX: 0, offsetY: 0}); - } - } - - if (isZoomed && isDragging && isMouseDown) { - setIsDragging(false); - setIsMouseDown(false); - } else { - // We first zoom and once its done then we scroll to the location the user clicked. - setIsZoomed(!isZoomed); - setIsMouseDown(false); - } - }; - - const trackPointerPosition = useCallback( - (event: MouseEvent) => { - // Whether the pointer is released inside the ImageView - const isInsideImageView = scrollableRef.current?.contains(event.target as Node); - - if (!isInsideImageView && isZoomed && isDragging && isMouseDown) { - setIsDragging(false); - setIsMouseDown(false); - } - }, - [isDragging, isMouseDown, isZoomed], - ); - - const trackMovement = useCallback( - (event: MouseEvent) => { - if (!isZoomed) { - return; - } - - if (isDragging && isMouseDown && scrollableRef.current) { - const x = event.x; - const y = event.y; - const moveX = initialX - x; - const moveY = initialY - y; - scrollableRef.current.scrollLeft = initialScrollLeft + moveX; - scrollableRef.current.scrollTop = initialScrollTop + moveY; - } - - setIsDragging(isMouseDown); - }, - [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed], - ); - - useEffect(() => { - if (!isZoomed || !zoomDelta || !scrollableRef.current) { - return; - } - scrollableRef.current.scrollLeft = zoomDelta.offsetX; - scrollableRef.current.scrollTop = zoomDelta.offsetY; - }, [zoomDelta, isZoomed]); - - useEffect(() => { - if (canUseTouchScreen) { - return; - } - document.addEventListener('mousemove', trackMovement); - document.addEventListener('mouseup', trackPointerPosition); - - return () => { - document.removeEventListener('mousemove', trackMovement); - document.removeEventListener('mouseup', trackPointerPosition); - }; - }, [canUseTouchScreen, trackMovement, trackPointerPosition]); - // isLocalToUserDeviceFile means the file is located on the user device, // not loaded on the server yet (the user is offline when loading this file in fact) let isLocalToUserDeviceFile = isLocalFile(url); @@ -242,7 +126,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV waitForSession={() => { setImageSize({width: 0, height: 0}); setIsLoading(true); - setIsZoomed(false); + resetZoom(); }} onError={onError} /> diff --git a/src/hooks/useClickZoomPan.ts b/src/hooks/useClickZoomPan.ts new file mode 100644 index 000000000000..8c8d3eafd6a9 --- /dev/null +++ b/src/hooks/useClickZoomPan.ts @@ -0,0 +1,178 @@ +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; + +import type {Dimensions} from '@src/types/utils/Layout'; + +import type {RefObject, SyntheticEvent} from 'react'; +import type {GestureResponderEvent, View} from 'react-native'; + +import {useCallback, useEffect, useState} from 'react'; + +type ZoomDelta = {offsetX: number; offsetY: number}; + +type UseClickZoomPanParams = { + /** The scrollable element the zoomed content overflows into */ + scrollableRef: RefObject<(View & HTMLDivElement) | null>; + + /** The size of the visible scroll area, used to center the clicked point after zooming */ + containerSize: Dimensions; + + /** Multiplier that maps a point in displayed (fitted) space to the same point in zoomed space */ + zoomFactor: number; +}; + +type UseClickZoomPanResult = { + /** Whether the content is currently zoomed in */ + isZoomed: boolean; + + /** Whether the user is currently dragging to pan the zoomed content */ + isDragging: boolean; + + /** Press-in handler for the pressable zoom area — records the drag start position */ + onContainerPressIn: (e: GestureResponderEvent) => void; + + /** Press handler for the pressable zoom area — toggles zoom or ends a drag */ + onContainerPress: (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => void; + + /** Resets all zoom/drag state, e.g. when the content reloads or its container closes */ + resetZoom: () => void; +}; + +/** + * Desktop-web click-to-zoom with scroll/drag panning, shared by the image attachment viewer + * (ImageView) and the expanded chart so both zoom identically: click zooms in centered on the + * clicked point, mouse scroll or drag pans while zoomed, and clicking again zooms back out. + */ +function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoomPanParams): UseClickZoomPanResult { + const canUseTouchScreen = canUseTouchScreenUtil(); + + const [isZoomed, setIsZoomed] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [isMouseDown, setIsMouseDown] = useState(false); + const [initialScrollLeft, setInitialScrollLeft] = useState(0); + const [initialScrollTop, setInitialScrollTop] = useState(0); + const [initialX, setInitialX] = useState(0); + const [initialY, setInitialY] = useState(0); + const [zoomDelta, setZoomDelta] = useState(); + + const onContainerPressIn = (e: GestureResponderEvent) => { + const {pageX, pageY} = e.nativeEvent; + setIsMouseDown(true); + setInitialX(pageX); + setInitialY(pageY); + setInitialScrollLeft(scrollableRef.current?.scrollLeft ?? 0); + setInitialScrollTop(scrollableRef.current?.scrollTop ?? 0); + }; + + /** + * Convert touch point to zoomed point + * @param x point when click zoom + * @param y point when click zoom + * @returns converted touch point + */ + const getScrollOffset = (x: number, y: number) => { + let offsetX = 0; + let offsetY = 0; + + // Container size bigger than clicked position offset + if (x <= containerSize.width / 2) { + offsetX = 0; + } else if (x > containerSize.width / 2) { + // Minus half of container size because we want to be center clicked position + offsetX = x - containerSize.width / 2; + } + if (y <= containerSize.height / 2) { + offsetY = 0; + } else if (y > containerSize.height / 2) { + // Minus half of container size because we want to be center clicked position + offsetY = y - containerSize.height / 2; + } + return {offsetX, offsetY}; + }; + + const onContainerPress = (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => { + if (!isZoomed && !isDragging) { + if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { + const {offsetX, offsetY} = e.nativeEvent; + + // Multiplying clicked positions by the zoom factor to get zoomed-space coordinates + // so that once we zoom we will scroll to the clicked location. + const delta = getScrollOffset(offsetX * zoomFactor, offsetY * zoomFactor); + setZoomDelta(delta); + } else { + setZoomDelta({offsetX: 0, offsetY: 0}); + } + } + + if (isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } else { + // We first zoom and once its done then we scroll to the location the user clicked. + setIsZoomed(!isZoomed); + setIsMouseDown(false); + } + }; + + const resetZoom = useCallback(() => { + setIsZoomed(false); + setIsDragging(false); + setIsMouseDown(false); + setZoomDelta(undefined); + }, []); + + const trackPointerPosition = useCallback( + (event: MouseEvent) => { + // Whether the pointer is released inside the scrollable container + const isInsideContainer = event.target instanceof Node && scrollableRef.current?.contains(event.target); + + if (!isInsideContainer && isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } + }, + [isDragging, isMouseDown, isZoomed, scrollableRef], + ); + + const trackMovement = useCallback( + (event: MouseEvent) => { + if (!isZoomed) { + return; + } + + if (isDragging && isMouseDown && scrollableRef.current) { + const moveX = initialX - event.x; + const moveY = initialY - event.y; + scrollableRef.current.scrollLeft = initialScrollLeft + moveX; + scrollableRef.current.scrollTop = initialScrollTop + moveY; + } + + setIsDragging(isMouseDown); + }, + [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed, scrollableRef], + ); + + useEffect(() => { + if (!isZoomed || !zoomDelta || !scrollableRef.current) { + return; + } + scrollableRef.current.scrollLeft = zoomDelta.offsetX; + scrollableRef.current.scrollTop = zoomDelta.offsetY; + }, [zoomDelta, isZoomed, scrollableRef]); + + useEffect(() => { + if (canUseTouchScreen) { + return; + } + document.addEventListener('mousemove', trackMovement); + document.addEventListener('mouseup', trackPointerPosition); + + return () => { + document.removeEventListener('mousemove', trackMovement); + document.removeEventListener('mouseup', trackPointerPosition); + }; + }, [canUseTouchScreen, trackMovement, trackPointerPosition]); + + return {isZoomed, isDragging, onContainerPressIn, onContainerPress, resetZoom}; +} + +export default useClickZoomPan; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 489bafa8dfa1..a296f1ef1917 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1076,6 +1076,17 @@ function getTransformScaleStyle(scaleValue: AnimatableNumericValue): ViewStyle { }; } +/** + * Scales a view about its top-left corner, e.g. to display high-resolution content + * at a smaller size inside a clipping box without re-rendering it. + */ +function getTopLeftTransformScaleStyle(scaleValue: number): ViewStyle { + return { + transform: [{scale: scaleValue}], + transformOrigin: 'top left', + }; +} + /** * Returns a style object with a rotation transformation applied based on the provided direction prop. * @@ -1456,6 +1467,7 @@ const staticStyleUtils = { getEmojiPickerStyle, getEmojiReactionBubbleTextStyle, getTransformScaleStyle, + getTopLeftTransformScaleStyle, getCodeFontSize, getFontSizeStyle, getLineHeightStyle, From 6b54ae627422e90ff9be6f4671341fd7cb72da49 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 4 Sep 2026 16:54:24 +0500 Subject: [PATCH 14/18] fix: resolve lint errors - relative sibling imports, param-reassign in zoom hook, typography disable in test --- .../utils/parseCornerRadius.ts | 3 +-- .../utils/scaleVictoryChartContextValue.ts | 3 ++- src/hooks/useClickZoomPan.ts | 14 ++++++++------ .../scaleVictoryChartContextValueTest.ts | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts index 8782e2d727c5..7691a37db94e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts @@ -1,10 +1,9 @@ -import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; - import type {RoundedCorners} from 'victory-native'; import lodashIsObject from 'lodash/isObject'; import parseAttribute from './parseAttribute'; +import scalePixels from './scalePixels'; /** * Translate VictoryChart's `cornerRadius` attribute into victory-native's `roundedCorners` shape. diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts index 19cfe5308e2c..42de7c69ee35 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -1,11 +1,12 @@ import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import type {LabelItem, LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; -import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import type {SkFont, SkTypeface} from '@shopify/react-native-skia'; import {Skia} from '@shopify/react-native-skia'; +import scalePixels from './scalePixels'; + /** * 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 diff --git a/src/hooks/useClickZoomPan.ts b/src/hooks/useClickZoomPan.ts index 8c8d3eafd6a9..bde6aba3cb54 100644 --- a/src/hooks/useClickZoomPan.ts +++ b/src/hooks/useClickZoomPan.ts @@ -139,11 +139,12 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo return; } - if (isDragging && isMouseDown && scrollableRef.current) { + const scrollableContainer = scrollableRef.current; + if (isDragging && isMouseDown && scrollableContainer) { const moveX = initialX - event.x; const moveY = initialY - event.y; - scrollableRef.current.scrollLeft = initialScrollLeft + moveX; - scrollableRef.current.scrollTop = initialScrollTop + moveY; + scrollableContainer.scrollLeft = initialScrollLeft + moveX; + scrollableContainer.scrollTop = initialScrollTop + moveY; } setIsDragging(isMouseDown); @@ -152,11 +153,12 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo ); useEffect(() => { - if (!isZoomed || !zoomDelta || !scrollableRef.current) { + const scrollableContainer = scrollableRef.current; + if (!isZoomed || !zoomDelta || !scrollableContainer) { return; } - scrollableRef.current.scrollLeft = zoomDelta.offsetX; - scrollableRef.current.scrollTop = zoomDelta.offsetY; + scrollableContainer.scrollLeft = zoomDelta.offsetX; + scrollableContainer.scrollTop = zoomDelta.offsetY; }, [zoomDelta, isZoomed, scrollableRef]); useEffect(() => { diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts index 69422d6cfa31..62115530a793 100644 --- a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -1,4 +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 */ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention, rulesdir/no-raw-typography -- test-only: chart context mocks are narrowed from minimal literals, per-line font maps are keyed by numeric line index, and the font sizes are parsed chart pixel attributes, not UI typography */ import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; From 0e1bc2494fa0619f8245b6ea0e47fa824c308832 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 4 Sep 2026 17:24:25 +0500 Subject: [PATCH 15/18] fix: remove manual memoization per React Compiler guidance and cap double-tap zoom at rendered resolution --- .../useExpandedChartLayout.ts | 6 +- .../context/VictoryChartContext.tsx | 5 +- src/hooks/useClickZoomPan.ts | 57 +++++++++---------- 3 files changed, 33 insertions(+), 35 deletions(-) diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts index 648bafa23d8f..38e3c15d3a0d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts @@ -1,6 +1,7 @@ import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; +import {DOUBLE_TAP_SCALE} from '@components/MultiGestureCanvas/constants'; import useTheme from '@hooks/useTheme'; @@ -12,8 +13,9 @@ import type {ColorValue} from 'react-native'; // size, so zooming never allocates an excessively large canvas. The fitted render itself is // never reduced — the chart must at least fill the viewport. const MAX_CANVAS_DIMENSION = 2048; -// 2x headroom covers typical zoom depth without paying for a larger render surface. -const MAX_ZOOM_HEADROOM = 2; +// MultiGestureCanvas double-taps to at least DOUBLE_TAP_SCALE regardless of zoomRange, so the +// headroom must reach it for double-tap to land exactly on rendered (not upscaled) pixels. +const MAX_ZOOM_HEADROOM = DOUBLE_TAP_SCALE; type ExpandedChartLayout = { /** Whether the chart has numeric design dimensions and the available area has been measured */ diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index 37fed8519318..31369a766681 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -8,7 +8,7 @@ import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRe import type {TNode} from 'react-native-render-html'; -import React, {createContext, useContext, useMemo} from 'react'; +import React, {createContext, useContext} from 'react'; type VictoryChartContextValue = { tnode: TNode; @@ -106,7 +106,8 @@ function VictoryChartScaledProvider({scale, children}: VictoryChartScaledProvide const value = useVictoryChartContext(); const typefaces = useChartTypefaces(); const typeface = getVictoryChartTreeTypeface(typefaces); - const scaledValue = useMemo(() => scaleVictoryChartContextValue(value, scale, typeface), [value, scale, typeface]); + // No manual memoization — React Compiler memoizes this call automatically. + const scaledValue = scaleVictoryChartContextValue(value, scale, typeface); return {children}; } diff --git a/src/hooks/useClickZoomPan.ts b/src/hooks/useClickZoomPan.ts index bde6aba3cb54..d6cec1163813 100644 --- a/src/hooks/useClickZoomPan.ts +++ b/src/hooks/useClickZoomPan.ts @@ -5,7 +5,7 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {RefObject, SyntheticEvent} from 'react'; import type {GestureResponderEvent, View} from 'react-native'; -import {useCallback, useEffect, useState} from 'react'; +import {useEffect, useState} from 'react'; type ZoomDelta = {offsetX: number; offsetY: number}; @@ -113,44 +113,39 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo } }; - const resetZoom = useCallback(() => { + // No manual memoization anywhere in this hook — React Compiler stabilizes these callbacks. + const resetZoom = () => { setIsZoomed(false); setIsDragging(false); setIsMouseDown(false); setZoomDelta(undefined); - }, []); + }; - const trackPointerPosition = useCallback( - (event: MouseEvent) => { - // Whether the pointer is released inside the scrollable container - const isInsideContainer = event.target instanceof Node && scrollableRef.current?.contains(event.target); + const trackPointerPosition = (event: MouseEvent) => { + // Whether the pointer is released inside the scrollable container + const isInsideContainer = event.target instanceof Node && scrollableRef.current?.contains(event.target); - if (!isInsideContainer && isZoomed && isDragging && isMouseDown) { - setIsDragging(false); - setIsMouseDown(false); - } - }, - [isDragging, isMouseDown, isZoomed, scrollableRef], - ); - - const trackMovement = useCallback( - (event: MouseEvent) => { - if (!isZoomed) { - return; - } + if (!isInsideContainer && isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } + }; - const scrollableContainer = scrollableRef.current; - if (isDragging && isMouseDown && scrollableContainer) { - const moveX = initialX - event.x; - const moveY = initialY - event.y; - scrollableContainer.scrollLeft = initialScrollLeft + moveX; - scrollableContainer.scrollTop = initialScrollTop + moveY; - } + const trackMovement = (event: MouseEvent) => { + if (!isZoomed) { + return; + } - setIsDragging(isMouseDown); - }, - [initialScrollLeft, initialScrollTop, initialX, initialY, isDragging, isMouseDown, isZoomed, scrollableRef], - ); + const scrollableContainer = scrollableRef.current; + if (isDragging && isMouseDown && scrollableContainer) { + const moveX = initialX - event.x; + const moveY = initialY - event.y; + scrollableContainer.scrollLeft = initialScrollLeft + moveX; + scrollableContainer.scrollTop = initialScrollTop + moveY; + } + + setIsDragging(isMouseDown); + }; useEffect(() => { const scrollableContainer = scrollableRef.current; From 00f23347b7206bccc02a857b8300d8bbc36a5448 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 11 Sep 2026 07:21:25 +0500 Subject: [PATCH 16/18] fix: static bitmap canvas for expanded chart (no close flash), clamp double-tap to zoom range, fix click-to-zoom point, address review feedback --- patches/victory-native/details.md | 16 ++ ...tory-native+41.21.0+003+canvas-props.patch | 248 ++++++++++++++++++ .../components/VictoryChartCartesian.tsx | 7 +- .../components/VictoryChartContent.tsx | 7 +- .../components/VictoryChartExpandModal.tsx | 47 ++-- .../BaseVictoryChartExpandedContent.tsx | 19 +- .../ExpandedChartBox.tsx | 30 +-- .../VictoryChartExpandedContent/index.tsx | 35 +-- .../VictoryChartExpandedContent/types.ts | 7 +- .../useExpandedChartLayout.ts | 84 +----- .../components/VictoryChartPolar.tsx | 7 +- .../context/VictoryChartContext.tsx | 20 +- .../utils/computeExpandedChartLayout.ts | 84 ++++++ .../getStaticChartCanvasProps/index.native.ts | 6 + .../utils/getStaticChartCanvasProps/index.ts | 11 + .../utils/getStaticChartCanvasProps/types.ts | 7 + .../utils/scaleVictoryChartContextValue.ts | 24 +- src/components/MultiGestureCanvas/index.tsx | 1 + .../MultiGestureCanvas/useTapGestures.ts | 6 +- src/hooks/useClickZoomPan/index.native.ts | 12 + .../index.ts} | 50 +--- src/hooks/useClickZoomPan/types.ts | 29 ++ .../computeExpandedChartLayoutTest.ts | 58 ++++ 23 files changed, 597 insertions(+), 218 deletions(-) create mode 100644 patches/victory-native/victory-native+41.21.0+003+canvas-props.patch create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts create mode 100644 src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts create mode 100644 src/hooks/useClickZoomPan/index.native.ts rename src/hooks/{useClickZoomPan.ts => useClickZoomPan/index.ts} (70%) create mode 100644 src/hooks/useClickZoomPan/types.ts create mode 100644 tests/unit/components/HTMLEngineProvider/computeExpandedChartLayoutTest.ts diff --git a/patches/victory-native/details.md b/patches/victory-native/details.md index a1f721a1a811..218061dd9c1d 100644 --- a/patches/victory-native/details.md +++ b/patches/victory-native/details.md @@ -23,3 +23,19 @@ - Upstream PR/issue: https://github.com/FormidableLabs/victory-native-xl/pull/666 - E/App issue: https://github.com/Expensify/App/issues/92114 - PR introducing patch: https://github.com/Expensify/App/pull/92130 + +### [victory-native+41.21.0+003+canvas-props.patch](victory-native+41.21.0+003+canvas-props.patch) + +- Reason: + + ``` + Forwards a `canvasProps` prop from CartesianChart/PolarChart to the underlying Skia , so the + expanded (full-screen) chart can opt into Skia's `__destroyWebGLContextAfterRender` static renderer on + web. That renders the chart into a plain 2D canvas bitmap and releases the WebGL context, which keeps + the chart visible through the modal close animation without the WebGL white flash and without holding + a live GPU context per expanded chart. + ``` + +- Upstream PR/issue: Not yet. +- E/App issue: https://github.com/Expensify/App/issues/92969 +- PR introducing patch: https://github.com/Expensify/App/pull/97698 diff --git a/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch b/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch new file mode 100644 index 000000000000..7692cce6596d --- /dev/null +++ b/patches/victory-native/victory-native+41.21.0+003+canvas-props.patch @@ -0,0 +1,248 @@ +diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts +index 6802428..b30fdac 100644 +--- a/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts ++++ b/node_modules/victory-native/dist/cartesian/CartesianChart.d.ts +@@ -8,6 +8,7 @@ import type { ChartPressState, ChartPressStateInit } from "./hooks/useChartPress + import { type ChartTransformState } from "./hooks/useChartTransformState"; + import { type PanTransformGestureConfig, type PinchTransformGestureConfig } from "./utils/transformGestures"; + import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; ++import { type ChartCanvasProps } from "../shared/ChartWrapper"; + export type CartesianActionsHandle = T extends ChartPressState ? S extends ChartPressStateInit ? { + handleTouch: (v: T, x: number, y: number) => void; + } : never : never; +@@ -67,6 +68,7 @@ type CartesianChartProps, XK extends key + x: InputFields[XK]; + y: Record; + }> | undefined>>; ++ canvasProps?: ChartCanvasProps; + } & ChartLayoutModeProps; + export declare function CartesianChart, XK extends keyof InputFields, YK extends keyof NumericalFields>({ transformState, children, ref, ...rest }: CartesianChartProps): React.JSX.Element; + export {}; +diff --git a/node_modules/victory-native/dist/cartesian/CartesianChart.js b/node_modules/victory-native/dist/cartesian/CartesianChart.js +index 839afbd..3b0837a 100644 +--- a/node_modules/victory-native/dist/cartesian/CartesianChart.js ++++ b/node_modules/victory-native/dist/cartesian/CartesianChart.js +@@ -73,7 +73,7 @@ function CartesianChart(_a) { + ); + } + exports.CartesianChart = CartesianChart; +-function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, }) { ++function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, children, renderOutside = () => null, axisOptions, domain, chartPressState, chartPressConfig, gestureHandlerConfig, onChartBoundsChange, onScaleChange, gestureLongPressDelay = 100, xAxis, yAxis, frame, transformState, transformConfig, customGestures, actionsRef, viewport, ref, explicitSize, headless, canvasProps, }) { + var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; + const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = (0, useChartCanvasSize_1.useChartCanvasSize)({ explicitSize, headless }); + const chartBoundsRef = React.useRef(undefined); +@@ -498,5 +498,5 @@ function CartesianChartContent({ data, xKey, yKeys, padding, domainPadding, chil + height: primaryYScale.range()[1] - Math.min(primaryYScale.range()[0], 0), + }}/>); + } +- return (); ++ return (); + } +diff --git a/node_modules/victory-native/dist/polar/PolarChart.d.ts b/node_modules/victory-native/dist/polar/PolarChart.d.ts +index 6cf2971..5b862da 100644 +--- a/node_modules/victory-native/dist/polar/PolarChart.d.ts ++++ b/node_modules/victory-native/dist/polar/PolarChart.d.ts +@@ -4,6 +4,7 @@ import type { ColorFields, InputFields, NumericalFields, StringKeyOf } from "../ + import { type ChartTransformState } from "../cartesian/hooks/useChartTransformState"; + import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; + import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; ++import { type ChartCanvasProps } from "../shared/ChartWrapper"; + type PolarChartBaseProps = { + onLayout: ({ nativeEvent: { layout } }: LayoutChangeEvent) => void; + hasMeasuredLayoutSize: boolean; +@@ -16,6 +17,7 @@ type PolarChartBaseProps = { + transformState?: ChartTransformState; + isHeadless: boolean; + explicitSize?: ChartExplicitSize; ++ canvasProps?: ChartCanvasProps; + }; + type PolarChartProps, LabelKey extends StringKeyOf>, ValueKey extends StringKeyOf>, ColorKey extends StringKeyOf>> = { + data: RawData[]; +diff --git a/node_modules/victory-native/dist/polar/PolarChart.js b/node_modules/victory-native/dist/polar/PolarChart.js +index d6416d0..c990be2 100644 +--- a/node_modules/victory-native/dist/polar/PolarChart.js ++++ b/node_modules/victory-native/dist/polar/PolarChart.js +@@ -34,7 +34,7 @@ const GestureHandler_1 = require("../shared/GestureHandler"); + const ChartWrapper_1 = require("../shared/ChartWrapper"); + const useChartCanvasSize_1 = require("../shared/useChartCanvasSize"); + const PolarChartBase = (props) => { +- const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, } = props; ++ const { containerStyle, canvasStyle, children, onLayout, hasMeasuredLayoutSize, canvasSize, transformState, isHeadless, explicitSize, canvasProps, } = props; + const { width, height } = canvasSize; + const Bridge = (0, its_fine_1.useContextBridge)(); + let composed = react_native_gesture_handler_1.Gesture.Race(); +@@ -44,7 +44,7 @@ const PolarChartBase = (props) => { + const chartContent = ( + {hasMeasuredLayoutSize && children} + ); +- return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); ++ return ( {content}} gestureOverlay={isHeadless ? undefined : ()}/>); + }; + const PolarChart = (props) => { + const { data, labelKey, colorKey, valueKey } = props; +diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts +index 0688ab2..a878191 100644 +--- a/node_modules/victory-native/dist/shared/ChartWrapper.d.ts ++++ b/node_modules/victory-native/dist/shared/ChartWrapper.d.ts +@@ -1,8 +1,9 @@ + import * as React from "react"; + import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +-import { type CanvasRef } from "@shopify/react-native-skia"; ++import { type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; + import { type ChartExplicitSize } from "./ChartExplicitSize"; + import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; ++export type ChartCanvasProps = Omit; + type ChartWrapperProps = { + isHeadless: boolean; + explicitSize?: ChartExplicitSize; +@@ -15,6 +16,7 @@ type ChartWrapperProps = { + containerStyle?: StyleProp; + canvasStyle?: StyleProp; + wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; ++ canvasProps?: ChartCanvasProps; + }; +-export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }: ChartWrapperProps): React.JSX.Element; ++export declare function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }: ChartWrapperProps): React.JSX.Element; + export {}; +diff --git a/node_modules/victory-native/dist/shared/ChartWrapper.js b/node_modules/victory-native/dist/shared/ChartWrapper.js +index 5aca3cc..8d9f9ee 100644 +--- a/node_modules/victory-native/dist/shared/ChartWrapper.js ++++ b/node_modules/victory-native/dist/shared/ChartWrapper.js +@@ -28,7 +28,7 @@ const React = __importStar(require("react")); + const react_native_1 = require("react-native"); + const react_native_skia_1 = require("@shopify/react-native-skia"); + const react_native_gesture_handler_1 = require("react-native-gesture-handler"); +-function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, }) { ++function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSize, canvasSize, canvasRef, chartContent, gestureOverlay, containerStyle, canvasStyle, wrapCanvasContent, canvasProps, }) { + if (isHeadless) { + return {chartContent}; + } +@@ -42,7 +42,7 @@ function ChartWrapper({ isHeadless, explicitSize, onLayout, hasMeasuredLayoutSiz + : null, + containerStyle, + ]} onLayout={explicitSize ? undefined : onLayout}> +- + >; ++ canvasProps?: ChartCanvasProps; + } & ChartLayoutModeProps; + + export function CartesianChart< +@@ -188,6 +189,7 @@ function CartesianChartContent< + ref, + explicitSize, + headless, ++ canvasProps, + }: CartesianChartProps) { + const { size, hasMeasuredLayoutSize, onLayout, isHeadless } = + useChartCanvasSize({ explicitSize, headless }); +@@ -773,6 +775,7 @@ function CartesianChartContent< + canvasRef={canvasRef} + chartContent={chartContent} + gestureOverlay={gestureOverlay} ++ canvasProps={canvasProps} + /> + ); + } +diff --git a/node_modules/victory-native/src/polar/PolarChart.tsx b/node_modules/victory-native/src/polar/PolarChart.tsx +index 4229d7b..7665633 100644 +--- a/node_modules/victory-native/src/polar/PolarChart.tsx ++++ b/node_modules/victory-native/src/polar/PolarChart.tsx +@@ -22,7 +22,7 @@ import { + import { GestureHandler } from "../shared/GestureHandler"; + import { type ChartExplicitSize } from "../shared/ChartExplicitSize"; + import { type ChartLayoutModeProps } from "../shared/ChartLayoutModeProps"; +-import { ChartWrapper } from "../shared/ChartWrapper"; ++import { ChartWrapper, type ChartCanvasProps } from "../shared/ChartWrapper"; + import { useChartCanvasSize } from "../shared/useChartCanvasSize"; + + type PolarChartBaseProps = { +@@ -34,6 +34,7 @@ type PolarChartBaseProps = { + transformState?: ChartTransformState; + isHeadless: boolean; + explicitSize?: ChartExplicitSize; ++ canvasProps?: ChartCanvasProps; + }; + + const PolarChartBase = ( +@@ -49,6 +50,7 @@ const PolarChartBase = ( + transformState, + isHeadless, + explicitSize, ++ canvasProps, + } = props; + const { width, height } = canvasSize; + const Bridge: ContextBridge = useContextBridge(); +@@ -77,6 +79,7 @@ const PolarChartBase = ( + canvasSize={canvasSize} + containerStyle={containerStyle} + canvasStyle={canvasStyle} ++ canvasProps={canvasProps} + chartContent={chartContent} + wrapCanvasContent={ + isHeadless ? undefined : (content) => {content} +diff --git a/node_modules/victory-native/src/shared/ChartWrapper.tsx b/node_modules/victory-native/src/shared/ChartWrapper.tsx +index ab149e9..b907381 100644 +--- a/node_modules/victory-native/src/shared/ChartWrapper.tsx ++++ b/node_modules/victory-native/src/shared/ChartWrapper.tsx +@@ -6,11 +6,16 @@ import { + type StyleProp, + type ViewStyle, + } from "react-native"; +-import { Canvas, Group, type CanvasRef } from "@shopify/react-native-skia"; ++import { Canvas, Group, type CanvasProps, type CanvasRef } from "@shopify/react-native-skia"; + import { GestureHandlerRootView } from "react-native-gesture-handler"; + import { type ChartExplicitSize } from "./ChartExplicitSize"; + import { type ChartCanvasSize } from "./chartCanvasSizeUtils"; + ++export type ChartCanvasProps = Omit< ++ CanvasProps, ++ "children" | "ref" | "style" | "onLayout" ++>; ++ + type ChartWrapperProps = { + isHeadless: boolean; + explicitSize?: ChartExplicitSize; +@@ -23,6 +28,7 @@ type ChartWrapperProps = { + containerStyle?: StyleProp; + canvasStyle?: StyleProp; + wrapCanvasContent?: (content: React.ReactNode) => React.ReactNode; ++ canvasProps?: ChartCanvasProps; + }; + + export function ChartWrapper({ +@@ -37,6 +43,7 @@ export function ChartWrapper({ + containerStyle, + canvasStyle, + wrapCanvasContent, ++ canvasProps, + }: ChartWrapperProps) { + if (isHeadless) { + return {chartContent}; +@@ -58,6 +65,7 @@ export function ChartWrapper({ + onLayout={explicitSize ? undefined : onLayout} + > + ) => void; }; @@ -35,7 +39,7 @@ type VictoryChartCartesianProps = { * Renders the CartesianChart with data, axes, and domain config drawn from context. * Labels and legend overlays are handled internally via `renderOutside`. */ -function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryChartCartesianProps) { +function VictoryChartCartesian({explicitSize, headless, shouldUseStaticCanvas, onRenderArgs}: VictoryChartCartesianProps) { const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles, pixelScale} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -65,6 +69,7 @@ function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryCh domainPadding={domainPadding} padding={padding} {...getChartLayoutModeProps(explicitSize, headless)} + canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} renderOutside={(renderArgs) => { const overlayContent = ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx index 21904ab9e5ea..fbd751310a73 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent.tsx @@ -9,9 +9,12 @@ import VictoryChartPolar from './VictoryChartPolar'; type VictoryChartContentProps = { explicitSize?: {width: number; height: number}; headless?: boolean; + + /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ + shouldUseStaticCanvas?: boolean; }; -function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) { +function VictoryChartContent({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartContentProps) { const {type} = useVictoryChartContext(); switch (type) { case CHART_TYPE.CARTESIAN: @@ -19,6 +22,7 @@ function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) ); case CHART_TYPE.POLAR: @@ -26,6 +30,7 @@ function VictoryChartContent({explicitSize, headless}: VictoryChartContentProps) ); default: diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index ef0a3aeb6be4..42752b3c538e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -27,10 +27,9 @@ type VictoryChartExpandModalProps = { }; /** - * Centered full-screen modal that presents the current chart scaled up to the viewport, with - * platform-appropriate zoom mirroring the image attachment viewer: pinch/double-tap on touch - * devices, click + scroll on desktop web. - * Must be rendered inside a VictoryChartProvider so the chart can read the parsed chart context. + * Full-screen modal presenting the current chart scaled up to the viewport, with attachment-style + * zoom (pinch/double-tap on touch, click + scroll on desktop web). Must be rendered inside a + * VictoryChartProvider. */ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalProps) { const styles = useThemeStyles(); @@ -39,37 +38,42 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [availableSize, setAvailableSize] = useState({width: 0, height: 0}); + // The chart stays mounted through the close animation and is released once the modal has hidden + const [isHidden, setIsHidden] = useState(!isVisible); const layout = useExpandedChartLayout(availableSize); + useEffect(() => { + if (!isVisible) { + return; + } + setIsHidden(false); + }, [isVisible]); + 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. + // Re-measuring mid close animation would rescale the chart if (!isVisible) { return; } const {width, height} = event.nativeEvent.layout; - // Avoid re-render churn when the layout callback fires without an actual size change. setAvailableSize((prev) => (prev.width === width && prev.height === height ? prev : {width, height})); }; const isMeasured = availableSize.width > 0 && availableSize.height > 0; + const shouldRenderChart = isMeasured && (isVisible || !isHidden); return ( setIsHidden(true)} + // Browser back should close only the modal, not the report behind it shouldHandleNavigationBack enableEdgeToEdgeBottomSafeAreaPadding > - {/* GestureHandlerRootView is required for MultiGestureCanvas gestures to work inside a - modal on Android, which hosts modals in a separate native window — the same reason - the attachment modal wraps its content in one. It also explicitly paints the modal - surface: during the close animation the unpainted modal base can flash through as - white, which is clearly visible on dark themes. */} + {/* GestureHandlerRootView is required for gestures inside an Android modal (separate native window), + and painting appBG here avoids the unpainted modal base flashing through on dark themes */} - {/* Header matches the attachment modal: back button on narrow layouts, close button on the right otherwise. */} - {/* 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 && + {shouldRenderChart && (layout.hasLayout ? ( ) : ( - // Charts without design dimensions have no design-based label coordinates, so fluid - // rendering is safe. Background/rounding are still applied so the expanded chart - // keeps the same themed container the inline fluid path renders with. + // Charts without design dimensions render fluid, like inline - + ))} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx index 230dcac7a5da..3e21df31461f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx @@ -6,18 +6,14 @@ import {useSharedValue} from 'react-native-reanimated'; import type VictoryChartExpandedContentProps from './types'; import ExpandedChartBox from './ExpandedChartBox'; -import useExpandedChartLayout from './useExpandedChartLayout'; /** - * Touch-device zoom for the expanded chart, mirroring the Lightbox/image-attachment pattern: the - * chart is rendered ONCE at a fixed high resolution (like a 2x image asset) and handed to - * MultiGestureCanvas at that intrinsic size. The canvas computes the fit scale itself and owns the - * single transform for fitting, centering, and pinch/double-tap zooming — no manual transforms of - * our own, since nested transforms rasterize the inner layer and blur it on native. + * Touch devices: the chart is rendered once at the zoomed size and MultiGestureCanvas owns the + * fit/pinch/double-tap transform, like the Lightbox does for image attachments. */ -function BaseVictoryChartExpandedContent({availableSize, isVisible, onSwipeDown}: VictoryChartExpandedContentProps) { - const {hasLayout, fitScale, zoomHeadroom, renderWidth, renderHeight, clippedRenderHeight, backgroundColor, borderRadius, isPolar} = useExpandedChartLayout(availableSize); - // No pager wraps this canvas, so scrolling never needs to be handed back to one. +function BaseVictoryChartExpandedContent({availableSize, layout, isVisible, onSwipeDown}: VictoryChartExpandedContentProps) { + const {hasLayout, fitScale, zoomHeadroom, renderWidth, renderHeight, clippedRenderHeight, backgroundColor, renderBorderRadius, isPolar} = layout; + // No pager wraps this canvas const isPagerScrollEnabled = useSharedValue(false); if (!hasLayout) { @@ -29,8 +25,7 @@ function BaseVictoryChartExpandedContent({availableSize, isVisible, onSwipeDown} isActive={isVisible} canvasSize={availableSize} contentSize={{width: renderWidth, height: clippedRenderHeight}} - // Zooming past the rendered resolution would upscale pixels and blur the chart — cap - // the zoom at the headroom the chart was actually rendered with. + // Zooming past the rendered resolution would blur the chart zoomRange={{max: zoomHeadroom}} isUsedInCarousel={false} isPagerScrollEnabled={isPagerScrollEnabled} @@ -42,7 +37,7 @@ function BaseVictoryChartExpandedContent({availableSize, isVisible, onSwipeDown} clippedHeight={clippedRenderHeight} providerScale={fitScale * zoomHeadroom} backgroundColor={backgroundColor} - borderRadius={borderRadius} + borderRadius={renderBorderRadius} isPolar={isPolar} /> diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx index c9b0d4c2bfab..e17cbf07a196 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -1,6 +1,5 @@ import VictoryChartContent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent'; import {VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; -import scalePixels from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scalePixels'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -17,48 +16,36 @@ type ExpandedChartBoxProps = { /** Rendered chart height in pixels (full design canvas) */ height: number; - /** Visible height in pixels — smaller than `height` for polar charts, whose dead bottom space is clipped */ + /** Visible height — smaller than `height` for polar charts, whose dead bottom space is clipped */ clippedHeight: number; - /** Uniform factor the chart's pixel-space config is scaled by for this render size */ + /** Factor the chart's pixel-space config is scaled by for this render size */ providerScale: number; - /** Theme-resolved container background parsed from the chart HTML */ + /** Container background, theme-resolved */ backgroundColor: ColorValue | undefined; - /** Container corner radius parsed from the chart HTML, in design-space pixels */ + /** Container corner radius, already scaled to the render size */ borderRadius: number | undefined; - /** Whether the chart is polar — its clip container keeps the rounded corners */ + /** Whether the chart is polar — its clip box keeps the rounded corners */ isPolar: boolean; }; -/** - * The expanded chart rendered natively at the given size: an outer clip box (hides polar dead - * space), an inner card with the chart's themed background/rounding, and the chart itself - * re-rendered through VictoryChartScaledProvider so every pixel-space value matches the size. - */ +/** The chart card rendered natively at the given size. */ function ExpandedChartBox({width, height, clippedHeight, providerScale, backgroundColor, borderRadius, isPolar}: ExpandedChartBoxProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - // The parsed radius is in design-space pixels; scale it to the render size so the card keeps - // the same proportions as the inline chart (which scales its whole box). - const scaledBorderRadius = scalePixels(borderRadius, providerScale); - return ( @@ -66,6 +53,7 @@ function ExpandedChartBox({width, height, clippedHeight, providerScale, backgrou diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx index 909d51f64521..3470204ae2cf 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -18,36 +18,28 @@ import type VictoryChartExpandedContentProps from './types'; import BaseVictoryChartExpandedContent from './BaseVictoryChartExpandedContent'; import ExpandedChartBox from './ExpandedChartBox'; -import useExpandedChartLayout from './useExpandedChartLayout'; /** - * Desktop-web zoom for the expanded chart, mirroring the image attachment viewer (ImageView): - * a zoom-in/zoom-out cursor, click to zoom into the clicked spot, mouse scroll (or drag while - * zoomed) to pan — via the same useClickZoomPan hook the image viewer uses. - * - * Like a high-resolution image, the chart is rendered ONCE at the zoomed size and displayed - * downscaled while fitted (crisp both ways), so toggling zoom only changes CSS — the Skia canvas - * never re-renders and there is no flicker. + * Desktop web: click-to-zoom with scroll/drag panning, like the image attachment viewer. The chart + * is rendered once at the zoomed size and shown downscaled while fitted, so zooming is CSS-only. */ -function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryChartExpandedContentProps) { +function DesktopVictoryChartExpandedContent({availableSize, layout, isVisible}: VictoryChartExpandedContentProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const scrollableRef = useRef(null); - const layout = useExpandedChartLayout(availableSize); - // On large displays the fitted chart can already use all the zoom headroom, in which case - // clicking could not enlarge anything — hide the zoom affordance entirely. + // No headroom (very large displays) means clicking couldn't enlarge anything const canZoom = layout.zoomHeadroom > 1; + // Click offsets are reported in the chart's own (render-space) coordinates, so no conversion is needed const {isZoomed, isDragging, onContainerPressIn, onContainerPress, resetZoom} = useClickZoomPan({ scrollableRef, containerSize: availableSize, - zoomFactor: layout.zoomHeadroom, + zoomFactor: 1, }); - // The modal stays mounted after closing so it reopens fast — reset the zoom so it never - // reopens in a stale zoomed state (the touch path resets via MultiGestureCanvas.isActive). + // Don't reopen in a stale zoomed state useEffect(() => { if (isVisible) { return; @@ -66,8 +58,6 @@ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryC styles.overflowHidden, ]} > - {/* The chart is always rendered at the zoomed size; while fitted it is displayed - downscaled — like a 2x image asset — so zooming never re-renders the canvas. */} @@ -87,9 +77,7 @@ function DesktopVictoryChartExpandedContent({availableSize, isVisible}: VictoryC ref={scrollableRef} style={[styles.flex1, styles.w100, styles.overflowAuto, styles.pRelative]} > - {/* Fills the viewport so the fitted chart centers. Centering is dropped while zoomed: - flex-centering content larger than the scroll viewport pushes its start edges before - the scroll origin, making the top/left of the chart unreachable. */} + {/* Centering is dropped while zoomed: centered overflow would push the chart's top/left past the scroll origin */} {canZoom ? ( ; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts index 5e29f3fa9d89..966525588111 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts @@ -1,13 +1,18 @@ import type {Dimensions} from '@src/types/utils/Layout'; +import type {ThemedExpandedChartLayout} from './useExpandedChartLayout'; + type VictoryChartExpandedContentProps = { /** The measured area available to the expanded chart inside the modal */ availableSize: Dimensions; + /** Fitted/zoomed sizes computed by the modal for `availableSize` */ + layout: ThemedExpandedChartLayout; + /** Whether the modal is visible — gestures are deactivated and zoom state is reset while closed */ isVisible: boolean; - /** Called when the user swipes the chart down on touch devices, matching the attachment viewer */ + /** Called when the user swipes the chart down on touch devices */ onSwipeDown?: () => void; }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts index 38e3c15d3a0d..ba3027390603 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts @@ -1,7 +1,8 @@ -import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {CHART_TYPE} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {ExpandedChartLayout} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout'; +import computeExpandedChartLayout from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; -import {DOUBLE_TAP_SCALE} from '@components/MultiGestureCanvas/constants'; import useTheme from '@hooks/useTheme'; @@ -9,88 +10,29 @@ import type {Dimensions} from '@src/types/utils/Layout'; import type {ColorValue} from 'react-native'; -// The zoom headroom shrinks (down to 1, i.e. no zoom) once the fitted render approaches this -// size, so zooming never allocates an excessively large canvas. The fitted render itself is -// never reduced — the chart must at least fill the viewport. -const MAX_CANVAS_DIMENSION = 2048; -// MultiGestureCanvas double-taps to at least DOUBLE_TAP_SCALE regardless of zoomRange, so the -// headroom must reach it for double-tap to land exactly on rendered (not upscaled) pixels. -const MAX_ZOOM_HEADROOM = DOUBLE_TAP_SCALE; - -type ExpandedChartLayout = { - /** Whether the chart has numeric design dimensions and the available area has been measured */ - hasLayout: boolean; - - /** Uniform scale that fits the chart's (clipped) design box inside the available area (may be > 1) */ - fitScale: number; - - /** The fitted (displayed) size of the chart */ - targetWidth: number; - targetHeight: number; - clippedTargetHeight: number; - - /** The zoomed render size of the chart (fitted size × headroom) */ - zoomHeadroom: number; - renderWidth: number; - renderHeight: number; - clippedRenderHeight: number; - - /** Theme-resolved container visuals parsed from the chart HTML */ +type ThemedExpandedChartLayout = ExpandedChartLayout & { + /** Theme-resolved container background parsed from the chart HTML */ backgroundColor: ColorValue | undefined; - borderRadius: number | undefined; - /** Whether the chart is polar (pie), whose container is clipped to hide dead canvas space */ - isPolar: boolean; + /** Unscaled container corner radius, for the fluid (design-size) fallback */ + designBorderRadius: number | undefined; }; -/** - * Computes the fitted and zoomed render sizes for the expanded chart from the chart's design - * dimensions and the available modal area, shared by every platform's zoom implementation. - */ -function useExpandedChartLayout(availableSize: Dimensions): ExpandedChartLayout { +/** Reads the chart's design values from context and computes the expanded layout for the available area. */ +function useExpandedChartLayout(availableSize: Dimensions): ThemedExpandedChartLayout { const theme = useTheme(); const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); const designWidth = typeof chartContentStyles.width === 'number' ? chartContentStyles.width : undefined; const designHeight = typeof chartContentStyles.height === 'number' ? chartContentStyles.height : undefined; - const hasDesignDimensions = !!designWidth && !!designHeight; - const isMeasured = availableSize.width > 0 && availableSize.height > 0; - - // Match the inline container: polar charts are clipped to hide the dead space at the - // bottom of their design canvas, so the expanded chart centers the same way inline does. - const isPolar = type === CHART_TYPE.POLAR; - const effectiveDesignHeight = designHeight !== undefined && isPolar ? designHeight * POLAR_CONTAINER_HEIGHT_RATIO : designHeight; - - const fitScale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1; - - const targetWidth = (designWidth ?? 0) * fitScale; - const targetHeight = (designHeight ?? 0) * fitScale; - const clippedTargetHeight = (effectiveDesignHeight ?? 0) * fitScale; - - 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 the same way VictoryChartContainerFixed - // does inline, so the expanded chart keeps the same (theme-aware) background and rounding. - const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme); const borderRadius = typeof chartContainerStyles.borderRadius === 'number' ? chartContainerStyles.borderRadius : undefined; return { - hasLayout: hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured, - fitScale, - targetWidth, - targetHeight, - clippedTargetHeight, - zoomHeadroom, - renderWidth, - renderHeight, - clippedRenderHeight, - backgroundColor, - borderRadius, - isPolar, + ...computeExpandedChartLayout({designWidth, designHeight, borderRadius, isPolar: type === CHART_TYPE.POLAR}, availableSize), + backgroundColor: resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme), + designBorderRadius: borderRadius, }; } export default useExpandedChartLayout; +export type {ThemedExpandedChartLayout}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx index c14a048ec76e..1e6c3891c7b8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPolar.tsx @@ -4,6 +4,7 @@ import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRendere import getChartDesignWidth from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartDesignWidth'; import getChartLayoutModeProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getChartLayoutModeProps'; import getHierarchyID from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getHierarchyID'; +import getStaticChartCanvasProps from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; import useTheme from '@hooks/useTheme'; @@ -20,12 +21,15 @@ import VictoryChartLegend from './VictoryChartLegend'; type VictoryChartPolarProps = { explicitSize?: {width: number; height: number}; headless?: boolean; + + /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ + shouldUseStaticCanvas?: boolean; }; /** * Renders the PolarChart with data drawn from context. */ -function VictoryChartPolar({explicitSize, headless}: VictoryChartPolarProps) { +function VictoryChartPolar({explicitSize, headless, shouldUseStaticCanvas}: VictoryChartPolarProps) { const {tnode, data, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); const theme = useTheme(); const timezone = useCurrentTimezone(); @@ -63,6 +67,7 @@ function VictoryChartPolar({explicitSize, headless}: VictoryChartPolarProps) { valueKey={VALUE_KEY} colorKey={COLOR_KEY} {...getChartLayoutModeProps(explicitSize, headless)} + canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} > {headless ? ( {chartContent} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index 31369a766681..26b685a6309f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -4,11 +4,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 scaleVictoryChartContextValue, {disposeScaledFonts} 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, useEffect} from 'react'; type VictoryChartContextValue = { tnode: TNode; @@ -29,11 +29,7 @@ type VictoryChartContextValue = { 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. - */ + /** Factor already applied to this context's pixel values (1 inline); raw tnode pixel attributes must be multiplied by it */ pixelScale: number; }; @@ -98,16 +94,18 @@ type VictoryChartScaledProviderProps = { }; /** - * 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. + * Re-provides the chart context with every pixel-space value scaled by a uniform factor, so the + * expand modal can re-render the chart natively at a larger size. */ function VictoryChartScaledProvider({scale, children}: VictoryChartScaledProviderProps) { const value = useVictoryChartContext(); const typefaces = useChartTypefaces(); const typeface = getVictoryChartTreeTypeface(typefaces); - // No manual memoization — React Compiler memoizes this call automatically. const scaledValue = scaleVictoryChartContextValue(value, scale, typeface); + + // Release the Skia fonts created for this scale once they are replaced or unmounted + useEffect(() => () => disposeScaledFonts(scaledValue, value), [scaledValue, value]); + return {children}; } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout.ts new file mode 100644 index 000000000000..1fcc2a3c6d63 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout.ts @@ -0,0 +1,84 @@ +import {POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {DOUBLE_TAP_SCALE} from '@components/MultiGestureCanvas/constants'; + +import type {Dimensions} from '@src/types/utils/Layout'; + +import scalePixels from './scalePixels'; + +// Zoom headroom shrinks (down to 1 = no zoom) once the fitted render nears this size, so the +// zoomed canvas never gets excessively large. The fitted render itself always fills the viewport. +const MAX_CANVAS_DIMENSION = 2048; +// MultiGestureCanvas double-taps to at least DOUBLE_TAP_SCALE, so the headroom must reach it for +// double-tap to land on rendered (not upscaled) pixels. +const MAX_ZOOM_HEADROOM = DOUBLE_TAP_SCALE; + +type ExpandedChartDesign = { + /** Design-space chart size parsed from the chart HTML, if it declares one */ + designWidth: number | undefined; + designHeight: number | undefined; + + /** Design-space container corner radius parsed from the chart HTML */ + borderRadius: number | undefined; + + /** Whether the chart is polar (pie), whose container is clipped to hide dead canvas space */ + isPolar: boolean; +}; + +type ExpandedChartLayout = { + /** Whether the chart has design dimensions and the available area has been measured */ + hasLayout: boolean; + + /** Uniform scale that fits the (clipped) design box inside the available area (may be > 1) */ + fitScale: number; + + /** Fitted (displayed) size */ + targetWidth: number; + targetHeight: number; + clippedTargetHeight: number; + + /** Zoomed render size (fitted size × headroom) */ + zoomHeadroom: number; + renderWidth: number; + renderHeight: number; + clippedRenderHeight: number; + + /** Container corner radius scaled to the render size */ + renderBorderRadius: number | undefined; + + isPolar: boolean; +}; + +/** Pure sizing math for the expanded chart: fitted size, zoom headroom, and the resulting render size. */ +function computeExpandedChartLayout({designWidth, designHeight, borderRadius, isPolar}: ExpandedChartDesign, availableSize: Dimensions): ExpandedChartLayout { + const hasDesignDimensions = !!designWidth && !!designHeight; + const isMeasured = availableSize.width > 0 && availableSize.height > 0; + + // Polar charts are clipped like inline to hide the dead space at the bottom of their canvas. + const effectiveDesignHeight = designHeight !== undefined && isPolar ? designHeight * POLAR_CONTAINER_HEIGHT_RATIO : designHeight; + + const fitScale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1; + + const targetWidth = (designWidth ?? 0) * fitScale; + const targetHeight = (designHeight ?? 0) * fitScale; + const clippedTargetHeight = (effectiveDesignHeight ?? 0) * fitScale; + + const zoomHeadroom = Math.max(1, Math.min(MAX_ZOOM_HEADROOM, MAX_CANVAS_DIMENSION / Math.max(targetWidth, targetHeight, 1))); + + return { + hasLayout: hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured, + fitScale, + targetWidth, + targetHeight, + clippedTargetHeight, + zoomHeadroom, + renderWidth: targetWidth * zoomHeadroom, + renderHeight: targetHeight * zoomHeadroom, + clippedRenderHeight: clippedTargetHeight * zoomHeadroom, + renderBorderRadius: scalePixels(borderRadius, fitScale * zoomHeadroom), + isPolar, + }; +} + +export default computeExpandedChartLayout; +export {MAX_CANVAS_DIMENSION, MAX_ZOOM_HEADROOM}; +export type {ExpandedChartLayout}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts new file mode 100644 index 000000000000..a06c1d23f515 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.native.ts @@ -0,0 +1,6 @@ +import type GetStaticChartCanvasProps from './types'; + +/** Native Skia views don't flash on re-composite, so the chart keeps its regular renderer. */ +const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => undefined; + +export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts new file mode 100644 index 000000000000..8b898def5617 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/index.ts @@ -0,0 +1,11 @@ +import type GetStaticChartCanvasProps from './types'; + +/** + * Web: renders the chart into a plain 2D canvas bitmap and releases the WebGL context right after + * drawing. A bitmap stays visible through the modal close animation (a live WebGL canvas flashes + * white when re-composited) and doesn't hold a GPU context per expanded chart. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention -- prop name is defined by react-native-skia +const getStaticChartCanvasProps: GetStaticChartCanvasProps = () => ({__destroyWebGLContextAfterRender: true}); + +export default getStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts new file mode 100644 index 000000000000..4d13cf87d8f2 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/getStaticChartCanvasProps/types.ts @@ -0,0 +1,7 @@ +import type {CanvasProps} from '@shopify/react-native-skia'; + +type StaticChartCanvasProps = Pick | undefined; + +type GetStaticChartCanvasProps = () => StaticChartCanvasProps; + +export default GetStaticChartCanvasProps; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts index 42de7c69ee35..bc5747d32460 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -8,10 +8,8 @@ import {Skia} from '@shopify/react-native-skia'; import scalePixels from './scalePixels'; /** - * 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. + * 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 size. Data-space values (points, domains, ticks) are untouched. */ function scaleRecordValues(record: Record | undefined, scale: number): Record | undefined { @@ -69,9 +67,8 @@ function scalePadding(padding: number | SidedPixelValues | undefined, scale: num } /** - * Rebuilds a Skia font at the scaled size using the chart's shared typeface; the original font - * object is left untouched. The typeface must be passed in rather than read via `font.getTypeface()` - * because CanvasKit (web) returns a raw pointer there that cannot be passed back into `Skia.Font`. + * Rebuilds a Skia font at the scaled size. The typeface is passed in because on web `font.getTypeface()` + * returns a raw pointer that CanvasKit refuses to reuse. */ function scaleFont(font: SkFont | null | undefined, scale: number, typeface: SkTypeface | null): SkFont | null | undefined { if (!font || !typeface) { @@ -113,5 +110,16 @@ function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: n }; } +/** Disposes the axis fonts a scaled context created, leaving the original (shared) fonts untouched. */ +function disposeScaledFonts(scaled: VictoryChartContextValue, original: VictoryChartContextValue) { + const originalFonts = new Set([original.xAxis?.font, ...(original.yAxis ?? []).map((axis) => axis.font)]); + const scaledFonts = [scaled.xAxis?.font, ...(scaled.yAxis ?? []).map((axis) => axis.font)]; + for (const font of scaledFonts) { + if (font && !originalFonts.has(font)) { + font.dispose(); + } + } +} + export default scaleVictoryChartContextValue; -export {scaleLabelItem}; +export {scaleLabelItem, disposeScaledFonts}; diff --git a/src/components/MultiGestureCanvas/index.tsx b/src/components/MultiGestureCanvas/index.tsx index 8bd77b275cb1..4719c65cb6ca 100644 --- a/src/components/MultiGestureCanvas/index.tsx +++ b/src/components/MultiGestureCanvas/index.tsx @@ -200,6 +200,7 @@ function MultiGestureCanvas({ const {singleTapGesture: baseSingleTapGesture, doubleTapGesture} = useTapGestures({ canvasSize, contentSize, + zoomRange, minContentScale, maxContentScale, offsetX, diff --git a/src/components/MultiGestureCanvas/useTapGestures.ts b/src/components/MultiGestureCanvas/useTapGestures.ts index 3ddc11375438..1d2ab7aa3fc4 100644 --- a/src/components/MultiGestureCanvas/useTapGestures.ts +++ b/src/components/MultiGestureCanvas/useTapGestures.ts @@ -14,6 +14,7 @@ type UseTapGesturesProps = Pick< MultiGestureCanvasVariables, | 'canvasSize' | 'contentSize' + | 'zoomRange' | 'minContentScale' | 'maxContentScale' | 'offsetX' @@ -31,6 +32,7 @@ type UseTapGesturesProps = Pick< const useTapGestures = ({ canvasSize, contentSize, + zoomRange, minContentScale, maxContentScale, offsetX, @@ -48,8 +50,8 @@ const useTapGestures = ({ const scaledContentWidth = useMemo(() => contentSize.width * minContentScale, [contentSize.width, minContentScale]); const scaledContentHeight = useMemo(() => contentSize.height * minContentScale, [contentSize.height, minContentScale]); - // On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE - const doubleTapScale = useMemo(() => Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale), [maxContentScale, minContentScale]); + // On double tap the content should be zoomed to fill, but at least zoomed by DOUBLE_TAP_SCALE — never past the allowed zoom range + const doubleTapScale = useMemo(() => Math.min(zoomRange.max, Math.max(DOUBLE_TAP_SCALE, maxContentScale / minContentScale)), [maxContentScale, minContentScale, zoomRange.max]); const zoomToCoordinates = useCallback( (focalX: number, focalY: number, callback: () => void) => { diff --git a/src/hooks/useClickZoomPan/index.native.ts b/src/hooks/useClickZoomPan/index.native.ts new file mode 100644 index 000000000000..388bebf89f63 --- /dev/null +++ b/src/hooks/useClickZoomPan/index.native.ts @@ -0,0 +1,12 @@ +import type UseClickZoomPan from './types'; + +/** Click-to-zoom is a mouse interaction; touch platforms zoom with gestures (Lightbox / MultiGestureCanvas). */ +const useClickZoomPan: UseClickZoomPan = () => ({ + isZoomed: false, + isDragging: false, + onContainerPressIn: () => {}, + onContainerPress: () => {}, + resetZoom: () => {}, +}); + +export default useClickZoomPan; diff --git a/src/hooks/useClickZoomPan.ts b/src/hooks/useClickZoomPan/index.ts similarity index 70% rename from src/hooks/useClickZoomPan.ts rename to src/hooks/useClickZoomPan/index.ts index d6cec1163813..ac4c8c70b77f 100644 --- a/src/hooks/useClickZoomPan.ts +++ b/src/hooks/useClickZoomPan/index.ts @@ -1,48 +1,19 @@ import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; -import type {Dimensions} from '@src/types/utils/Layout'; - -import type {RefObject, SyntheticEvent} from 'react'; -import type {GestureResponderEvent, View} from 'react-native'; +import type {SyntheticEvent} from 'react'; +import type {GestureResponderEvent} from 'react-native'; import {useEffect, useState} from 'react'; -type ZoomDelta = {offsetX: number; offsetY: number}; - -type UseClickZoomPanParams = { - /** The scrollable element the zoomed content overflows into */ - scrollableRef: RefObject<(View & HTMLDivElement) | null>; - - /** The size of the visible scroll area, used to center the clicked point after zooming */ - containerSize: Dimensions; - - /** Multiplier that maps a point in displayed (fitted) space to the same point in zoomed space */ - zoomFactor: number; -}; - -type UseClickZoomPanResult = { - /** Whether the content is currently zoomed in */ - isZoomed: boolean; +import type UseClickZoomPan from './types'; - /** Whether the user is currently dragging to pan the zoomed content */ - isDragging: boolean; - - /** Press-in handler for the pressable zoom area — records the drag start position */ - onContainerPressIn: (e: GestureResponderEvent) => void; - - /** Press handler for the pressable zoom area — toggles zoom or ends a drag */ - onContainerPress: (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => void; - - /** Resets all zoom/drag state, e.g. when the content reloads or its container closes */ - resetZoom: () => void; -}; +type ZoomDelta = {offsetX: number; offsetY: number}; /** - * Desktop-web click-to-zoom with scroll/drag panning, shared by the image attachment viewer - * (ImageView) and the expanded chart so both zoom identically: click zooms in centered on the - * clicked point, mouse scroll or drag pans while zoomed, and clicking again zooms back out. + * Desktop-web click-to-zoom with scroll/drag panning, shared by ImageView and the expanded chart: + * click zooms in centered on the clicked point, scroll or drag pans, click again zooms out. */ -function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoomPanParams): UseClickZoomPanResult { +const useClickZoomPan: UseClickZoomPan = ({scrollableRef, containerSize, zoomFactor}) => { const canUseTouchScreen = canUseTouchScreenUtil(); const [isZoomed, setIsZoomed] = useState(false); @@ -94,8 +65,7 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { const {offsetX, offsetY} = e.nativeEvent; - // Multiplying clicked positions by the zoom factor to get zoomed-space coordinates - // so that once we zoom we will scroll to the clicked location. + // Convert the click into zoomed-space coordinates so we scroll to the clicked location once zoomed const delta = getScrollOffset(offsetX * zoomFactor, offsetY * zoomFactor); setZoomDelta(delta); } else { @@ -107,13 +77,11 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo setIsDragging(false); setIsMouseDown(false); } else { - // We first zoom and once its done then we scroll to the location the user clicked. setIsZoomed(!isZoomed); setIsMouseDown(false); } }; - // No manual memoization anywhere in this hook — React Compiler stabilizes these callbacks. const resetZoom = () => { setIsZoomed(false); setIsDragging(false); @@ -170,6 +138,6 @@ function useClickZoomPan({scrollableRef, containerSize, zoomFactor}: UseClickZoo }, [canUseTouchScreen, trackMovement, trackPointerPosition]); return {isZoomed, isDragging, onContainerPressIn, onContainerPress, resetZoom}; -} +}; export default useClickZoomPan; diff --git a/src/hooks/useClickZoomPan/types.ts b/src/hooks/useClickZoomPan/types.ts new file mode 100644 index 000000000000..0979fd4b1945 --- /dev/null +++ b/src/hooks/useClickZoomPan/types.ts @@ -0,0 +1,29 @@ +import type {Dimensions} from '@src/types/utils/Layout'; + +import type {RefObject, SyntheticEvent} from 'react'; +import type {GestureResponderEvent, View} from 'react-native'; + +type UseClickZoomPanParams = { + /** The scrollable element the zoomed content overflows into */ + scrollableRef: RefObject<(View & HTMLDivElement) | null>; + + /** Size of the visible scroll area, used to center the clicked point after zooming */ + containerSize: Dimensions; + + /** Maps a click offset (reported in the pressed element's own coordinates) into zoomed-content coordinates */ + zoomFactor: number; +}; + +type UseClickZoomPanResult = { + isZoomed: boolean; + isDragging: boolean; + onContainerPressIn: (e: GestureResponderEvent) => void; + onContainerPress: (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => void; + + /** Clears zoom/drag state, e.g. when the content reloads or its container closes */ + resetZoom: () => void; +}; + +type UseClickZoomPan = (params: UseClickZoomPanParams) => UseClickZoomPanResult; + +export default UseClickZoomPan; diff --git a/tests/unit/components/HTMLEngineProvider/computeExpandedChartLayoutTest.ts b/tests/unit/components/HTMLEngineProvider/computeExpandedChartLayoutTest.ts new file mode 100644 index 000000000000..e2db1aa00567 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/computeExpandedChartLayoutTest.ts @@ -0,0 +1,58 @@ +import {POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import computeExpandedChartLayout, {MAX_CANVAS_DIMENSION, MAX_ZOOM_HEADROOM} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeExpandedChartLayout'; + +const design = {designWidth: 680, designHeight: 340, borderRadius: 16, isPolar: false}; + +describe('computeExpandedChartLayout', () => { + it('reports no layout until the area is measured or when design dimensions are missing', () => { + expect(computeExpandedChartLayout(design, {width: 0, height: 0}).hasLayout).toBe(false); + expect(computeExpandedChartLayout({...design, designWidth: undefined}, {width: 1000, height: 800}).hasLayout).toBe(false); + expect(computeExpandedChartLayout({...design, designHeight: 0}, {width: 1000, height: 800}).hasLayout).toBe(false); + expect(computeExpandedChartLayout(design, {width: 1000, height: 800}).hasLayout).toBe(true); + }); + + it('fits the design box into the available area using the limiting dimension', () => { + // Width-limited: 1000 / 680 + const wide = computeExpandedChartLayout(design, {width: 1000, height: 800}); + expect(wide.fitScale).toBeCloseTo(1000 / 680); + expect(wide.targetWidth).toBeCloseTo(1000); + expect(wide.targetHeight).toBeCloseTo(500); + + // Height-limited: 300 / 340 + const short = computeExpandedChartLayout(design, {width: 1000, height: 300}); + expect(short.fitScale).toBeCloseTo(300 / 340); + }); + + it('gives full zoom headroom when the zoomed render stays under the canvas cap', () => { + const layout = computeExpandedChartLayout(design, {width: 400, height: 800}); + expect(layout.zoomHeadroom).toBe(MAX_ZOOM_HEADROOM); + expect(layout.renderWidth).toBeCloseTo(400 * MAX_ZOOM_HEADROOM); + expect(layout.renderBorderRadius).toBeCloseTo(16 * layout.fitScale * MAX_ZOOM_HEADROOM); + }); + + it('reduces zoom headroom so the zoomed render never exceeds the canvas cap', () => { + const layout = computeExpandedChartLayout(design, {width: 1200, height: 800}); + expect(layout.zoomHeadroom).toBeCloseTo(MAX_CANVAS_DIMENSION / 1200); + expect(layout.renderWidth).toBeCloseTo(MAX_CANVAS_DIMENSION); + }); + + it('never shrinks the fitted render: headroom bottoms out at 1 on very large displays', () => { + const layout = computeExpandedChartLayout(design, {width: 3000, height: 2000}); + expect(layout.zoomHeadroom).toBe(1); + expect(layout.renderWidth).toBeCloseTo(layout.targetWidth); + expect(layout.renderBorderRadius).toBeCloseTo(16 * layout.fitScale); + }); + + it('fits polar charts by their clipped height and reports the clipped sizes', () => { + const layout = computeExpandedChartLayout({...design, designWidth: 400, designHeight: 400, isPolar: true}, {width: 1000, height: 360}); + expect(layout.fitScale).toBeCloseTo(360 / (400 * POLAR_CONTAINER_HEIGHT_RATIO)); + expect(layout.clippedTargetHeight).toBeCloseTo(360); + expect(layout.targetHeight).toBeCloseTo(360 / POLAR_CONTAINER_HEIGHT_RATIO); + expect(layout.clippedRenderHeight).toBeCloseTo(layout.clippedTargetHeight * layout.zoomHeadroom); + expect(layout.isPolar).toBe(true); + }); + + it('passes an undefined border radius through', () => { + expect(computeExpandedChartLayout({...design, borderRadius: undefined}, {width: 1000, height: 800}).renderBorderRadius).toBeUndefined(); + }); +}); From 7bee74869b49d24ffe5f7f3f3d2009cdb303711d Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 11 Sep 2026 07:40:46 +0500 Subject: [PATCH 17/18] fix: reset chart visibility via modal callbacks instead of an effect --- ios/Podfile.lock | 22 ++++--------------- .../components/VictoryChartExpandModal.tsx | 10 ++------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2d96d8706c68..eb9da754b6e4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -571,7 +571,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -597,7 +596,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -622,7 +620,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -649,7 +646,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -675,7 +671,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -701,7 +696,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -727,7 +721,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -753,7 +746,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -779,7 +771,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -805,7 +796,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -831,7 +821,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -857,7 +846,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -883,7 +871,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -909,7 +896,6 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger - - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -5011,7 +4997,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 8992dac2fb037a77b783469d993f59f96167417e + hermes-engine: 05a5a96595cc0f99788ee9f7b11d122284df7c70 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -5040,7 +5026,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 922be6f90a3addd48f57f2066dd90aacec4768b6 React: 2574546f2d017abd14d0c9b48cf2b6a0547c2591 React-callinvoker: b997d4d109c92cae4bc7f1edb08dc6a7c11a1ca2 - React-Core: ee317fb557001c3972bcf4fe15996197e65ae54d + React-Core: 5c7a37fd611353c8bf1c12ef24cfdec81ff46549 React-CoreModules: 2ae392df55453fca5602e9bb5bc16a72f36dd961 React-cxxreact: 9a58282eb607fb19d775cfaa8ff9da7be8fc99c5 React-debug: 307f174c04c2e1f51b31c31efcc3e635b1e56453 @@ -5092,7 +5078,7 @@ SPEC CHECKSUMS: React-NativeModulesApple: e477c7b5f198235d9de93702ef6b495b87b625bc React-networking: b90039a00fd63e75a78358ee5fc2ea4b4cbad736 React-oscompat: e3c95718adca6d0e7ca9e5b01a04d057f4aa8f72 - React-perflogger: 8712c3310a467e036fceac437dc7f2a4be69e634 + React-perflogger: 327334823748e1ecac8cb76e3aa1c072d99146af React-performancecdpmetrics: 992de932ea38bfc3e49f7b4eba19f6c1aca021bb React-performancetimeline: a0626b87f948b534b3fb90b696ad11863af94dd8 React-RCTActionSheet: cbe921221b0ca00d2ea586efbaa494bc990260c1 @@ -5108,7 +5094,7 @@ SPEC CHECKSUMS: React-RCTSettings: b478cd2ee4091e33bb847edef83c19aa87bf1c45 React-RCTText: 6bae9a5e52ebc012d7bc499542c42285f73d1e1d React-RCTVibration: 3ddd10141590a1800a1890248b47337d9004b7f4 - React-rendererconsistency: d1e82296151f814802c06be35952c9fda99973b1 + React-rendererconsistency: 8759d621ba9d2a3139c266d3462bf2e38ccc4b01 React-renderercss: 7b56e138a3d800fa9e17e61cdb3b293d6b8622c6 React-rendererdebug: cf7d7ca70a0b7263df1d49c87970ae6cfe7ce5d0 React-RuntimeApple: 39be405060d5dc5d1f9e4ee4e24ad09cd079a5db diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 42752b3c538e..99ff2aa796a8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import type {LayoutChangeEvent} from 'react-native'; -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -42,13 +42,6 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const [isHidden, setIsHidden] = useState(!isVisible); const layout = useExpandedChartLayout(availableSize); - useEffect(() => { - if (!isVisible) { - return; - } - setIsHidden(false); - }, [isVisible]); - const onContainerLayout = (event: LayoutChangeEvent) => { // Re-measuring mid close animation would rescale the chart if (!isVisible) { @@ -66,6 +59,7 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr isVisible={isVisible} type={CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE} onClose={onClose} + onModalShow={() => setIsHidden(false)} onModalHide={() => setIsHidden(true)} // Browser back should close only the modal, not the report behind it shouldHandleNavigationBack From 168f367d39a64c56a2dab98028dae91c352c3dd0 Mon Sep 17 00:00:00 2001 From: abbasifaizan70 Date: Fri, 11 Sep 2026 08:06:42 +0500 Subject: [PATCH 18/18] chore: revert unrelated Podfile.lock change from local pod install --- ios/Podfile.lock | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index eb9da754b6e4..2d96d8706c68 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -571,6 +571,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -596,6 +597,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -620,6 +622,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -646,6 +649,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -671,6 +675,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -696,6 +701,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -721,6 +727,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -746,6 +753,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -771,6 +779,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -796,6 +805,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -821,6 +831,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -846,6 +857,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -871,6 +883,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -896,6 +909,7 @@ PODS: - React-jsinspectorcdp - React-jsitooling - React-perflogger + - React-rendererconsistency - React-runtimeexecutor - React-runtimescheduler - React-utils @@ -4997,7 +5011,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa - hermes-engine: 05a5a96595cc0f99788ee9f7b11d122284df7c70 + hermes-engine: 8992dac2fb037a77b783469d993f59f96167417e libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -5026,7 +5040,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 922be6f90a3addd48f57f2066dd90aacec4768b6 React: 2574546f2d017abd14d0c9b48cf2b6a0547c2591 React-callinvoker: b997d4d109c92cae4bc7f1edb08dc6a7c11a1ca2 - React-Core: 5c7a37fd611353c8bf1c12ef24cfdec81ff46549 + React-Core: ee317fb557001c3972bcf4fe15996197e65ae54d React-CoreModules: 2ae392df55453fca5602e9bb5bc16a72f36dd961 React-cxxreact: 9a58282eb607fb19d775cfaa8ff9da7be8fc99c5 React-debug: 307f174c04c2e1f51b31c31efcc3e635b1e56453 @@ -5078,7 +5092,7 @@ SPEC CHECKSUMS: React-NativeModulesApple: e477c7b5f198235d9de93702ef6b495b87b625bc React-networking: b90039a00fd63e75a78358ee5fc2ea4b4cbad736 React-oscompat: e3c95718adca6d0e7ca9e5b01a04d057f4aa8f72 - React-perflogger: 327334823748e1ecac8cb76e3aa1c072d99146af + React-perflogger: 8712c3310a467e036fceac437dc7f2a4be69e634 React-performancecdpmetrics: 992de932ea38bfc3e49f7b4eba19f6c1aca021bb React-performancetimeline: a0626b87f948b534b3fb90b696ad11863af94dd8 React-RCTActionSheet: cbe921221b0ca00d2ea586efbaa494bc990260c1 @@ -5094,7 +5108,7 @@ SPEC CHECKSUMS: React-RCTSettings: b478cd2ee4091e33bb847edef83c19aa87bf1c45 React-RCTText: 6bae9a5e52ebc012d7bc499542c42285f73d1e1d React-RCTVibration: 3ddd10141590a1800a1890248b47337d9004b7f4 - React-rendererconsistency: 8759d621ba9d2a3139c266d3462bf2e38ccc4b01 + React-rendererconsistency: d1e82296151f814802c06be35952c9fda99973b1 React-renderercss: 7b56e138a3d800fa9e17e61cdb3b293d6b8622c6 React-rendererdebug: cf7d7ca70a0b7263df1d49c87970ae6cfe7ce5d0 React-RuntimeApple: 39be405060d5dc5d1f9e4ee4e24ad09cd079a5db