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} + > + ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartBarGroup.tsx index 02113b73b1f5..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'; @@ -18,7 +19,7 @@ type VictoryChartBarGroupProps = { }; function VictoryChartBarGroup({tnode, isHorizontal}: VictoryChartBarGroupProps) { - const {points, chartBounds} = useVictoryChartRenderArgs(); + const {points, chartBounds, pixelScale} = useVictoryChartRenderArgs(); const barChildren = tnode.children.filter((child) => child.tagName === 'victorybar'); const firstBarChild = barChildren.at(0); @@ -26,10 +27,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 = scalePixels(rawBarWidth, pixelScale); const betweenGroupPadding = barWidth - ? parseOffset(tnode.attributes.offset, chartBounds, barChildren.length, barWidth, points[getYKey(firstBarChild)].length, isHorizontal ?? false) + ? parseOffset(tnode.attributes.offset, chartBounds, barChildren.length, barWidth, points[getYKey(firstBarChild)].length, isHorizontal ?? false, pixelScale) : undefined; return ( diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx index b2e16df4d591..54669fc1fab9 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartCartesian.tsx @@ -5,6 +5,7 @@ import type {CartesianChartData, YKey} from '@components/HTMLEngineProvider/HTML 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 resolveChartThemeColor from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import useCurrentTimezone from '@hooks/useCurrentTimezone'; @@ -28,6 +29,9 @@ type VictoryChartCartesianProps = { /** When true, renders without visible chrome (used for snapshots/tests) */ headless?: boolean; + /** Render into a static bitmap canvas instead of a live WebGL canvas (web) */ + shouldUseStaticCanvas?: boolean; + onRenderArgs?: (renderArgs: CartesianChartRenderArg) => void; }; @@ -35,8 +39,8 @@ 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) { - const {tnode, data, xKey, yKeys, xAxis, yAxis, domain, domainPadding, padding, isHorizontal, labelItems, legendItems, chartContentStyles} = useVictoryChartContext(); +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(); const designWidth = getChartDesignWidth(explicitSize, chartContentStyles.width); @@ -65,9 +69,10 @@ function VictoryChartCartesian({explicitSize, headless, onRenderArgs}: VictoryCh domainPadding={domainPadding} padding={padding} {...getChartLayoutModeProps(explicitSize, headless)} + canvasProps={shouldUseStaticCanvas ? getStaticChartCanvasProps() : undefined} renderOutside={(renderArgs) => { const overlayContent = ( - + {labelItems.map((labelItem) => ( + {tnode.children.map((child) => ( ); 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 b76164c3d096..99ff2aa796a8 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,7 +1,4 @@ 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 {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import Modal from '@components/Modal'; import useLocalize from '@hooks/useLocalize'; @@ -16,8 +13,11 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; +import {GestureHandlerRootView} from 'react-native-gesture-handler'; import VictoryChartContent from './VictoryChartContent'; +import VictoryChartExpandedContent from './VictoryChartExpandedContent'; +import useExpandedChartLayout from './VictoryChartExpandedContent/useExpandedChartLayout'; type VictoryChartExpandModalProps = { isVisible: boolean; @@ -27,15 +27,9 @@ 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). + * 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(); @@ -43,48 +37,37 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const theme = useTheme(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); 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); const onContainerLayout = (event: LayoutChangeEvent) => { + // 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 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; - - // 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; - - // 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. - const backgroundColor = resolveChartContainerBgColor(chartContainerStyles.backgroundColor, theme); - const borderRadius = chartContainerStyles.borderRadius; + const shouldRenderChart = isMeasured && (isVisible || !isHidden); return ( setIsHidden(false)} + onModalHide={() => setIsHidden(true)} + // Browser back should close only the modal, not the report behind it + shouldHandleNavigationBack enableEdgeToEdgeBottomSafeAreaPadding > - {/* 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. */} - - {/* Header matches the attachment modal: back button on narrow layouts, close button on the right otherwise. */} + {/* 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 */} + - {/* Padding lives on the outer view; the inner view is measured so the scale never - exceeds the actual content area and the side gutters are preserved. */} - {isMeasured && - (hasDesignDimensions && effectiveDesignHeight !== undefined ? ( - // Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. - - {/* Fixed design-size box so the fluid chart renders at design size, then scaled uniformly. */} - - {/* 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 && } - - + {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 - {isVisible && } + ))} - + ); } 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..3e21df31461f --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/BaseVictoryChartExpandedContent.tsx @@ -0,0 +1,49 @@ +import MultiGestureCanvas from '@components/MultiGestureCanvas'; + +import React from 'react'; +import {useSharedValue} from 'react-native-reanimated'; + +import type VictoryChartExpandedContentProps from './types'; + +import ExpandedChartBox from './ExpandedChartBox'; + +/** + * 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, 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) { + 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..e17cbf07a196 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/ExpandedChartBox.tsx @@ -0,0 +1,66 @@ +import VictoryChartContent from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartContent'; +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'; + +type ExpandedChartBoxProps = { + /** Rendered chart width in pixels */ + width: number; + + /** Rendered chart height in pixels (full design canvas) */ + height: number; + + /** Visible height — smaller than `height` for polar charts, whose dead bottom space is clipped */ + clippedHeight: number; + + /** Factor the chart's pixel-space config is scaled by for this render size */ + providerScale: number; + + /** Container background, theme-resolved */ + backgroundColor: ColorValue | undefined; + + /** Container corner radius, already scaled to the render size */ + borderRadius: number | undefined; + + /** Whether the chart is polar — its clip box keeps the rounded corners */ + isPolar: boolean; +}; + +/** 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(); + + return ( + + + + + + + + ); +} + +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..d87dc175d292 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.native.tsx @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000000..3470204ae2cf --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/index.tsx @@ -0,0 +1,113 @@ +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'; + +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; + +import CONST from '@src/CONST'; + +import type {View as RNView} from 'react-native'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +import type VictoryChartExpandedContentProps from './types'; + +import BaseVictoryChartExpandedContent from './BaseVictoryChartExpandedContent'; +import ExpandedChartBox from './ExpandedChartBox'; + +/** + * 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, layout, isVisible}: VictoryChartExpandedContentProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const scrollableRef = useRef(null); + + // 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: 1, + }); + + // Don't reopen in a stale zoomed state + useEffect(() => { + if (isVisible) { + return; + } + resetZoom(); + }, [isVisible, resetZoom]); + + if (!layout.hasLayout) { + return null; + } + + const chartBox = ( + + + + + + ); + + return ( + + {/* Centering is dropped while zoomed: centered overflow would push the chart's top/left past the scroll origin */} + + {canZoom ? ( + + {chartBox} + + ) : ( + chartBox + )} + + + ); +} + +DesktopVictoryChartExpandedContent.displayName = 'DesktopVictoryChartExpandedContent'; + +/** Touch devices zoom like the Lightbox; desktop web zooms like the image attachment viewer. */ +function VictoryChartExpandedContent(props: VictoryChartExpandedContentProps) { + if (canUseTouchScreenUtil()) { + return ; + } + 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..966525588111 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/types.ts @@ -0,0 +1,19 @@ +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 */ + 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 new file mode 100644 index 000000000000..ba3027390603 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandedContent/useExpandedChartLayout.ts @@ -0,0 +1,38 @@ +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 useTheme from '@hooks/useTheme'; + +import type {Dimensions} from '@src/types/utils/Layout'; + +import type {ColorValue} from 'react-native'; + +type ThemedExpandedChartLayout = ExpandedChartLayout & { + /** Theme-resolved container background parsed from the chart HTML */ + backgroundColor: ColorValue | undefined; + + /** Unscaled container corner radius, for the fluid (design-size) fallback */ + designBorderRadius: number | undefined; +}; + +/** 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 borderRadius = typeof chartContainerStyles.borderRadius === 'number' ? chartContainerStyles.borderRadius : undefined; + + return { + ...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/VictoryChartLine.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx index 41964ca9be24..b70f8ab14ce6 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartLine.tsx @@ -12,14 +12,14 @@ import {Line} from 'victory-native'; type VictoryChartLineProps = {tnode: TNode}; function VictoryChartLine({tnode}: VictoryChartLineProps) { - const {points} = useVictoryChartRenderArgs(); + const {points, pixelScale} = useVictoryChartRenderArgs(); const yKey = getYKey(tnode); const {nodeStyles} = parseStyles(tnode); return ( ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx index 5cc2f662b0c5..84bf11d2cbff 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartPie.tsx @@ -14,6 +14,8 @@ 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'; @@ -44,17 +46,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 = scalePixels(rawLabelRadius, pixelScale); + const rawInnerRadius = parseAttributeAsNumber(tnode.attributes.innerradius); + const innerRadius = scalePixels(rawInnerRadius, pixelScale); const padAngle = parseAttributeAsNumber(tnode.attributes.padangle); - const radius = parseAttributeAsNumber(tnode.attributes.radius); + const rawRadius = parseAttributeAsNumber(tnode.attributes.radius); + const radius = scalePixels(rawRadius, pixelScale); const effectiveLabelRadius = labelRadius ?? radius; const size = radius ? radius * 2 : undefined; const angularStrokeWidth = padAngle && radius ? 2 * convertAngleToArcLength(padAngle, radius) : 0; @@ -62,10 +70,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 = scalePixels(rawIndicatorXShift, pixelScale); + const labelIndicatorYShift = scalePixels(rawIndicatorYShift, pixelScale); + const labelIndicatorStrokeWidth = scalePixels(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 = scalePixels(rawIndicatorInnerOffset, pixelScale); + const rawIndicatorOuterOffset = parseAttributeAsNumber(tnode.attributes.labelindicatorouteroffset); + const labelIndicatorOuterOffset = scalePixels(rawIndicatorOuterOffset, pixelScale); const customLabelByDataLabel: Record = {}; const sliceValues: PieSliceValue[] = []; @@ -86,12 +99,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 +117,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 ( {headless ? ( {chartContent} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index f51afbfbd0f9..26b685a6309f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -1,11 +1,14 @@ +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'; import parseStyles from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseStyles'; +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; @@ -25,6 +28,9 @@ type VictoryChartContextValue = { chartContentStyles: ReturnType['nodeStyles']; chartContainerStyles: ReturnType['parentNodeStyles']; type: ChartType; + + /** Factor already applied to this context's pixel values (1 inline); raw tnode pixel attributes must be multiplied by it */ + pixelScale: number; }; const VictoryChartContext = createContext(null); @@ -73,11 +79,36 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC chartContentStyles: effectiveChartContentStyles, chartContainerStyles, type, + pixelScale: 1, }; return {children}; } +type VictoryChartScaledProviderProps = { + /** Uniform factor to scale all pixel-space chart config by (may be > 1) */ + scale: number; + + /** Chart sub-tree to re-provide the scaled context to */ + children: React.ReactNode; +}; + +/** + * Re-provides the 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); + 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}; +} + function useVictoryChartContext(): VictoryChartContextValue { const context = useContext(VictoryChartContext); if (!context) { @@ -86,4 +117,5 @@ function useVictoryChartContext(): VictoryChartContextValue { return context; } -export {VictoryChartProvider, useVictoryChartContext}; +export {VictoryChartProvider, VictoryChartScaledProvider, useVictoryChartContext}; +export type {VictoryChartContextValue}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx index 1bb006417c36..af005d19b900 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartRenderArgsContext.tsx @@ -4,19 +4,28 @@ import type {CartesianChartRenderArg} from 'victory-native'; import React, {createContext, useContext} from 'react'; -const VictoryChartRenderArgsContext = createContext | null>(null); +type VictoryChartRenderArgs = CartesianChartRenderArg & { + /** + * Uniform factor applied to the chart's pixel-space config (1 for inline charts). It travels + * through this context because series components render inside the chart's canvas, where the + * outer VictoryChartContext does not propagate. + */ + pixelScale: number; +}; + +const VictoryChartRenderArgsContext = createContext(null); /** * Makes the CartesianChart render-prop arguments available to series sub-components * (VictoryChartBar, VictoryChartLine) rendered inside the chart's children callback. */ -function VictoryChartRenderArgsProvider({value, children}: {value: CartesianChartRenderArg; children: React.ReactNode}) { +function VictoryChartRenderArgsProvider({value, children}: {value: VictoryChartRenderArgs; children: React.ReactNode}) { return {children}; } VictoryChartRenderArgsProvider.displayName = 'VictoryChartRenderArgsProvider'; -function useVictoryChartRenderArgs(): CartesianChartRenderArg { +function useVictoryChartRenderArgs(): VictoryChartRenderArgs { const context = useContext(VictoryChartRenderArgsContext); if (!context) { throw new Error('useVictoryChartRenderArgs must be used within VictoryChartRenderArgsProvider'); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/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/parseCornerRadius.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts index ba2841e226e2..7691a37db94e 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseCornerRadius.ts @@ -3,18 +3,21 @@ 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. + * `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 +45,12 @@ function parseCornerRadius(attribute: string): RoundedCorners | undefined { } else if ('bottom' in cornerRadius) { bottomRight = Number(cornerRadius.bottom); } - return {topLeft, topRight, bottomLeft, bottomRight}; + return { + 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/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/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 new file mode 100644 index 000000000000..bc5747d32460 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -0,0 +1,125 @@ +import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {LabelItem, LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +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 size. Data-space values (points, domains, ticks) are untouched. + */ + +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: scalePixels(legendItem.gutter, scale), + symbolSpacer: scalePixels(legendItem.symbolSpacer, scale), + entries: legendItem.entries.map((entry) => ({ + ...entry, + fontSize: scalePixels(entry.fontSize, scale), + symbolSize: scalePixels(entry.symbolSize, scale), + })), + }; +} + +type SidedPixelValues = {left?: number; right?: number; top?: number; bottom?: number}; + +function scaleSidedPixelValues(sides: SidedPixelValues, scale: number): SidedPixelValues { + return { + left: scalePixels(sides.left, scale), + right: scalePixels(sides.right, scale), + top: scalePixels(sides.top, scale), + bottom: scalePixels(sides.bottom, scale), + }; +} + +/** (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; + } + if (typeof padding === 'number') { + return padding * scale; + } + return scaleSidedPixelValues(padding, scale); +} + +/** + * 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) { + return font; + } + return Skia.Font(typeface, font.getSize() * scale); +} + +function scaleAxis(axis: TAxis, scale: number, typeface: SkTypeface | null): TAxis { + if (!axis) { + return axis; + } + return { + ...axis, + lineWidth: scalePixels(axis.lineWidth, scale), + labelOffset: scalePixels(axis.labelOffset, scale), + font: scaleFont(axis.font, scale, typeface), + }; +} + +function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: number, typeface: SkTypeface | null = null): 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, typeface), + yAxis: value.yAxis?.map((axis) => scaleAxis(axis, scale, typeface)), + 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)), + chartContentStyles: {...value.chartContentStyles, width: designWidth, height: designHeight}, + pixelScale: value.pixelScale * scale, + }; +} + +/** 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, disposeScaledFonts}; 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/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/index.ts b/src/hooks/useClickZoomPan/index.ts new file mode 100644 index 000000000000..ac4c8c70b77f --- /dev/null +++ b/src/hooks/useClickZoomPan/index.ts @@ -0,0 +1,143 @@ +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; + +import type {SyntheticEvent} from 'react'; +import type {GestureResponderEvent} from 'react-native'; + +import {useEffect, useState} from 'react'; + +import type UseClickZoomPan from './types'; + +type ZoomDelta = {offsetX: number; offsetY: number}; + +/** + * 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. + */ +const useClickZoomPan: UseClickZoomPan = ({scrollableRef, containerSize, zoomFactor}) => { + 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; + + // 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 { + setZoomDelta({offsetX: 0, offsetY: 0}); + } + } + + if (isZoomed && isDragging && isMouseDown) { + setIsDragging(false); + setIsMouseDown(false); + } else { + setIsZoomed(!isZoomed); + setIsMouseDown(false); + } + }; + + const resetZoom = () => { + setIsZoomed(false); + setIsDragging(false); + setIsMouseDown(false); + setZoomDelta(undefined); + }; + + 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); + } + }; + + const trackMovement = (event: MouseEvent) => { + if (!isZoomed) { + return; + } + + 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; + if (!isZoomed || !zoomDelta || !scrollableContainer) { + return; + } + scrollableContainer.scrollLeft = zoomDelta.offsetX; + scrollableContainer.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/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/src/styles/index.ts b/src/styles/index.ts index 1413e4fbb265..3c31f0c38866 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6661,9 +6661,6 @@ const staticStyles = (theme: ThemeColors) => chartContainer: { borderRadius: variables.componentBorderRadiusLarge, }, - chartExpandedContent: { - transformOrigin: 'top left', - }, chartContent: { height: CHART_CONTENT_MIN_HEIGHT, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 81dafe19eaf8..99aa438a541c 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, diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx new file mode 100644 index 000000000000..65bfcba3d9d0 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -0,0 +1,97 @@ +/* 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'; +import Text from '@components/Text'; + +import type {TNode} from 'react-native-render-html'; + +import React from 'react'; + +const tnode = {attributes: {width: '680', height: '340'}, children: []} as unknown as TNode; + +const processedResult = { + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: undefined, + yAxis: undefined, + domain: undefined, + domainPadding: 20, + padding: 16, + leftAxisLabelPadding: undefined, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}}], + legendItems: [], +} as unknown as ProcessNodeResult; + +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(); + return ( + + {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height, pixelScale})} + + ); +} + +function getProbedContext(): Record { + return JSON.parse(screen.getByTestId('contextProbe').props.children as string) as Record; +} + +describe('VictoryChartScaledProvider', () => { + it('provides pixel-space values scaled by the given factor', () => { + render( + + + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 32, + domainPadding: 40, + firstLabel: {x: 680, y: 48, fontSize: {0: 28}}, + width: 1360, + height: 680, + pixelScale: 2, + }); + }); + + it('provides the unscaled context for scale 1', () => { + render( + + + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 16, + domainPadding: 20, + firstLabel: {x: 340, y: 24}, + pixelScale: 1, + }); + }); +}); diff --git a/tests/unit/components/HTMLEngineProvider/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(); + }); +}); diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts new file mode 100644 index 000000000000..62115530a793 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention, 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'; + +import type {TNode} from 'react-native-render-html'; + +const baseValue = { + tnode: {} as TNode, + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: {tickCount: 3, tickValues: [1, 2, 3], lineWidth: 1, labelOffset: 8, font: null}, + yAxis: [{tickCount: 4, tickValues: [0, 10, 20, 30], lineWidth: 2, labelOffset: 4, font: null}], + domain: {y: [0, 40]}, + domainPadding: {left: 20, right: 20}, + padding: 16, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}, lineHeight: {0: 1.2}}], + legendItems: [{x: 100, y: 200, gutter: 8, symbolSpacer: 4, entries: [{text: 'A', fontSize: 12, symbolSize: 6}]}], + chartContentStyles: {width: 680, height: 340}, + chartContainerStyles: {}, + type: 'cartesian', + pixelScale: 1, +} as unknown as VictoryChartContextValue; + +describe('scaleVictoryChartContextValue', () => { + it('returns the same value for scale 1', () => { + expect(scaleVictoryChartContextValue(baseValue, 1)).toBe(baseValue); + }); + + it('scales pixel-space values by the given factor', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.labelItems.at(0)).toMatchObject({x: 680, y: 48, fontSize: {0: 28}}); + expect(scaled.legendItems.at(0)).toMatchObject({x: 200, y: 400, gutter: 16, symbolSpacer: 8}); + expect(scaled.legendItems.at(0)?.entries.at(0)).toMatchObject({fontSize: 24, symbolSize: 12}); + expect(scaled.padding).toBe(32); + expect(scaled.domainPadding).toEqual({left: 40, right: 40}); + expect(scaled.chartContentStyles).toMatchObject({width: 1360, height: 680}); + expect(scaled.xAxis).toMatchObject({lineWidth: 2, labelOffset: 16}); + expect(scaled.pixelScale).toBe(2); + expect(scaled.yAxis?.at(0)).toMatchObject({lineWidth: 4, labelOffset: 8}); + }); + + it('leaves data-space values untouched', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.data).toEqual(baseValue.data); + expect(scaled.domain).toEqual(baseValue.domain); + expect(scaled.xAxis).toMatchObject({tickCount: 3, tickValues: [1, 2, 3]}); + expect(scaled.yAxis?.at(0)).toMatchObject({tickValues: [0, 10, 20, 30]}); + }); + + it('does not scale line-height multipliers', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + expect(scaled.labelItems.at(0)?.lineHeight).toEqual({0: 1.2}); + }); + + it('returns axis fonts unchanged when 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); + }); + + 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(); + }); +});