diff --git a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.module.scss b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.module.scss new file mode 100644 index 000000000..e2630e87b --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.module.scss @@ -0,0 +1,13 @@ +.lineChart { + display: grid; + gap: 12px; +} + +.chart { + width: 100%; + height: 680px; + + &.expandedLegend { + height: 800px; + } +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.spec.tsx b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.spec.tsx index 94ad83347..5cd31c051 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.spec.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.spec.tsx @@ -1,7 +1,9 @@ -import { render } from '@testing-library/react'; +import { createRef } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, beforeEach, vi } from 'vitest'; +import * as echarts from 'echarts'; -import LineChart from './LineChart'; +import { LineChart, LineChartHandle } from './LineChart'; import { mapPxTableToChartDataset } from '../Utils/chartDataMapper'; import { useEChartOption } from '../Utils/useEChartOption'; import { @@ -11,6 +13,7 @@ import { import { getChartColorsFromCssVariables } from '../Utils/chartHelper'; import type { EChartsDataset } from '../Utils/chartTypes'; import type { PxTable } from '../../../shared-types/pxTable'; +import styles from './LineChart.module.scss'; vi.mock('../Utils/chartDataMapper', () => ({ mapPxTableToChartDataset: vi.fn(), @@ -36,6 +39,14 @@ vi.mock('../Utils/chartHelper', () => ({ getChartColorsFromCssVariables: vi.fn(), })); +vi.mock('echarts', async () => { + const actual = await vi.importActual('echarts'); + return { + ...actual, + init: vi.fn(actual.init), + }; +}); + const mockDataset: EChartsDataset = { title: 'Population by year', origin: 'Statistics Demo', @@ -64,6 +75,30 @@ function getTooltipFormatter(option: { tooltip?: unknown }) { : undefined; } +function getLastChartOption() { + const calls = vi.mocked(useEChartOption).mock.calls; + const lastCall = calls.at(-1); + + if (!lastCall) { + throw new Error('Expected useEChartOption to be called'); + } + + return lastCall[0]; +} + +const overflowingDataset: EChartsDataset = { + ...mockDataset, + dimensions: ['name', 's1', 's2', 's3', 's4', 's5'], + source: [{ name: '2024', s1: 10, s2: 12, s3: 14, s4: 16, s5: 18 }], + series: [ + { key: 's1', name: 'Detailed population segment 1' }, + { key: 's2', name: 'Detailed population segment 2' }, + { key: 's3', name: 'Detailed population segment 3' }, + { key: 's4', name: 'Detailed population segment 4' }, + { key: 's5', name: 'Detailed population segment 5' }, + ], +}; + describe('LineChart', () => { beforeEach(() => { vi.clearAllMocks(); @@ -105,7 +140,7 @@ describe('LineChart', () => { const option = vi.mocked(useEChartOption).mock.calls[0][0]; expect(option.legend).toEqual({ - height: 40 * mockDataset.series.length, + height: 72, }); expect(option.yAxis).toMatchObject({ name: 'persons', @@ -147,15 +182,13 @@ describe('LineChart', () => { ); }); - it('renders chart container with height based on number of series', () => { + it('renders chart container with stylesheet sizing', () => { const { container } = render(); - const chartDiv = Array.from(container.querySelectorAll('div')).find( - (element) => element.style.height, - ); + const chartDiv = container.querySelector(`.${styles.chart}`); expect(chartDiv).toBeTruthy(); - expect(chartDiv?.style.height).toBe('630px'); + expect(chartDiv?.getAttribute('style')).toBeNull(); }); it('returns empty tooltip text for empty params', () => { @@ -198,4 +231,233 @@ describe('LineChart', () => { expect(html).toContain(' { + vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset); + + render( + , + ); + + expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset); + expect(buildSeriesOption).toHaveBeenLastCalledWith( + overflowingDataset, + 'line', + ['#333333', '#444444'], + ); + expect(getLastChartOption().legend).toEqual({ + height: 48, + type: 'scroll', + orient: 'vertical', + data: overflowingDataset.series.map((series) => series.name), + pageButtonPosition: 'end', + pageFormatter: '{current} / {total}', + }); + expect(getLastChartOption().dataZoom).toBeUndefined(); + }); + + it('supports a horizontal ECharts paginated legend', () => { + vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset); + + render( + , + ); + + expect(getLastChartOption().legend).toEqual({ + height: 48, + type: 'scroll', + orient: 'horizontal', + data: overflowingDataset.series.map((series) => series.name), + pageButtonPosition: 'end', + pageFormatter: '{current} / {total}', + }); + }); + + it('reveals all legend items when show more mode is expanded', () => { + vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset); + + render( + , + ); + + expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset); + expect(getLastChartOption().legend).toEqual({ + height: 48, + data: ['Detailed population segment 1', 'Detailed population segment 2'], + }); + + fireEvent.click(screen.getByRole('button', { name: 'Show more' })); + + expect(buildDatasetOption).toHaveBeenLastCalledWith(overflowingDataset); + expect(getLastChartOption().legend).toEqual({ + height: 120, + data: overflowingDataset.series.map((series) => series.name), + }); + expect(screen.getByRole('button', { name: 'Show less' })).toBeTruthy(); + }); + + it('exposes getSvgDataURL that renders the option offscreen with an svg renderer', () => { + vi.mocked(useEChartOption).mockReturnValue({ + divRef: { current: null }, + chartRef: { + current: { + getDataURL: vi.fn(), + getWidth: () => 640, + getHeight: () => 480, + } as never, + }, + }); + + const svgExportGetDataURL = vi + .fn() + .mockReturnValue('data:image/svg+xml;base64,abc'); + const dispose = vi.fn(); + const setOption = vi.fn(); + const initSpy = vi.mocked(echarts.init).mockReturnValue({ + setOption, + getDataURL: svgExportGetDataURL, + dispose, + } as never); + + const ref = createRef(); + render(); + + expect(ref.current?.getSvgDataURL()).toBe('data:image/svg+xml;base64,abc'); + expect(initSpy).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + null, + expect.objectContaining({ renderer: 'svg', width: 640, height: 480 }), + ); + expect(svgExportGetDataURL).toHaveBeenCalledWith({ type: 'svg' }); + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ animation: false }), + ); + expect(dispose).toHaveBeenCalledTimes(1); + + initSpy.mockRestore(); + }); + + it('exposes getPngDataURL that renders the option offscreen with a canvas renderer', () => { + const svgChartGetDataURL = vi.fn(); + vi.mocked(useEChartOption).mockReturnValue({ + divRef: { current: null }, + chartRef: { + current: { + getDataURL: svgChartGetDataURL, + getWidth: () => 640, + getHeight: () => 480, + } as never, + }, + }); + + const pngChartGetDataURL = vi + .fn() + .mockReturnValue('data:image/png;base64,abc'); + const dispose = vi.fn(); + const setOption = vi.fn(); + const initSpy = vi.mocked(echarts.init).mockReturnValue({ + setOption, + getDataURL: pngChartGetDataURL, + dispose, + } as never); + + const ref = createRef(); + render(); + + expect(ref.current?.getPngDataURL()).toBe('data:image/png;base64,abc'); + expect(initSpy).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + null, + expect.objectContaining({ renderer: 'canvas', width: 640, height: 480 }), + ); + expect(pngChartGetDataURL).toHaveBeenCalledWith({ type: 'png' }); + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ animation: false }), + ); + expect(dispose).toHaveBeenCalledTimes(1); + expect(svgChartGetDataURL).not.toHaveBeenCalled(); + + initSpy.mockRestore(); + }); + + it('always exports the full legend and extra height, even when "show more" truncates it on screen', () => { + vi.mocked(mapPxTableToChartDataset).mockReturnValue(overflowingDataset); + vi.mocked(useEChartOption).mockReturnValue({ + divRef: { current: null }, + chartRef: { + current: { + getDataURL: vi.fn(), + getWidth: () => 640, + getHeight: () => 480, + } as never, + }, + }); + + const setOption = vi.fn(); + const initSpy = vi.mocked(echarts.init).mockReturnValue({ + setOption, + getDataURL: vi.fn().mockReturnValue('data:image/png;base64,abc'), + dispose: vi.fn(), + } as never); + + const ref = createRef(); + render( + , + ); + + // On screen, only 2 of the 5 legend items are shown until "Show more" is clicked + expect(getLastChartOption().legend).toEqual({ + height: 48, + data: overflowingDataset.series.slice(0, 2).map((series) => series.name), + }); + + ref.current?.getPngDataURL(); + + // Exported legend always contains every series, with no truncated `data` list + expect(setOption).toHaveBeenCalledWith( + expect.objectContaining({ legend: { height: 120 } }), + ); + // Chart height grows to fit the full legend instead of the collapsed on-screen height + expect(initSpy).toHaveBeenCalledWith( + expect.any(HTMLDivElement), + null, + expect.objectContaining({ height: 480 + (120 - 48) }), + ); + + initSpy.mockRestore(); + }); + + it('returns undefined from getSvgDataURL when the chart instance is not ready', () => { + const ref = createRef(); + + render(); + + expect(ref.current?.getSvgDataURL()).toBeUndefined(); + }); + + it('returns undefined from getPngDataURL when the chart instance is not ready', () => { + const ref = createRef(); + + render(); + + expect(ref.current?.getPngDataURL()).toBeUndefined(); + }); }); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.module.scss b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.module.scss new file mode 100644 index 000000000..ca8c73e2f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.module.scss @@ -0,0 +1,9 @@ +.storyWrapper { + max-width: 960px; + margin: 0 auto; +} + +.legendOverflowComparison { + display: grid; + gap: 32px; +} diff --git a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.tsx b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.tsx index 2d197f7c4..5fd752113 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.stories.tsx @@ -1,14 +1,55 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import LineChart from './LineChart'; +import { + LineChart, + type LineChartLegendOverflowMode, + type LineChartLegendPaginationOrientation, +} from './LineChart'; import { setPxTableData } from '../../Table/Utils/cubeHelper'; import type { PxTable } from '../../../shared-types/pxTable'; import type { DataCell, PxData } from '../../../shared-types/pxTableData'; import type { Variable } from '../../../shared-types/variable'; import { VartypeEnum } from '../../../shared-types/vartypeEnum'; +import styles from './LineChart.stories.module.scss'; type Story = StoryObj; +const legendOverflowModes: LineChartLegendOverflowMode[] = [ + 'pagination', + 'showMore', +]; + +const legendOverflowModeLabels: Record = { + pagination: 'Pagination', + showMore: 'Show more', +}; + +const legendPaginationOrientations: LineChartLegendPaginationOrientation[] = [ + 'horizontal', + 'vertical', +]; + +const legendOverflowComparisonCases: Array<{ + readonly title: string; + readonly legendOverflowMode: LineChartLegendOverflowMode; + readonly legendPaginationOrientation?: LineChartLegendPaginationOrientation; +}> = [ + { + title: 'Horizontal pagination', + legendOverflowMode: 'pagination', + legendPaginationOrientation: 'horizontal', + }, + { + title: 'Vertical pagination', + legendOverflowMode: 'pagination', + legendPaginationOrientation: 'vertical', + }, + { + title: legendOverflowModeLabels.showMore, + legendOverflowMode: 'showMore', + }, +]; + function createVariable( id: string, type: VartypeEnum, @@ -174,12 +215,38 @@ const sparseDataPxTable = createLineChartPxTable( }, ); +const legendOverflowPxTable = createLineChartPxTable( + ['2019', '2020', '2021', '2022', '2023', '2024'], + Array.from({ length: 18 }, (_value, index) => ({ + code: `S${index + 1}`, + label: `Detailed population segment ${index + 1}`, + })), + (_year, seriesCode, yearIndex) => { + const seriesNumber = Number(seriesCode.replace('S', '')); + return 100 + yearIndex * 4 + seriesNumber * 12; + }, +); + const meta: Meta = { component: LineChart, title: 'Components/Chart/LineChart', + argTypes: { + pxtable: { control: false }, + legendOverflowMode: { + control: 'radio', + options: legendOverflowModes, + }, + legendPaginationOrientation: { + control: 'radio', + options: legendPaginationOrientations, + }, + visibleLegendItemCount: { + control: { type: 'number', min: 1, max: 18, step: 1 }, + }, + }, decorators: [ (StoryComponent) => ( -
+
), @@ -226,3 +293,28 @@ export const SparseData: Story = { colors: ['#24a148', '#8a3ffc'], }, }; + +export const LegendOverflowPlayground: Story = { + args: { + pxtable: legendOverflowPxTable, + legendOverflowMode: 'pagination', + visibleLegendItemCount: 8, + }, +}; + +export const LegendOverflowComparison: Story = { + args: { + pxtable: legendOverflowPxTable, + visibleLegendItemCount: 8, + }, + render: (args) => ( +
+ {legendOverflowComparisonCases.map((comparisonCase) => ( +
+

{comparisonCase.title}

+ +
+ ))} +
+ ), +}; diff --git a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx index cd3b59b93..df4d44964 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx @@ -1,5 +1,13 @@ -import { useMemo } from 'react'; -import type * as echarts from 'echarts'; +import { + type Ref, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; +import cl from 'clsx'; +import * as echarts from 'echarts'; import { buildDatasetOption, @@ -10,12 +18,29 @@ import { useEChartOption } from '../Utils/useEChartOption'; import type { PxTable } from '../../../shared-types/pxTable'; import { mapPxTableToChartDataset } from '../Utils/chartDataMapper'; import { getChartColorsFromCssVariables } from '../Utils/chartHelper'; +import styles from './LineChart.module.scss'; interface LineChartProps { readonly pxtable: PxTable; readonly colors?: string[]; + readonly legendOverflowMode?: LineChartLegendOverflowMode; + readonly legendPaginationOrientation?: LineChartLegendPaginationOrientation; + readonly visibleLegendItemCount?: number; + readonly ref?: Ref; +} + +export type LineChartLegendOverflowMode = 'pagination' | 'showMore'; +export type LineChartLegendPaginationOrientation = 'horizontal' | 'vertical'; + +export interface LineChartHandle { + getPngDataURL: () => string | undefined; + getSvgDataURL: () => string | undefined; } +const DEFAULT_VISIBLE_LEGEND_ITEM_COUNT = 8; +const LEGEND_ITEM_HEIGHT = 24; +const MIN_LEGEND_HEIGHT = 40; + type TooltipParam = { axisValueLabel?: string; seriesIndex: number; @@ -41,8 +66,157 @@ function getTooltipSymbolSvg(symbol: string, color: string): string { } } -export function LineChart({ pxtable, colors }: LineChartProps) { +function getNormalizedVisibleLegendItemCount( + visibleLegendItemCount?: number, +): number { + return Math.max( + 1, + Math.floor(visibleLegendItemCount ?? DEFAULT_VISIBLE_LEGEND_ITEM_COUNT), + ); +} + +function getLegendItemNames( + dataset: ReturnType, +): string[] { + return dataset.series.map((series) => series.name); +} + +function getLegendData( + legendItemNames: string[], + legendOverflowMode: LineChartLegendOverflowMode | undefined, + visibleLegendItemCount: number, + showAllLegendItems: boolean, +): string[] | undefined { + if (!legendOverflowMode || legendItemNames.length <= visibleLegendItemCount) { + return undefined; + } + + if (legendOverflowMode === 'pagination') { + return legendItemNames; + } + + if (legendOverflowMode === 'showMore' && !showAllLegendItems) { + return legendItemNames.slice(0, visibleLegendItemCount); + } + + return legendItemNames; +} + +function getVisibleLegendItemCount( + legendItemCount: number, + legendOverflowMode: LineChartLegendOverflowMode | undefined, + visibleLegendItemCount: number, + showAllLegendItems: boolean, +): number { + if (!legendOverflowMode || legendItemCount <= visibleLegendItemCount) { + return legendItemCount; + } + + if (legendOverflowMode === 'showMore' && showAllLegendItems) { + return legendItemCount; + } + + return visibleLegendItemCount; +} + +type LineChartLegendOption = { + readonly height: number; + readonly data?: string[]; + readonly type?: 'scroll'; + readonly orient?: 'horizontal' | 'vertical'; + readonly pageButtonPosition?: 'start' | 'end'; + readonly pageFormatter?: string; +}; + +function getLegendHeight(visibleLegendItems: number): number { + return Math.max(MIN_LEGEND_HEIGHT, visibleLegendItems * LEGEND_ITEM_HEIGHT); +} + +function getLegendOption( + legendItemNames: string[], + legendOverflowMode: LineChartLegendOverflowMode | undefined, + legendPaginationOrientation: LineChartLegendPaginationOrientation | undefined, + visibleLegendItemCount: number, + visibleLegendItems: number, + showAllLegendItems: boolean, +): LineChartLegendOption { + const legendData = getLegendData( + legendItemNames, + legendOverflowMode, + visibleLegendItemCount, + showAllLegendItems, + ); + const baseLegend = { + height: getLegendHeight(visibleLegendItems), + }; + + if (legendOverflowMode === 'pagination' && legendData) { + return { + ...baseLegend, + type: 'scroll', + orient: legendPaginationOrientation ?? 'vertical', + data: legendData, + pageButtonPosition: 'end', + pageFormatter: '{current} / {total}', + }; + } + + if (legendData) { + return { + ...baseLegend, + data: legendData, + }; + } + + return baseLegend; +} + +export function LineChart({ + pxtable, + colors, + legendOverflowMode, + legendPaginationOrientation, + visibleLegendItemCount, + ref, +}: LineChartProps) { const dataset = useMemo(() => mapPxTableToChartDataset(pxtable), [pxtable]); + const normalizedVisibleLegendItemCount = getNormalizedVisibleLegendItemCount( + visibleLegendItemCount, + ); + const [showAllLegendItems, setShowAllLegendItems] = useState(false); + + useEffect(() => { + setShowAllLegendItems(false); + }, [dataset, legendOverflowMode, normalizedVisibleLegendItemCount]); + + const legendItemNames = useMemo(() => getLegendItemNames(dataset), [dataset]); + const hasOverflowingLegend = + legendItemNames.length > normalizedVisibleLegendItemCount; + const visibleLegendItems = getVisibleLegendItemCount( + legendItemNames.length, + legendOverflowMode, + normalizedVisibleLegendItemCount, + showAllLegendItems, + ); + const legendOptionsMemoized = useMemo( + () => + getLegendOption( + legendItemNames, + legendOverflowMode, + legendPaginationOrientation, + normalizedVisibleLegendItemCount, + visibleLegendItems, + showAllLegendItems, + ), + [ + legendItemNames, + legendOverflowMode, + legendPaginationOrientation, + normalizedVisibleLegendItemCount, + showAllLegendItems, + visibleLegendItems, + ], + ); const resolvedColors = useMemo(() => { return colors && colors.length > 0 @@ -53,15 +227,19 @@ export function LineChart({ pxtable, colors }: LineChartProps) { const option = useMemo( () => ({ ...buildDatasetOption(dataset), - grid: { top: 0, bottom: 200, left: '0', right: '0', containLabel: false }, + grid: { + top: 0, + bottom: 200, + left: '0', + right: '0', + containLabel: false, + }, xAxis: { type: 'category' as const, axisLabel: { rotate: 45 } }, yAxis: { name: dataset.unit, min: (value) => value.min, }, - legend: { - height: 40 * dataset.series.length, // increase legend height based on number of series to prevent overlap with x-axis labels - }, + legend: legendOptionsMemoized, series: buildSeriesOption(dataset, 'line', resolvedColors), tooltip: { trigger: 'axis', @@ -93,16 +271,99 @@ export function LineChart({ pxtable, colors }: LineChartProps) { }, }, }), - [dataset, resolvedColors], + [dataset, legendOptionsMemoized, resolvedColors], ); - const { divRef } = useEChartOption(option); - const height = 600 + dataset.series.length * 10; // increase chart height based on number of series to prevent legend overlap + const { divRef, chartRef } = useEChartOption(option); + const hasExpandedLegend = + visibleLegendItems > DEFAULT_VISIBLE_LEGEND_ITEM_COUNT; + + // Exports must show every legend item, ignoring any "show more"/pagination truncation used on screen + const getExportOption = useCallback((): { + option: echarts.EChartsOption; + extraLegendHeight: number; + } => { + const fullLegendOption = getLegendOption( + legendItemNames, + undefined, + legendPaginationOrientation, + legendItemNames.length, + legendItemNames.length, + true, + ); + + return { + option: { ...option, legend: fullLegendOption, animation: false }, + extraLegendHeight: Math.max( + 0, + getLegendHeight(legendItemNames.length) - + getLegendHeight(visibleLegendItems), + ), + }; + }, [ + legendItemNames, + legendPaginationOrientation, + option, + visibleLegendItems, + ]); + + const renderExportDataURL = useCallback( + (renderer: 'canvas' | 'svg', type: 'png' | 'svg') => { + const liveChart = chartRef.current; + if (!liveChart) { + return undefined; + } + + const { option: exportOption, extraLegendHeight } = getExportOption(); + const exportChart = echarts.init(document.createElement('div'), null, { + renderer, + width: liveChart.getWidth(), + height: liveChart.getHeight() + extraLegendHeight, + }); + // Disable animation so lines are fully drawn before the dataURL is captured + exportChart.setOption(exportOption); + const dataUrl = exportChart.getDataURL({ type }); + exportChart.dispose(); + + return dataUrl; + }, + [chartRef, getExportOption], + ); + + useImperativeHandle( + ref, + () => ({ + getPngDataURL: () => renderExportDataURL('canvas', 'png'), + getSvgDataURL: () => renderExportDataURL('svg', 'svg'), + }), + [renderExportDataURL], + ); + + const controls = (() => { + if (legendOverflowMode === 'showMore' && hasOverflowingLegend) { + return ( + + ); + } + + return null; + })(); return ( -
-
+
+
+ {controls}
); } -export default LineChart; diff --git a/packages/pxweb2/public/config/config.js b/packages/pxweb2/public/config/config.js index 87ec89910..26a9c5b36 100644 --- a/packages/pxweb2/public/config/config.js +++ b/packages/pxweb2/public/config/config.js @@ -38,4 +38,7 @@ globalThis.PxWeb2Config = { sv: '', // Set to your Swedish homepage URL en: '', // Set to your English homepage URL }, + features: { + chartEnabled: true, + }, }; diff --git a/packages/pxweb2/public/locales/ar/translation.json b/packages/pxweb2/public/locales/ar/translation.json index fbc3a3c61..797feb0e3 100644 --- a/packages/pxweb2/public/locales/ar/translation.json +++ b/packages/pxweb2/public/locales/ar/translation.json @@ -258,7 +258,8 @@ }, "imagefile": { "title": "حفظ كرسم بياني", - "png": "الرسم البياني (بابوا نيو غينيا)" + "png": "الرسم البياني (بابوا نيو غينيا)", + "svg": "الرسم البياني (svg)" }, "link": { "title": "حفظ كرابط", diff --git a/packages/pxweb2/public/locales/en/translation.json b/packages/pxweb2/public/locales/en/translation.json index f0c3358de..37eaa1d3e 100644 --- a/packages/pxweb2/public/locales/en/translation.json +++ b/packages/pxweb2/public/locales/en/translation.json @@ -256,7 +256,8 @@ }, "imagefile": { "title": "Save as graph", - "png": "Chart (png)" + "png": "Chart (png)", + "svg": "Chart (svg)" }, "link": { "title": "Save as link", diff --git a/packages/pxweb2/public/locales/no/translation.json b/packages/pxweb2/public/locales/no/translation.json index 06a1a8336..1b29ff5d2 100644 --- a/packages/pxweb2/public/locales/no/translation.json +++ b/packages/pxweb2/public/locales/no/translation.json @@ -256,7 +256,8 @@ }, "imagefile": { "title": "Lagre som graf", - "png": "Diagram (png)" + "png": "Diagram (png)", + "svg": "Diagram (svg)" }, "link": { "title": "Lagre som lenke", diff --git a/packages/pxweb2/public/locales/sv/translation.json b/packages/pxweb2/public/locales/sv/translation.json index 58c4b3dd6..8c209846c 100644 --- a/packages/pxweb2/public/locales/sv/translation.json +++ b/packages/pxweb2/public/locales/sv/translation.json @@ -256,7 +256,8 @@ }, "imagefile": { "title": "Spara som diagram", - "png": "Diagram (png)" + "png": "Diagram (png)", + "svg": "Diagram (svg)" }, "link": { "title": "Spara som länk", diff --git a/packages/pxweb2/src/@types/resources.d.ts b/packages/pxweb2/src/@types/resources.d.ts index eff1b072f..6e330d4e1 100644 --- a/packages/pxweb2/src/@types/resources.d.ts +++ b/packages/pxweb2/src/@types/resources.d.ts @@ -252,6 +252,7 @@ export default interface Resources { }; imagefile: { png: 'Chart (png)'; + svg: 'Chart (svg)'; title: 'Save as graph'; }; link: { diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.spec.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.spec.tsx index 3f74de4ce..cf85b4b01 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.spec.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.spec.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, @@ -122,6 +122,24 @@ vi.mock('../../../util/export/exportUtil', () => ({ createNewSavedQuery: vi.fn(() => Promise.resolve('query123')), createSavedQueryURL: (id: string) => `https://example.com/query/${id}`, exportToFile: vi.fn(), + getTimestamp: () => '20260101-000000', +})); + +const mockSearchParams = { current: new URLSearchParams() }; +vi.mock('react-router', () => ({ + useSearchParams: () => [mockSearchParams.current], +})); + +const mockChartRef = { + current: { + current: null as { + getPngDataURL: () => string | undefined; + getSvgDataURL: () => string | undefined; + } | null, + }, +}; +vi.mock('../../../context/useChartRef', () => ({ + default: () => ({ chartRef: mockChartRef.current }), })); // Mock CodeSnippet component used in DrawerSave @@ -138,6 +156,11 @@ vi.mock('@pxweb2/pxweb2-ui', async (importOriginal) => { }); describe('DrawerSave', () => { + beforeEach(() => { + mockSearchParams.current = new URLSearchParams(); + mockChartRef.current = { current: null }; + }); + it('renders without crashing', () => { render(); @@ -299,4 +322,69 @@ describe('DrawerSave', () => { ); }); }); + + describe('Save chart as image', () => { + it('does not render chart image options in table view', () => { + mockSearchParams.current = new URLSearchParams(); + + render(); + + expect( + screen.queryByRole('button', { + name: 'presentation_page.side_menu.save.imagefile.svg', + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { + name: 'presentation_page.side_menu.save.imagefile.png', + }), + ).not.toBeInTheDocument(); + }); + + it('renders and downloads chart images in chart view', async () => { + mockSearchParams.current = new URLSearchParams('view=linechart'); + const getPngDataURL = vi + .fn() + .mockReturnValue('data:image/png;base64,abc'); + const getSvgDataURL = vi + .fn() + .mockReturnValue('data:image/svg+xml;base64,abc'); + mockChartRef.current = { current: { getPngDataURL, getSvgDataURL } }; + + const blob = new Blob(['chart'], { type: 'image/png' }); + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.resolve({ blob: () => Promise.resolve(blob) })), + ); + const createObjectURL = vi.fn(() => 'blob:mock-url'); + const revokeObjectURL = vi.fn(); + vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL }); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}); + + render(); + + const svgBtn = screen.getByRole('button', { + name: 'presentation_page.side_menu.save.imagefile.svg', + }); + fireEvent.click(svgBtn); + + await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1)); + expect(getSvgDataURL).toHaveBeenCalledTimes(1); + expect(createObjectURL).toHaveBeenCalledWith(blob); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + + const pngBtn = screen.getByRole('button', { + name: 'presentation_page.side_menu.save.imagefile.png', + }); + fireEvent.click(pngBtn); + + await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(2)); + expect(getPngDataURL).toHaveBeenCalledTimes(1); + + clickSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + }); }); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.tsx index 91c88c26c..8d668e258 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerSave.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { useEffect, useState, useRef } from 'react'; +import { useSearchParams } from 'react-router'; import classes from './DrawerSave.module.scss'; import { @@ -22,12 +23,16 @@ import { } from '@pxweb2/pxweb2-api-client'; import useVariables from '../../../context/useVariables'; import useTableData from '../../../context/useTableData'; +import useChartRef from '../../../context/useChartRef'; import { problemMessage } from '../../../util/problemMessage'; +import { getConfig } from '../../../util/config/getConfig'; +import { getViewMode } from '../../../pages/TableViewer/Utils/tableViewerHelper'; import { applyTimeFilter, createNewSavedQuery, createSavedQueryURL, exportToFile, + getTimestamp, TimeFilter, } from '../../../util/export/exportUtil'; import { ApiQuery } from '../../ApiQuery/ApiQuery'; @@ -179,9 +184,17 @@ export function DrawerSave({ tableId }: DrawerSaveProps) { const variables = useVariables(); const heading = useTableData().data?.heading; const stub = useTableData().data?.stub; + const { chartRef } = useChartRef(); + const [searchParams] = useSearchParams(); + const chartEnabled = getConfig().features?.chartEnabled === true; + const isChartView = + chartEnabled && getViewMode(searchParams, chartEnabled) === 'linechart'; const [loadingFormat, setLoadingFormat] = useState( null, ); + const [savingChartFormat, setSavingChartFormat] = useState< + 'png' | 'svg' | null + >(null); const [errorMsg, setErrorMsg] = useState(''); const [saveQueryUrl, setsaveQueryUrl] = useState(''); @@ -337,6 +350,39 @@ export function DrawerSave({ tableId }: DrawerSaveProps) { }); } + async function saveChartAsImage( + format: 'png' | 'svg', + dataUrl: string | undefined, + ): Promise { + if (!dataUrl) { + setErrorMsg(`Could not export chart as ${format.toUpperCase()}.`); + return; + } + + setSavingChartFormat(format); + + try { + const blob = await fetch(dataUrl).then((response) => response.blob()); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = `${tableId}_${getTimestamp()}.${format}`; + link.click(); + URL.revokeObjectURL(link.href); + } catch { + setErrorMsg(`Could not export chart as ${format.toUpperCase()}.`); + } finally { + setSavingChartFormat(null); + } + } + + function saveChartAsSvg(): Promise { + return saveChartAsImage('svg', chartRef.current?.getSvgDataURL()); + } + + function saveChartAsPng(): Promise { + return saveChartAsImage('png', chartRef.current?.getPngDataURL()); + } + /** * Creates a saved query with the current variable selections and time filter. * If a time filter is provided, it modifies the selection for the time variable accordingly. @@ -456,6 +502,30 @@ export function DrawerSave({ tableId }: DrawerSaveProps) { className={classes.saveAsActionList} aria-labelledby="drawer-save-to-file" > + {isChartView && ( + <> +
  • + +
  • +
  • + +
  • + + )} {fileFormats.map((format) => (
  • - +
  • @@ -361,7 +363,7 @@ export function Presentation({ [classes.fadeChart]: isFadingTable, })} > - +
    diff --git a/packages/pxweb2/src/app/context/ChartRefProvider.tsx b/packages/pxweb2/src/app/context/ChartRefProvider.tsx new file mode 100644 index 000000000..cf0a71106 --- /dev/null +++ b/packages/pxweb2/src/app/context/ChartRefProvider.tsx @@ -0,0 +1,29 @@ +import React, { createContext, ReactNode, useMemo, useRef } from 'react'; + +import type { LineChartHandle } from '@pxweb2/pxweb2-ui'; + +export interface ChartRefContextType { + chartRef: React.RefObject; +} + +interface ChartRefProviderProps { + children: ReactNode; +} + +const ChartRefContext = createContext( + undefined, +); + +// Shares one chart instance ref between Presentation (renders the chart) and DrawerSave (exports it), which are siblings. +const ChartRefProvider: React.FC = ({ children }) => { + const chartRef = useRef(null); + const value = useMemo(() => ({ chartRef }), [chartRef]); + + return ( + + {children} + + ); +}; + +export { ChartRefProvider, ChartRefContext }; diff --git a/packages/pxweb2/src/app/context/useChartRef.ts b/packages/pxweb2/src/app/context/useChartRef.ts new file mode 100644 index 000000000..b316ef1d4 --- /dev/null +++ b/packages/pxweb2/src/app/context/useChartRef.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { ChartRefContext, ChartRefContextType } from './ChartRefProvider'; + +const useChartRef = (): ChartRefContextType => { + const context = useContext(ChartRefContext); + if (!context) { + throw new Error('useChartRef must be used within a ChartRefProvider'); + } + return context; +}; + +export default useChartRef; diff --git a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx index e57b902dc..3bd898f33 100644 --- a/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx +++ b/packages/pxweb2/src/app/pages/TableViewer/TableViewer.tsx @@ -16,6 +16,7 @@ import useApp from '../../context/useApp'; import { AccessibilityProvider } from '../../context/AccessibilityProvider'; import { VariablesProvider } from '../../context/VariablesProvider'; import { TableDataProvider } from '../../context/TableDataProvider'; +import { ChartRefProvider } from '../../context/ChartRefProvider'; import WipStatusMessage from '../../components/Banners/WipStatusMessage'; export function TableViewer() { @@ -288,7 +289,9 @@ function Render() { - + + + diff --git a/packages/pxweb2/src/app/util/testing-utils.tsx b/packages/pxweb2/src/app/util/testing-utils.tsx index 58c551274..1342b471a 100644 --- a/packages/pxweb2/src/app/util/testing-utils.tsx +++ b/packages/pxweb2/src/app/util/testing-utils.tsx @@ -4,12 +4,15 @@ import { render } from '@testing-library/react'; import { VariablesProvider } from '../context/VariablesProvider'; import { TableDataProvider } from '../context/TableDataProvider'; import { AppProvider } from '../context/AppProvider'; +import { ChartRefProvider } from '../context/ChartRefProvider'; const renderWithProviders = (ui: React.ReactNode) => { return render( - {ui} + + {ui} + , );