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..3d5ebd409 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 @@ -34,6 +34,8 @@ vi.mock('../Utils/chartOptionBuilder', async () => { vi.mock('../Utils/chartHelper', () => ({ getChartColorsFromCssVariables: vi.fn(), + getAdaptiveYAxisMin: vi.fn(), + getAdaptiveYAxisMax: vi.fn(), })); const mockDataset: EChartsDataset = { @@ -49,6 +51,10 @@ const mockDataset: EChartsDataset = { ], }; +const mockPxTable = { + stub: [{ label: 'Year' }], +} as PxTable; + function getTooltipFormatter(option: { tooltip?: unknown }) { const tooltip = Array.isArray(option.tooltip) ? option.tooltip[0] @@ -95,9 +101,9 @@ describe('LineChart', () => { it('builds chart option with mapped dataset and provided colors', () => { const colors = ['#111111', '#222222']; - render(); + render(); - expect(mapPxTableToChartDataset).toHaveBeenCalledWith({}); + expect(mapPxTableToChartDataset).toHaveBeenCalledWith(mockPxTable); expect(buildDatasetOption).toHaveBeenCalledWith(mockDataset); expect(buildSeriesOption).toHaveBeenCalledWith(mockDataset, 'line', colors); expect(getChartColorsFromCssVariables).not.toHaveBeenCalled(); @@ -123,7 +129,7 @@ describe('LineChart', () => { const fallbackColors = ['#abcdef', '#fedcba']; vi.mocked(getChartColorsFromCssVariables).mockReturnValue(fallbackColors); - render(); + render(); expect(getChartColorsFromCssVariables).toHaveBeenCalledTimes(1); expect(buildSeriesOption).toHaveBeenCalledWith( @@ -137,7 +143,7 @@ describe('LineChart', () => { const fallbackColors = ['#121212', '#343434']; vi.mocked(getChartColorsFromCssVariables).mockReturnValue(fallbackColors); - render(); + render(); expect(getChartColorsFromCssVariables).toHaveBeenCalledTimes(1); expect(buildSeriesOption).toHaveBeenCalledWith( @@ -148,7 +154,7 @@ describe('LineChart', () => { }); it('renders chart container with height based on number of series', () => { - const { container } = render(); + const { container } = render(); const chartDiv = Array.from(container.querySelectorAll('div')).find( (element) => element.style.height, @@ -159,7 +165,7 @@ describe('LineChart', () => { }); it('returns empty tooltip text for empty params', () => { - render(); + render(); const option = vi.mocked(useEChartOption).mock.calls[0][0]; const formatter = getTooltipFormatter(option); @@ -169,7 +175,7 @@ describe('LineChart', () => { }); it('formats tooltip rows with symbol svg, labels, values and fallback color', () => { - render(); + render(); const option = vi.mocked(useEChartOption).mock.calls[0][0]; const formatter = getTooltipFormatter(option); 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 123d414d4..d2655604d 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx +++ b/packages/pxweb2-ui/src/lib/components/Chart/LineChart/LineChart.tsx @@ -9,7 +9,11 @@ import { import { useEChartOption } from '../Utils/useEChartOption'; import type { PxTable } from '../../../shared-types/pxTable'; import { mapPxTableToChartDataset } from '../Utils/chartDataMapper'; -import { getChartColorsFromCssVariables } from '../Utils/chartHelper'; +import { + getAdaptiveYAxisMax, + getAdaptiveYAxisMin, + getChartColorsFromCssVariables, +} from '../Utils/chartHelper'; interface LineChartProps { readonly pxtable: PxTable; @@ -44,6 +48,10 @@ function getTooltipSymbolSvg(symbol: string, color: string): string { export function LineChart({ pxtable, colors }: LineChartProps) { const dataset = useMemo(() => mapPxTableToChartDataset(pxtable), [pxtable]); + const xAxisName = useMemo(() => { + return pxtable.stub.map((variable) => variable.label).join(' / '); + }, [pxtable]); + const resolvedColors = useMemo(() => { return colors && colors.length > 0 ? colors @@ -54,10 +62,18 @@ export function LineChart({ pxtable, colors }: LineChartProps) { () => ({ ...buildDatasetOption(dataset), grid: { top: 0, bottom: 200, left: '0', right: '0', containLabel: false }, - xAxis: { type: 'category' as const, axisLabel: { rotate: 45 } }, + xAxis: { + type: 'category' as const, + name: xAxisName, + nameLocation: 'end', + nameGap: 70, + axisLabel: { rotate: 45 }, + }, yAxis: { name: dataset.unit, - min: (value) => value.min, + scale: true, + min: getAdaptiveYAxisMin, + max: getAdaptiveYAxisMax, }, legend: { height: 40 * dataset.series.length, // increase legend height based on number of series to prevent overlap with x-axis labels @@ -94,7 +110,7 @@ export function LineChart({ pxtable, colors }: LineChartProps) { }, }, }), - [dataset, resolvedColors], + [dataset, resolvedColors, xAxisName], ); const { divRef } = useEChartOption(option); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.spec.ts b/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.spec.ts index 8ff8b61d2..f9edf2c25 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.spec.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.spec.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getChartColorsFromCssVariables } from './chartHelper'; +import { + getAdaptiveYAxisMax, + getAdaptiveYAxisMin, + getChartColorsFromCssVariables, +} from './chartHelper'; function mockStyles(values: Record): CSSStyleDeclaration { return { @@ -75,3 +79,31 @@ describe('getChartColorsFromCssVariables', () => { expect(getChartColorsFromCssVariables()).toBeUndefined(); }); }); + +describe('getAdaptiveYAxisMin', () => { + it('clamps to zero when the source range is non-negative', () => { + expect(getAdaptiveYAxisMin({ min: 0, max: 1 })).toBe(0); + }); + + it('rounds down to a clean snap value for positive ranges', () => { + expect(getAdaptiveYAxisMin({ min: 100, max: 200 })).toBe(50); + }); + + it('keeps negative ranges negative and rounds down', () => { + expect(getAdaptiveYAxisMin({ min: -120, max: 80 })).toBe(-200); + }); +}); + +describe('getAdaptiveYAxisMax', () => { + it('rounds up to a clean snap value for positive ranges', () => { + expect(getAdaptiveYAxisMax({ min: 100, max: 200 })).toBe(250); + }); + + it('adds headroom and rounds up for small decimal ranges', () => { + expect(getAdaptiveYAxisMax({ min: 0, max: 1 })).toBe(1.5); + }); + + it('works when min and max are equal', () => { + expect(getAdaptiveYAxisMax({ min: 5, max: 5 })).toBe(5.5); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.ts b/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.ts index a78da3e8f..1d5addebc 100644 --- a/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.ts +++ b/packages/pxweb2-ui/src/lib/components/Chart/Utils/chartHelper.ts @@ -48,3 +48,69 @@ export function getChartColorsFromCssVariables(): string[] | undefined { return undefined; } + +function getNiceNumber(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 1; + } + + const exponent = Math.floor(Math.log10(value)); + const base = 10 ** exponent; + const fraction = value / base; + + if (fraction <= 1) { + return 1 * base; + } + if (fraction <= 2) { + return 2 * base; + } + if (fraction <= 5) { + return 5 * base; + } + return 10 * base; +} + +function getAdaptiveSnapUnit(min: number, max: number): number { + const span = Math.max(max - min, 1); + + // No fixed tick count: just derive a clean rounding grain from data span. + // For spans around 1.3M this typically becomes 500k. + return getNiceNumber(span / 3); +} + +export function getAdaptiveYAxisMin(value: { + min: number; + max: number; +}): number { + const min = Number(value.min); + const max = Number(value.max); + + const span = Math.max(max - min, 1); + const pad = span * 0.03; + const snap = getAdaptiveSnapUnit(min, max); + + const paddedMin = min - pad; + const roundedMin = Math.floor(paddedMin / snap) * snap; + + // Keep non-negative axes non-negative. + if (min >= 0) { + return Math.max(0, roundedMin); + } + + return roundedMin; +} + +export function getAdaptiveYAxisMax(value: { + min: number; + max: number; +}): number { + const min = Number(value.min); + const max = Number(value.max); + + const span = Math.max(max - min, 1); + const pad = span * 0.03; + const snap = getAdaptiveSnapUnit(min, max); + + const paddedMax = max + pad; + return Math.ceil(paddedMax / snap) * snap; +} 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, + }, };