diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx index 66ac91776a76..61a5c2466ecb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/components/VictoryChartExpandModal.tsx @@ -1,8 +1,9 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {CHART_TYPE, POLAR_CONTAINER_HEIGHT_RATIO} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; -import {useVictoryChartContext} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import {useVictoryChartContext, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; import {resolveChartContainerBgColor} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/resolveChartThemeColor'; import Modal from '@components/Modal'; +import MultiGestureCanvas from '@components/MultiGestureCanvas'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -16,6 +17,7 @@ import type {LayoutChangeEvent} from 'react-native'; import React, {useState} from 'react'; import {View} from 'react-native'; +import {useSharedValue} from 'react-native-reanimated'; import VictoryChartContent from './VictoryChartContent'; @@ -31,12 +33,11 @@ type VictoryChartExpandModalProps = { * Centered full-screen modal that re-renders the current chart scaled up to the viewport. * Must be rendered inside a VictoryChartProvider so VictoryChartContent can read the parsed chart context. * - * The chart is rendered at its design size and uniformly transform-scaled to fit the modal — - * the same technique the inline scaled container uses to shrink charts. This keeps the canvas - * and the absolutely-positioned label/legend overlays (whose coordinates are design-based) - * perfectly aligned, so the expanded chart looks identical to the inline one, only larger. - * Rendering fluidly instead would resize only the canvas and leave labels at design coordinates, - * misplacing them (and potentially overlaying the header, blocking the back button). + * The chart is re-rendered natively at the target size through VictoryChartScaledProvider, which + * scales every pixel-space value (labels, legends, axes, paddings) by the same uniform factor — + * so the expanded chart is a sharp Skia render that looks identical to the inline one, only larger. + * The chart is wrapped in MultiGestureCanvas, giving it the same pinch/double-tap zoom and pan + * gestures as image attachments. */ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalProps) { const styles = useThemeStyles(); @@ -46,8 +47,12 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr const {shouldUseNarrowLayout} = useResponsiveLayout(); const {chartContentStyles, chartContainerStyles, type} = useVictoryChartContext(); const [availableSize, setAvailableSize] = useState({width: 0, height: 0}); + // No pager wraps this canvas, so scrolling never needs to be handed back to one. + const isPagerScrollEnabled = useSharedValue(false); const onContainerLayout = (event: LayoutChangeEvent) => { + // Ignore layout changes while the modal is closing — re-measuring mid-animation + // would rescale the chart and cause a visible flicker. if (!isVisible) { return; } @@ -69,6 +74,24 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr // Uniform scale that fits the chart's (clipped) design box inside the available modal area (may be > 1). const scale = hasDesignDimensions && effectiveDesignHeight !== undefined && isMeasured ? Math.min(availableSize.width / designWidth, availableSize.height / effectiveDesignHeight) : 1; + // Target render size: the chart is drawn natively at these dimensions for a sharp result. + const targetWidth = (designWidth ?? 0) * scale; + const targetHeight = (designHeight ?? 0) * scale; + const clippedTargetHeight = (effectiveDesignHeight ?? 0) * scale; + + // Cartesian charts render with zoom headroom: the canvas is drawn larger than the fitted size and + // displayed scaled down, so pinch-zooming stays sharp up to the headroom factor instead of + // magnifying raster pixels immediately. Capped so the canvas never exceeds a safe texture size. + const MAX_CANVAS_DIMENSION = 2048; + const zoomHeadroom = Math.max(1, Math.min(2, MAX_CANVAS_DIMENSION / Math.max(targetWidth, targetHeight, 1))); + const renderWidth = targetWidth * zoomHeadroom; + const renderHeight = targetHeight * zoomHeadroom; + + // Polar charts render at design size and are transform-scaled; cartesian charts render natively with headroom. + const contentBoxWidth = isPolar ? (designWidth ?? 0) : renderWidth; + const contentBoxHeight = isPolar ? (designHeight ?? 0) : renderHeight; + const contentBoxScale = isPolar ? scale : 1 / zoomHeadroom; + // Visual styles parsed from the chart HTML — resolved and applied the same way // VictoryChartContainerFixed does inline, so the expanded chart keeps the same // (theme-aware) background and rounding. @@ -103,32 +126,53 @@ function VictoryChartExpandModal({isVisible, onClose}: VictoryChartExpandModalPr > {isMeasured && (hasDesignDimensions && effectiveDesignHeight !== undefined ? ( - // Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. - - {/* Fixed design-size box so the fluid chart renders at design size, then scaled uniformly. */} + {/* Clip the container (not the content) so polar dead space is hidden while the chart renders at full fidelity. */} - {/* The Skia canvas is removed as soon as closing starts: WebGL canvases can - flash white when re-composited during the close animation (visible on dark - themes). The card box stays so the modal animates out looking intact. */} - {isVisible && } + {/* Cartesian charts are re-rendered natively at the target size (sharp Skia output) via the + scaled context. Polar charts keep the uniform transform-scale of the design-size render + instead: their geometry (radius, label layout) is parsed from HTML attributes in the pie + components, so a scaled context alone cannot resize them consistently. */} + + {/* The Skia canvas is removed as soon as closing starts: WebGL canvases can + flash white when re-composited during the close animation (visible on dark + themes). The card box stays so the modal animates out looking intact. */} + {isVisible && + (isPolar ? ( + + ) : ( + + + + ))} + - + ) : ( // Charts without design dimensions have no design-based label coordinates, so fluid // rendering is safe. Background/rounding are still applied so the expanded chart diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx index f51afbfbd0f9..d230db122936 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext.tsx @@ -2,10 +2,11 @@ import type {ChartType, LabelItem, LegendItem, ProcessNodeResult} from '@compone import computeAdjustedOverlayY from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeAdjustedOverlayY'; import computeDynamicChartHeight from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/computeDynamicChartHeight'; import parseStyles from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/parseStyles'; +import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; import type {TNode} from 'react-native-render-html'; -import React, {createContext, useContext} from 'react'; +import React, {createContext, useContext, useMemo} from 'react'; type VictoryChartContextValue = { tnode: TNode; @@ -78,6 +79,24 @@ function VictoryChartProvider({tnode, processedResult, type, children}: VictoryC return {children}; } +type VictoryChartScaledProviderProps = { + /** Uniform factor to scale all pixel-space chart config by (may be > 1) */ + scale: number; + + children: React.ReactNode; +}; + +/** + * Re-provides the current chart context with every pixel-space value scaled by a uniform factor. + * Used by the expand modal to re-render the chart natively at a larger size (sharp Skia output) + * while keeping labels, legends, axes, and paddings proportionally identical to the inline chart. + */ +function VictoryChartScaledProvider({scale, children}: VictoryChartScaledProviderProps) { + const value = useVictoryChartContext(); + const scaledValue = useMemo(() => scaleVictoryChartContextValue(value, scale), [value, scale]); + return {children}; +} + function useVictoryChartContext(): VictoryChartContextValue { const context = useContext(VictoryChartContext); if (!context) { @@ -86,4 +105,5 @@ function useVictoryChartContext(): VictoryChartContextValue { return context; } -export {VictoryChartProvider, useVictoryChartContext}; +export {VictoryChartProvider, VictoryChartScaledProvider, useVictoryChartContext}; +export type {VictoryChartContextValue}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts new file mode 100644 index 000000000000..f5b01717bacd --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue.ts @@ -0,0 +1,124 @@ +import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {LabelItem, LegendItem} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; + +import type {SkFont} from '@shopify/react-native-skia'; + +import {Skia} from '@shopify/react-native-skia'; + +/** + * Scales every pixel-space value of a parsed chart context by a uniform factor, so the chart can be + * re-rendered natively at a larger target size (sharp Skia output) instead of raster-upscaling the + * design-size render. Data-space values (data points, domains, tick values) are left untouched — + * the chart's axes map them into the larger canvas automatically. + */ + +function scaleRecordValues(record: Record | undefined, scale: number): Record | undefined { + if (!record) { + return record; + } + return Object.fromEntries(Object.entries(record).map(([key, fontValue]) => [key, fontValue * scale])); +} + +function scaleLabelItem(labelItem: LabelItem, scale: number): LabelItem { + return { + ...labelItem, + x: labelItem.x * scale, + y: labelItem.y * scale, + // lineHeight is a multiplier of the font size, so it needs no scaling. + fontSize: scaleRecordValues(labelItem.fontSize, scale), + }; +} + +function scaleLegendItem(legendItem: LegendItem, scale: number): LegendItem { + return { + ...legendItem, + x: legendItem.x * scale, + y: legendItem.y * scale, + gutter: legendItem.gutter === undefined ? undefined : legendItem.gutter * scale, + symbolSpacer: legendItem.symbolSpacer === undefined ? undefined : legendItem.symbolSpacer * scale, + entries: legendItem.entries.map((entry) => ({ + ...entry, + fontSize: entry.fontSize === undefined ? undefined : entry.fontSize * scale, + symbolSize: entry.symbolSize === undefined ? undefined : entry.symbolSize * scale, + })), + }; +} + +type SidedPixelValues = {left?: number; right?: number; top?: number; bottom?: number}; + +function scaleSidedPixelValues(sides: SidedPixelValues, scale: number): SidedPixelValues { + return { + left: sides.left === undefined ? undefined : sides.left * scale, + right: sides.right === undefined ? undefined : sides.right * scale, + top: sides.top === undefined ? undefined : sides.top * scale, + bottom: sides.bottom === undefined ? undefined : sides.bottom * scale, + }; +} + +/** Padding can be a plain number or a per-side object — scale every numeric part. */ +function scalePadding(padding: VictoryChartContextValue['padding'], scale: number): VictoryChartContextValue['padding'] { + if (padding === undefined) { + return undefined; + } + if (typeof padding === 'number') { + return padding * scale; + } + return scaleSidedPixelValues(padding, scale); +} + +/** Domain padding can be a plain number or a per-side object — scale every numeric part. */ +function scaleDomainPadding(domainPadding: VictoryChartContextValue['domainPadding'], scale: number): VictoryChartContextValue['domainPadding'] { + if (domainPadding === undefined) { + return undefined; + } + if (typeof domainPadding === 'number') { + return domainPadding * scale; + } + return scaleSidedPixelValues(domainPadding, scale); +} + +/** Rebuilds a Skia font at the scaled size; the original font object is left untouched. */ +function scaleFont(font: SkFont | null | undefined, scale: number): SkFont | null | undefined { + if (!font) { + return font; + } + const typeface = font.getTypeface(); + if (!typeface) { + return font; + } + return Skia.Font(typeface, font.getSize() * scale); +} + +function scaleAxis(axis: TAxis, scale: number): TAxis { + if (!axis) { + return axis; + } + return { + ...axis, + lineWidth: axis.lineWidth === undefined ? undefined : axis.lineWidth * scale, + labelOffset: axis.labelOffset === undefined ? undefined : axis.labelOffset * scale, + font: scaleFont(axis.font, scale), + }; +} + +function scaleVictoryChartContextValue(value: VictoryChartContextValue, scale: number): VictoryChartContextValue { + if (scale === 1) { + return value; + } + + const designWidth = typeof value.chartContentStyles.width === 'number' ? value.chartContentStyles.width * scale : value.chartContentStyles.width; + const designHeight = typeof value.chartContentStyles.height === 'number' ? value.chartContentStyles.height * scale : value.chartContentStyles.height; + + return { + ...value, + xAxis: scaleAxis(value.xAxis, scale), + yAxis: value.yAxis?.map((axis) => scaleAxis(axis, scale)), + domainPadding: scaleDomainPadding(value.domainPadding, scale), + padding: scalePadding(value.padding, scale), + labelItems: value.labelItems.map((labelItem) => scaleLabelItem(labelItem, scale)), + legendItems: value.legendItems.map((legendItem) => scaleLegendItem(legendItem, scale)), + chartContentStyles: {...value.chartContentStyles, width: designWidth, height: designHeight}, + }; +} + +export default scaleVictoryChartContextValue; diff --git a/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx new file mode 100644 index 000000000000..bb1d9bdb5175 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/VictoryChartScaledProviderTest.tsx @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention -- test-only: chart context mocks are narrowed from minimal literals, and per-line font maps are keyed by numeric line index */ +import {render, screen} from '@testing-library/react-native'; + +import {CHART_TYPE} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/constants'; +import {useVictoryChartContext, VictoryChartProvider, VictoryChartScaledProvider} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import type {ProcessNodeResult} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/types'; +import Text from '@components/Text'; + +import type {TNode} from 'react-native-render-html'; + +import React from 'react'; + +const tnode = {attributes: {width: '680', height: '340'}, children: []} as unknown as TNode; + +const processedResult = { + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: undefined, + yAxis: undefined, + domain: undefined, + domainPadding: 20, + padding: 16, + leftAxisLabelPadding: undefined, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}}], + legendItems: [], +} as unknown as ProcessNodeResult; + +/** Serializes the parts of the context under test so assertions can read them from the rendered output. */ +function ContextProbe() { + const {padding, domainPadding, labelItems, chartContentStyles} = useVictoryChartContext(); + return {JSON.stringify({padding, domainPadding, firstLabel: labelItems.at(0), width: chartContentStyles.width, height: chartContentStyles.height})}; +} + +function getProbedContext(): Record { + return JSON.parse(screen.getByTestId('contextProbe').props.children as string) as Record; +} + +describe('VictoryChartScaledProvider', () => { + it('provides pixel-space values scaled by the given factor', () => { + render( + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 32, + domainPadding: 40, + firstLabel: {x: 680, y: 48, fontSize: {0: 28}}, + width: 1360, + height: 680, + }); + }); + + it('provides the unscaled context for scale 1', () => { + render( + + + + + , + ); + + expect(getProbedContext()).toMatchObject({ + padding: 16, + domainPadding: 20, + firstLabel: {x: 340, y: 24}, + }); + }); +}); diff --git a/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts new file mode 100644 index 000000000000..11e690b55059 --- /dev/null +++ b/tests/unit/components/HTMLEngineProvider/scaleVictoryChartContextValueTest.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention -- test-only: chart context mocks are narrowed from minimal literals, and per-line font maps are keyed by numeric line index */ +import type {VictoryChartContextValue} from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/context/VictoryChartContext'; +import scaleVictoryChartContextValue from '@components/HTMLEngineProvider/HTMLRenderers/VictoryChartRenderer/utils/scaleVictoryChartContextValue'; + +import type {TNode} from 'react-native-render-html'; + +const baseValue = { + tnode: {} as TNode, + data: {Jan: {x: 'Jan', y1: 10}}, + xKey: 'x', + yKeys: ['y1'], + xAxis: {tickCount: 3, tickValues: [1, 2, 3], lineWidth: 1, labelOffset: 8, font: null}, + yAxis: [{tickCount: 4, tickValues: [0, 10, 20, 30], lineWidth: 2, labelOffset: 4, font: null}], + domain: {y: [0, 40]}, + domainPadding: {left: 20, right: 20}, + padding: 16, + isHorizontal: false, + categories: undefined, + labelItems: [{x: 340, y: 24, text: 'Title', fontSize: {0: 14}, lineHeight: {0: 1.2}}], + legendItems: [{x: 100, y: 200, gutter: 8, symbolSpacer: 4, entries: [{text: 'A', fontSize: 12, symbolSize: 6}]}], + chartContentStyles: {width: 680, height: 340}, + chartContainerStyles: {}, + type: 'cartesian', +} as unknown as VictoryChartContextValue; + +describe('scaleVictoryChartContextValue', () => { + it('returns the same value for scale 1', () => { + expect(scaleVictoryChartContextValue(baseValue, 1)).toBe(baseValue); + }); + + it('scales pixel-space values by the given factor', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.labelItems.at(0)).toMatchObject({x: 680, y: 48, fontSize: {0: 28}}); + expect(scaled.legendItems.at(0)).toMatchObject({x: 200, y: 400, gutter: 16, symbolSpacer: 8}); + expect(scaled.legendItems.at(0)?.entries.at(0)).toMatchObject({fontSize: 24, symbolSize: 12}); + expect(scaled.padding).toBe(32); + expect(scaled.domainPadding).toEqual({left: 40, right: 40}); + expect(scaled.chartContentStyles).toMatchObject({width: 1360, height: 680}); + expect(scaled.xAxis).toMatchObject({lineWidth: 2, labelOffset: 16}); + expect(scaled.yAxis?.at(0)).toMatchObject({lineWidth: 4, labelOffset: 8}); + }); + + it('leaves data-space values untouched', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + + expect(scaled.data).toEqual(baseValue.data); + expect(scaled.domain).toEqual(baseValue.domain); + expect(scaled.xAxis).toMatchObject({tickCount: 3, tickValues: [1, 2, 3]}); + expect(scaled.yAxis?.at(0)).toMatchObject({tickValues: [0, 10, 20, 30]}); + }); + + it('does not scale line-height multipliers', () => { + const scaled = scaleVictoryChartContextValue(baseValue, 2); + expect(scaled.labelItems.at(0)?.lineHeight).toEqual({0: 1.2}); + }); + + it('returns axis fonts unchanged when they have no typeface', () => { + const fakeFont = {getTypeface: () => null, getSize: () => 12}; + const value = {...baseValue, xAxis: {...(baseValue.xAxis as Record), font: fakeFont}} as unknown as typeof baseValue; + const scaled = scaleVictoryChartContextValue(value, 2); + expect((scaled.xAxis as Record).font).toBe(fakeFont); + }); + + it('handles missing optional fields without throwing', () => { + const value = { + ...baseValue, + xAxis: undefined, + yAxis: undefined, + domainPadding: undefined, + padding: undefined, + labelItems: [{x: 1, y: 2, text: 'bare'}], + legendItems: [{x: 1, y: 2, entries: [{text: 'A'}]}], + chartContentStyles: {}, + } as unknown as typeof baseValue; + const scaled = scaleVictoryChartContextValue(value, 3); + expect(scaled.labelItems.at(0)).toMatchObject({x: 3, y: 6}); + expect(scaled.legendItems.at(0)?.entries.at(0)).toMatchObject({text: 'A'}); + expect(scaled.xAxis).toBeUndefined(); + expect(scaled.padding).toBeUndefined(); + }); +});