diff --git a/.github/workflows/macro-and-script-tests.yml b/.github/workflows/macro-and-script-tests.yml index 668862b922..271bc0d371 100644 --- a/.github/workflows/macro-and-script-tests.yml +++ b/.github/workflows/macro-and-script-tests.yml @@ -21,4 +21,4 @@ jobs: - name: Clear jest cache run: yarn test:clear-cache - name: Run macro and script tests - run: yarn test + run: yarn test | grep -v "Google analytics not connected" diff --git a/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_0_desktop.png b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_0_desktop.png new file mode 100644 index 0000000000..8e1536ab9e --- /dev/null +++ b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_0_desktop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a4f4433caef423624bd02f5f13ddd07a63083060f54e6ce70599acc6742d7864 +size 100800 diff --git a/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_1_tablet.png b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_1_tablet.png new file mode 100644 index 0000000000..3a49076084 --- /dev/null +++ b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_1_tablet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43d3a0ce1b3e9bc41ab965c3010cc48550dccff62d578a042a11111c7a8ab9b9 +size 77423 diff --git a/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_2_mobile.png b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_2_mobile.png new file mode 100644 index 0000000000..bfe85f82d0 --- /dev/null +++ b/backstop_data/bitmaps_reference/ds-vr-test__components_chart_example-line-chart_0_document_2_mobile.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:666efca4087b5bae4d2ac2cf6dc1646d954fd84b3036454c43388b5828a861ee +size 63276 diff --git a/package.json b/package.json index b35642f5af..d6233624e1 100644 --- a/package.json +++ b/package.json @@ -135,5 +135,6 @@ }, "dependencies": { "highcharts": "^12.1.2" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/components/chart/_chart.scss b/src/components/chart/_chart.scss index 0012c74876..2e2449e4b5 100644 --- a/src/components/chart/_chart.scss +++ b/src/components/chart/_chart.scss @@ -4,4 +4,18 @@ @extend .ons-u-pt-l; @extend .ons-u-fs-r--b; } + &__title { + font-size: 20px; + line-height: 26px; + } + + &__subtitle { + font-size: 16px; + line-height: 20.8px; + } + + &__caption { + font-size: 16px; + line-height: 19.2px; + } } diff --git a/src/components/chart/_macro-options.md b/src/components/chart/_macro-options.md index 7d4b2b9777..e2afda7c4f 100644 --- a/src/components/chart/_macro-options.md +++ b/src/components/chart/_macro-options.md @@ -1,14 +1,15 @@ -| Name | Type | Required | Description | -| ----------- | ------ | -------- | ------------------------------------------------------------------------------------------ | -| chartType | string | true | The type of chart to render (e.g., 'line', 'bar', etc.). | -| theme | string | true | The theme to apply to the chart. Either `primary` or `alternate`. | -| title | string | true | The main title of the chart. | -| subtitle | string | true | A subtitle that appears under the main title. | -| uuid | string | true | A unique identifier for the chart instance. | -| caption | string | false | A caption providing additional context for the chart. | -| description | string | false | A textual description of the chart for screen readers. | -| download | object | false | Object for (download)[#download] options. | -| config | object | true | The full [configuration](#config) object passed to Highcharts, defining axes, series, etc. | +| Name | Type | Required | Description | +| ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------- | +| chartType | string | true | The type of chart to render (e.g., 'line', 'bar', etc.). | +| theme | string | true | The theme to apply to the chart. Either `primary` or `alternate`. | +| title | string | true | The main title of the chart. | +| subtitle | string | true | A subtitle that appears under the main title. | +| uuid | string | true | A unique identifier for the chart instance. | +| caption | string | false | A caption providing additional context for the chart. | +| description | string | false | A textual description of the chart for screen readers. | +| download | object | false | Object for (download)[#download] options. | +| useStackedLayout | boolean | false | Determines whether the chart should use a stacked layout. It is useful only for bar charts. | +| config | object | true | The full [configuration](#config) object passed to Highcharts, defining axes, series, etc. | ### Download @@ -87,9 +88,7 @@ | name | string | true | The name of the series. | | data | array | true | The data values for the series. Each value corresponds to a category on the x-axis. | | dataLabels | object | false | Configuration options for displaying labels on the data points. | -| marker | object | false | Configuration options for the series (markers)[#markers]. | | tooltip | object | false | Customization options for the (tooltip)[#tooltip] displayed when hovering over data points. | -| animation | object | false | Defines (animation)[#animation] options for rendering the series. Defaults to false. | ### DataLabel @@ -97,21 +96,8 @@ | ------- | ------- | -------- | -------------------------------------------------------- | | enabled | boolean | false | Whether the DataLabel is displayed. Defaults to `false`. | -### Marker - -| Name | Type | Required | Description | -| ------- | ------- | -------- | ----------------------------------------------------------------- | -| enabled | boolean | false | Whether markers are displayed for the series. Defaults to `true`. | -| symbol | string | false | The shape of the marker (`circle`, `square`, `diamond`, etc.). | - ### Tooltip -| Name | Type | Required | Description | -| ----------- | ------ | -------- | ------------------------------------------------------------ | -| valueSuffix | string | false | A string to append to each tooltip value (e.g., '°C', 'kg'). | - -#### Animation - -| Name | Type | Required | Description | -| -------- | ------ | -------- | ------------------------------------------------------------- | -| duration | number | false | Duration of the animation in milliseconds. Default is `1000`. | +| Name | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------------------- | +| valueSuffix | string | false | A string to append to each tooltip value. | diff --git a/src/components/chart/_macro.njk b/src/components/chart/_macro.njk index 5cdd6cbd8a..1d120689f4 100644 --- a/src/components/chart/_macro.njk +++ b/src/components/chart/_macro.njk @@ -7,14 +7,18 @@ data-highcharts-theme="{{ params.theme }}" data-highcharts-title="{{ params.title }}" data-highcharts-uuid="{{ params.uuid }}" + {% if params.useStackedLayout %}data-highcharts-use-stacked-layout{% endif %} + id="{{ params.uuid }}" >
-

{{ params.title }}

-

{{ params.subtitle }}

-
{{ params.description }}
+

{{ params.title }}

+

{{ params.subtitle }}

+ {% if params.description %} +
{{ params.description }}
+ {% endif %}
{% if params.caption %} -
{{ params.caption }}
+
{{ params.caption }}
{% endif %}
@@ -29,8 +33,10 @@ {% endif %} - {# #} + + {% endmacro %} diff --git a/src/components/chart/_macro.spec.js b/src/components/chart/_macro.spec.js new file mode 100644 index 0000000000..f82615f2f5 --- /dev/null +++ b/src/components/chart/_macro.spec.js @@ -0,0 +1,116 @@ +/** @jest-environment jsdom */ + +import * as cheerio from 'cheerio'; + +import axe from '../../tests/helpers/axe'; +import { renderComponent } from '../../tests/helpers/rendering'; +import { EXAMPLE_CHART_REQUIRED_PARAMS, EXAMPLE_CHART_WITH_CONFIG_PARAMS } from './_test-examples'; + +describe('FOR: Macro: Chart', () => { + describe('GIVEN: Params: required', () => { + describe('WHEN: required params are provided', () => { + const $ = cheerio.load(renderComponent('chart', EXAMPLE_CHART_REQUIRED_PARAMS)); + + test('THEN: it passes jest-axe checks', async () => { + const results = await axe($.html()); + expect(results).toHaveNoViolations(); + }); + + test('THEN: it renders the chart container with the correct data attributes', () => { + expect($('[data-highcharts-base-chart]').attr('data-highcharts-type')).toBe('line'); + expect($('[data-highcharts-base-chart]').attr('data-highcharts-theme')).toBe('primary'); + expect($('[data-highcharts-base-chart]').attr('data-highcharts-title')).toBe('Example Line Chart'); + expect($('[data-highcharts-base-chart]').attr('data-highcharts-uuid')).toBe('chart-123'); + }); + + test('THEN: it includes the Highcharts JSON config', () => { + const configScript = $(`script[data-highcharts-config--chart-123]`).html(); + expect(configScript).toContain('"type":"line"'); + expect(configScript).toContain('"text":"X Axis Title"'); + expect(configScript).toContain('"text":"Y Axis Title"'); + }); + + test('THEN: it does NOT render optional fields', () => { + expect($('figcaption').length).toBe(0); + expect($('.ons-chart__download-title').length).toBe(0); + }); + }); + }); + + describe('GIVEN: Params: Config', () => { + describe('WHEN: config params are provided', () => { + const $ = cheerio.load(renderComponent('chart', EXAMPLE_CHART_WITH_CONFIG_PARAMS)); + + test('THEN: it renders the legend when enabled', () => { + const configScript = $(`script[data-highcharts-config--chart-456]`).html(); + expect(configScript).toContain('"enabled":true'); + expect(configScript).toContain('"align":"right"'); + expect(configScript).toContain('"verticalAlign":"top"'); + expect(configScript).toContain('"layout":"vertical"'); + }); + + test('THEN: it includes correct xAxis and yAxis titles', () => { + const configScript = $(`script[data-highcharts-config--chart-456]`).html(); + expect(configScript).toContain('"text":"X Axis Label"'); + expect(configScript).toContain('"text":"Y Axis Label"'); + }); + }); + }); + + describe('GIVEN: Params: Caption', () => { + describe('WHEN: caption is provided', () => { + const $ = cheerio.load( + renderComponent('chart', { + ...EXAMPLE_CHART_REQUIRED_PARAMS, + caption: 'This is an example caption for the chart.', + }), + ); + + test('THEN: it renders the caption when provided', () => { + expect($('figcaption').text()).toBe('This is an example caption for the chart.'); + }); + }); + }); + + describe('GIVEN: Params: Description)', () => { + describe('WHEN: description is provided', () => { + const $ = cheerio.load( + renderComponent('chart', { + ...EXAMPLE_CHART_REQUIRED_PARAMS, + description: 'An accessible description for screen readers.', + }), + ); + + test('THEN: it renders the description for accessibility', () => { + expect($('.ons-u-vh').text()).toBe('An accessible description for screen readers.'); + }); + }); + }); + + describe('GIVEN: Params: Download)', () => { + describe('WHEN: download object are provided', () => { + const $ = cheerio.load( + renderComponent('chart', { + ...EXAMPLE_CHART_REQUIRED_PARAMS, + download: { + title: 'Download Chart Data', + itemsList: [ + { text: 'Download as PNG', url: 'https://example.com/chart.png' }, + { text: 'Download as CSV', url: 'https://example.com/chart.csv' }, + ], + }, + }), + ); + + test('THEN: it renders the download section correctly', () => { + expect($('.ons-chart__download-title').text()).toBe('Download Chart Data'); + + const downloadLinks = $('.ons-chart__download-title').next().find('li a'); + expect(downloadLinks.eq(0).text()).toBe('Download as PNG'); + expect(downloadLinks.eq(0).attr('href')).toBe('https://example.com/chart.png'); + expect(downloadLinks.eq(1).text()).toBe('Download as CSV'); + expect(downloadLinks.eq(1).attr('href')).toBe('https://example.com/chart.csv'); + }); + }); + }); +}); diff --git a/src/components/chart/_test-examples.js b/src/components/chart/_test-examples.js new file mode 100644 index 0000000000..dab3f05689 --- /dev/null +++ b/src/components/chart/_test-examples.js @@ -0,0 +1,34 @@ +export const EXAMPLE_CHART_REQUIRED_PARAMS = { + chartType: 'line', + theme: 'primary', + title: 'Example Line Chart', + subtitle: 'A sample subtitle', + uuid: 'chart-123', + config: { + chart: { type: 'line' }, + xAxis: { title: { text: 'X Axis Title' }, categories: ['Jan', 'Feb', 'Mar'] }, + yAxis: { title: { text: 'Y Axis Title' } }, + series: [ + { name: 'Series 1', data: [10, 20, 30] }, + { name: 'Series 2', data: [15, 25, 35] }, + ], + }, +}; + +export const EXAMPLE_CHART_WITH_CONFIG_PARAMS = { + chartType: 'bar', + theme: 'alternate', + title: 'Example Bar Chart', + subtitle: 'A detailed subtitle', + uuid: 'chart-456', + config: { + chart: { type: 'bar' }, + legend: { enabled: true, align: 'right', verticalAlign: 'top', layout: 'vertical' }, + xAxis: { title: { text: 'X Axis Label' }, categories: ['A', 'B', 'C'] }, + yAxis: { title: { text: 'Y Axis Label' }, reversed: false }, + series: [ + { name: 'Category 1', data: [5, 15, 25] }, + { name: 'Category 2', data: [10, 20, 30] }, + ], + }, +}; diff --git a/src/components/chart/bar-chart.js b/src/components/chart/bar-chart.js new file mode 100644 index 0000000000..7ffd5a33d5 --- /dev/null +++ b/src/components/chart/bar-chart.js @@ -0,0 +1,166 @@ +import ChartConstants from './chart-constants'; + +class BarChart { + constructor() { + this.constants = ChartConstants.constants(); + //this.hideDataLabels = false; + } + + getBarChartOptions = (useStackedLayout) => { + return { + plotOptions: { + bar: { + // Set the width of the bars to be 30px + // The spacing is worked out in the specific-chart-options.js file + pointWidth: 30, // Fixed bar height + pointPadding: 0, + groupPadding: 0, + borderWidth: 0, + borderRadius: 0, + // Set the data labels to be enabled and positioned outside the bars + // We can add custom formatting on each chart to move the labels inside the bars if the bar is wide enough + dataLabels: { + enabled: true, + inside: false, + style: { + textOutline: 'none', + // there is no semibold font weight available in the design system fonts, so we use 700 instead + fontWeight: '700', + color: this.constants.labelColor, + fontSize: this.constants.mobileFontSize, + }, + }, + }, + series: { + stacking: useStackedLayout ? 'normal' : null, + }, + }, + xAxis: { + // Update the category label colours for bar charts + labels: { + style: { + color: this.constants.categoryLabelColor, + }, + }, + // remove the tick marks for bar charts + tickWidth: 0, + tickLength: 0, + tickColor: 'transparent', + }, + yAxis: { + title: { + // Todo: stop this overriding the other title properties + // Override the y Axis title settings for bar charts where the y axis is horizontal + offset: undefined, + y: 0, + }, + }, + }; + }; + + hideDataLabels = (config) => { + config.series.forEach((series) => { + series.dataLabels = { + enabled: false, + }; + }); + }; + + // Updates the config to move the data labels inside the bars, but only if the bar is wide enough + // This may also need to run when the chart is resized + postLoadDataLabels = (currentChart) => { + const insideOptions = { + dataLabels: this.getBarChartLabelsInsideOptions(), + }; + const outsideOptions = { + dataLabels: this.getBarChartLabelsOutsideOptions(), + }; + + currentChart.series.forEach((series) => { + const points = series.data; + points.forEach((point) => { + // Get the actual width of the data label + const labelWidth = point.dataLabel && point.dataLabel.getBBox().width; + // Move the data labels inside the bar if the bar is wider than the label plus some padding + if (point.shapeArgs.height > labelWidth + 20) { + point.update(insideOptions, false); + } else { + point.update(outsideOptions, false); + } + }); + }); + + currentChart.redraw(); + }; + + getBarChartLabelsInsideOptions = () => ({ + inside: true, + align: 'right', + verticalAlign: 'middle', + style: { + color: 'white', + fontWeight: 'bold', + }, + }); + + getBarChartLabelsOutsideOptions = () => ({ + inside: false, + align: undefined, + verticalAlign: undefined, + style: { + textOutline: 'none', + // there is no semibold font weight available in the design system fonts, so we use 700 instead + fontWeight: '700', + color: this.constants.labelColor, + fontSize: this.constants.mobileFontSize, + }, + }); + + // This updates the height of the vertical axis and overall chart to fit the number of categories + // Note that the vertical axis on a bar chart is the x axis + updateBarChartHeight = (config, currentChart, useStackedLayout) => { + const numberOfCategories = config.xAxis.categories.length; + const numberOfSeries = currentChart.series.length; // Get number of bar series + let barHeight = 30; // Height of each individual bar - set in bar-chart-plot-options + let groupSpacing = 0; // Space we want between category groups, or betweeen series groups for cluster charts + let categoriesTotalHeight = 0; + let totalSpaceHeight = 0; + if (useStackedLayout == false && numberOfSeries > 1) { + // slighly lower bar height for cluster charts + barHeight = 28; + // for cluster charts there is no space between the bars within a series, and 14px between each series + groupSpacing = 14; + // lower barHeight for series with 3 categories or more + if (numberOfSeries >= 3) { + barHeight = 20; + } + categoriesTotalHeight = numberOfCategories * barHeight * numberOfSeries; + + totalSpaceHeight = numberOfCategories * groupSpacing; + // work out the group padding for cluster charts which is measured in xAxis units. + const plotHeight = categoriesTotalHeight + totalSpaceHeight; + const xUnitHeight = plotHeight / numberOfCategories; + const groupPadding = groupSpacing / 2 / xUnitHeight; + currentChart.series.forEach((series) => { + series.update({ + groupPadding: groupPadding, + pointWidth: barHeight, + }); + }); + } else { + groupSpacing = 10; + categoriesTotalHeight = numberOfCategories * barHeight; + totalSpaceHeight = (numberOfCategories - 1) * groupSpacing; + } + + config.xAxis.height = categoriesTotalHeight + totalSpaceHeight; + const totalHeight = currentChart.plotTop + config.xAxis.height + currentChart.marginBottom; + if (totalHeight !== currentChart.chartHeight) { + currentChart.setSize(null, totalHeight, false); + } + + currentChart.redraw(); + }; +} + +export default BarChart; diff --git a/src/components/chart/chart-constants.js b/src/components/chart/chart-constants.js new file mode 100644 index 0000000000..ea215cff2c --- /dev/null +++ b/src/components/chart/chart-constants.js @@ -0,0 +1,21 @@ +class ChartConstants { + static constants() { + const constants = { + primaryTheme: ['#206095', '#27a0cc', '#003c57', '#118c7b', '#a8bd3a', '#871a5b', '#f66068', '#746cb1', '#22d0b6'], + // Alternate theme colours from https://service-manual.ons.gov.uk/data-visualisation/colours/using-colours-in-charts + alternateTheme: ['#206095', '#27A0CC', '#871A5B', '#A8BD3A', '#F66068'], + labelColor: '#414042', + axisLabelColor: '#707071', + categoryLabelColor: '#414042', + gridLineColor: '#d9d9d9', + zeroLineColor: '#b3b3b3', + // Responsive font sizes + mobileFontSize: '0.875rem', + desktopFontSize: '1rem', + }; + + return constants; + } +} + +export default ChartConstants; diff --git a/src/components/chart/chart.js b/src/components/chart/chart.js index 76844737df..7046cf29d4 100644 --- a/src/components/chart/chart.js +++ b/src/components/chart/chart.js @@ -1,6 +1,8 @@ import CommonChartOptions from './common-chart-options'; import SpecificChartOptions from './specific-chart-options'; -import LineChartPlotOptions from './line-chart'; +import LineChart from './line-chart'; +import BarChart from './bar-chart'; +import ColumnChart from './column-chart'; import Highcharts from 'highcharts'; class HighchartsBaseChart { @@ -14,39 +16,121 @@ class HighchartsBaseChart { this.theme = this.node.dataset.highchartsTheme; this.title = this.node.dataset.highchartsTitle; const chartNode = this.node.querySelector('[data-highcharts-chart]'); - this.uuid = this.node.dataset.highchartsUuid; - + this.useStackedLayout = this.node.hasAttribute('data-highcharts-use-stacked-layout'); this.config = JSON.parse(this.node.querySelector(`[data-highcharts-config--${this.uuid}]`).textContent); this.commonChartOptions = new CommonChartOptions(); - this.specificChartOptions = new SpecificChartOptions(this.theme, this.chartType); - + this.specificChartOptions = new SpecificChartOptions(this.theme, this.config); + this.lineChart = new LineChart(); + this.barChart = new BarChart(); + this.columnChart = new ColumnChart(); if (window.isCommonChartOptionsDefined === undefined) { this.setCommonChartOptions(); window.isCommonChartOptionsDefined = true; } this.setSpecificChartOptions(); - - Highcharts.chart(chartNode, this.config); + this.setLoadEvent(); + this.hideDataLabels = this.checkHideDataLabels(); + this.setWindowResizeEvent(); + this.chart = Highcharts.chart(chartNode, this.config); } - // Set up the global Highcharts options + // Set up the global Highcharts options which are used for all charts setCommonChartOptions = () => { const chartOptions = this.commonChartOptions.getOptions(); Highcharts.setOptions(chartOptions); }; + // Utility function to merge two configs together + mergeConfigs = (baseConfig, newConfig) => { + return { + ...baseConfig, + ...Object.keys(newConfig).reduce((mergedOptions, optionKey) => { + if (typeof baseConfig[optionKey] === 'object' && typeof newConfig[optionKey] === 'object') { + mergedOptions[optionKey] = { ...baseConfig[optionKey], ...newConfig[optionKey] }; + } else { + mergedOptions[optionKey] = newConfig[optionKey]; + } + return mergedOptions; + }, {}), + }; + }; + + // Set up options for specific charts and chart types setSpecificChartOptions = () => { const specificChartOptions = this.specificChartOptions.getOptions(); - for (const option in specificChartOptions) { - this.config[option] = specificChartOptions[option]; + const lineChartOptions = this.lineChart.getLineChartOptions(); + const barChartOptions = this.barChart.getBarChartOptions(this.useStackedLayout); + const columnChartOptions = this.columnChart.getColumnChartOptions(this.useStackedLayout); + // Merge specificChartOptions with the existing config + this.config = this.mergeConfigs(this.config, specificChartOptions); + + if (this.chartType === 'line') { + // Merge the line chart options with the existing config + this.config = this.mergeConfigs(this.config, lineChartOptions); + } + + if (this.chartType === 'bar') { + // Merge the bar chart options with the existing config + this.config = this.mergeConfigs(this.config, barChartOptions); } - this.config.plotOptions = { - line: new LineChartPlotOptions().plotOptions.line, + if (this.chartType === 'column') { + // Merge the column chart options with the existing config + this.config = this.mergeConfigs(this.config, columnChartOptions); + } + }; + + // Check if the data labels should be hidden + // They should be hidden for clustered bar charts with more than 2 series, and also for stacked bar charts + checkHideDataLabels = () => { + this.hideDataLabels = (this.chartType === 'bar' && this.config.series.length > 2) || this.useStackedLayout === true; + }; + + // Create the load event for various chart types + setLoadEvent = () => { + if (!this.config.chart.events) { + this.config.chart.events = {}; + } + this.config.chart.events.load = (event) => { + const currentChart = event.target; + if (this.chartType === 'line') { + this.lineChart.updateLastPointMarker(currentChart); + } + if (this.chartType === 'bar') { + this.barChart.updateBarChartHeight(this.config, currentChart, this.useStackedLayout); + if (!this.hideDataLabels) { + this.barChart.postLoadDataLabels(currentChart); + } + } + if (this.chartType === 'column') { + this.columnChart.updatePointPadding(this.config, currentChart); + } + currentChart.redraw(false); }; }; + + // Set resize events - throttled to 100ms + setWindowResizeEvent = () => { + if (this.chartType === 'column' || this.chartType === 'bar') { + window.addEventListener('resize', () => { + clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + // Get the current rendered chart instance + const currentChart = Highcharts.charts.find((chart) => chart && chart.container === this.chart.container); + // Update the data labels when the window is resized + if (this.chartType === 'bar' && !this.hideDataLabels) { + this.barChart.postLoadDataLabels(currentChart); + } + // Update the point padding for column charts when the window is resized + if (this.chartType === 'column') { + this.columnChart.updatePointPadding(this.config, currentChart); + } + }, 100); + }); + } + }; } export default HighchartsBaseChart; diff --git a/src/components/chart/column-chart.js b/src/components/chart/column-chart.js new file mode 100644 index 0000000000..3deef36b26 --- /dev/null +++ b/src/components/chart/column-chart.js @@ -0,0 +1,36 @@ +class ColumnChart { + getColumnChartOptions = (useStackedLayout) => { + return { + plotOptions: { + column: { + pointPadding: 0, + groupPadding: 0, + //pointWidth: 30, + //borderWidth: 0, + }, + series: { + stacking: useStackedLayout ? 'normal' : null, + }, + }, + }; + }; + + // Set the spacing between each bar to be 10px (a point padding that equates to 5px on each side) + updatePointPadding = (config, currentChart) => { + const numberOfCategories = config.xAxis.categories.length; + // const numberOfSeries = currentChart.series.length; // Get number of bar series + const chartWidth = currentChart.plotBox.width; + const widthOfEachColumn = chartWidth / numberOfCategories; + // Work out the poing padding decimal value + const pointPadding = 5 / widthOfEachColumn; + // update the point padding + currentChart.series.forEach((series) => { + series.update({ + pointPadding: pointPadding, + }); + }); + // Todo: adjust the point padding for cluster charts + }; +} + +export default ColumnChart; diff --git a/src/components/chart/common-chart-options.js b/src/components/chart/common-chart-options.js index dae1ae6eb1..bb22daabac 100644 --- a/src/components/chart/common-chart-options.js +++ b/src/components/chart/common-chart-options.js @@ -1,13 +1,9 @@ +import ChartConstants from './chart-constants'; + +// Options that are common to all chart types - these are set once in the Highcharts.setOptions() method class CommonChartOptions { constructor() { - this.constants = { - axisLabelColor: '#707071', - gridLineColor: '#d9d9d9', - zeroLineColor: '#b3b3b3', - // Responsive font sizes - mobileFontSize: '0.875rem', - desktopFontSize: '1rem', - }; + this.constants = ChartConstants.constants(); this.options = { chart: { @@ -17,6 +13,28 @@ class CommonChartOptions { color: '#222222', }, }, + legend: { + align: 'left', + verticalAlign: 'top', + layout: 'horizontal', + // Symbol width and height in the legend. May be overridden for individual chart types + symbolWidth: 12, + symbolHeight: 12, + margin: 50, + itemStyle: { + color: this.constants.labelColor, + fontSize: this.constants.desktopFontSize, + fontWeight: 'normal', + }, + // Disable click event on legend + // There is currently an issue because the legend items are still buttons + // and therefore the screen reader still announces that they can be clicked + events: { + itemClick: () => { + return false; + }, + }, + }, // Remove the chart title as rendered by Highcharts, as this is rendered in the surrounding component title: { text: '', @@ -37,17 +55,34 @@ class CommonChartOptions { }, title: { align: 'high', - offset: 0, + textAlign: 'middle', + reserveSpace: false, + offset: 15, rotation: 0, y: -25, + style: { + color: this.constants.axisLabelColor, + fontSize: this.constants.desktopFontSize, + }, }, lineColor: this.constants.gridLineColor, gridLineColor: this.constants.gridLineColor, - zeroLineColor: this.constants.zeroLineColor, + // Add zero line + plotLines: [ + { + color: this.constants.zeroLineColor, + width: 1, + value: 0, + zIndex: 2, + }, + ], + // Add tick marks + tickWidth: 1, + tickLength: 6, + tickColor: this.constants.gridLineColor, }, xAxis: { labels: { - rotation: 0, style: { color: this.constants.axisLabelColor, fontSize: this.constants.desktopFontSize, @@ -55,15 +90,32 @@ class CommonChartOptions { }, title: { align: 'high', + style: { + color: this.constants.axisLabelColor, + fontSize: this.constants.desktopFontSize, + }, }, lineColor: this.constants.gridLineColor, gridLineColor: this.constants.gridLineColor, - zeroLineColor: this.constants.zeroLineColor, // Add tick marks tickWidth: 1, tickLength: 6, tickColor: this.constants.gridLineColor, }, + plotOptions: { + series: { + // disabes the tooltip on hover + enableMouseTracking: false, + animation: false, + + // disables the legend item hover + states: { + inactive: { + enabled: false, + }, + }, + }, + }, // Adjust font size for smaller width of chart // Note this is not the same as the viewport width responsive: { @@ -84,6 +136,11 @@ class CommonChartOptions { fontSize: this.constants.mobileFontSize, }, }, + title: { + style: { + fontSize: this.constants.mobileFontSize, + }, + }, }, yAxis: { labels: { @@ -91,6 +148,11 @@ class CommonChartOptions { fontSize: this.constants.mobileFontSize, }, }, + title: { + style: { + fontSize: this.constants.mobileFontSize, + }, + }, }, }, }, diff --git a/src/components/chart/example-bar-chart.njk b/src/components/chart/example-bar-chart.njk new file mode 100644 index 0000000000..9639adc1fd --- /dev/null +++ b/src/components/chart/example-bar-chart.njk @@ -0,0 +1,65 @@ +{% from "components/chart/_macro.njk" import onsChart %} + +{{ + onsChart({ + "chartType": "bar", + "description": "Volume sales, seasonally adjusted, Great Britain, January 2022 to January 2025", + "theme": "primary", + "title": "Food stores showed a strong rise on the month, while non-food stores fell", + "subtitle": "Figure 6: Upward contribution from housing and household services (including energy) saw the annual CPIH inflation rate rise", + "uuid": "uuid", + "caption": "Source: Monthly Business Survey, Retail Sales Inquiry from the Office for National Statistics", + "download": { + 'title': 'Download Figure 1 data', + 'itemsList': [ + { + "text": "Excel spreadsheet (XLSX format, 18KB)", + "url": "#" + }, + { + "text": "Simple text file (CSV format, 25KB)", + "url": "#" + }, + { + + "text": "Image (PNG format, 25KB)", + "url": "#" + } + ]}, + "config": { + "chart": { "type": "bar" }, + "legend": { "enabled": true }, + "series": [ + { + "animation": false, + "data": [1.7, 2.1, 5.6, 0, -0.6, -2.7, -1.7, 2.4, -1.2], + "dataLabels": { "enabled": true }, + "name": "Jan-25" + } + ], + "xAxis": { + "categories": [ + "All retailing", + "All retailing excluding Automotive fuel", + "Food stores", + "Department stores", + "Other non-food stores", + "Textile clothing \u0026 footwear stores", + "Household goods stores", + "Non-store retailing", + "Automotive fuel" + ], + "reversed": false, + "title": { "enabled": true, "text": "" }, + "type": "linear" + }, + "yAxis": { + "reversed": false, + "title": { + "enabled": true, + "text": "Percent (%)" + } + } + } + }) +}} diff --git a/src/components/chart/example-clustered-bar-chart.njk b/src/components/chart/example-clustered-bar-chart.njk new file mode 100644 index 0000000000..36daec5071 --- /dev/null +++ b/src/components/chart/example-clustered-bar-chart.njk @@ -0,0 +1,73 @@ +{% from "components/chart/_macro.njk" import onsChart %} + +{{ + onsChart({ + "chartType": "bar", + "description": "Volume sales, seasonally adjusted, Great Britain, January 2022 to January 2025", + "theme": "primary", + "title": "Food stores showed a strong rise on the month, while non-food stores fell", + "subtitle": "Figure 6: Upward contribution from housing and household services (including energy) saw the annual CPIH inflation rate rise", + "uuid": "uuid", + "caption": "Source: Monthly Business Survey, Retail Sales Inquiry from the Office for National Statistics", + "download": { + 'title': 'Download Figure 1 data', + 'itemsList': [ + { + "text": "Excel spreadsheet (XLSX format, 18KB)", + "url": "#" + }, + { + "text": "Simple text file (CSV format, 25KB)", + "url": "#" + }, + { + + "text": "Image (PNG format, 25KB)", + "url": "#" + } + ]}, + "config": { + "chart": { "type": "bar" }, + "legend": { "enabled": true }, + "series": [ + { + "animation": false, + "data": [-6.2, 1.5, -15.9, 1.7], + "dataLabels": {"enabled": false}, + "name": "2022" + }, + { + "animation": false, + "data": [-2.9, -2.7, -2.9, -3.5], + "dataLabels": {"enabled": false}, + "name": "2023" + }, + { + "animation": false, + "data": [-1.6, 1.2, 3.2, 3.8], + "dataLabels": {"enabled": false}, + "name": "2024" + } + ], + "xAxis": { + "categories": [ + "Food stores", + "Non-food stores", + "Non-store retailing ", + "Automotive fuel" + ], + "title": { + "enabled": true, + "text": "" + }, + "type": "linear" + }, + "yAxis": { + "title": { + "enabled": true, + "text": "Percent (%)" + } + } + } + }) +}} diff --git a/src/components/chart/example-column-chart.njk b/src/components/chart/example-column-chart.njk new file mode 100644 index 0000000000..7211439d0b --- /dev/null +++ b/src/components/chart/example-column-chart.njk @@ -0,0 +1,65 @@ +{% from "components/chart/_macro.njk" import onsChart %} + +{{ + onsChart({ + "chartType": "column", + "description": "Public sector net debt excluding public sector banks, percentage of gross domestic product (GDP), UK, financial year ending (FYE) 1901 to October 2024", + "theme": "primary", + "title": "Figure 6: Net debt as a percentage of GDP remains at levels last seen in the early 1960s", + "subtitle": "Public sector net debt excluding public sector banks, percentage of gross domestic product (GDP), UK, financial year ending (FYE) 1901 to October 2024", + "uuid": "uuid", + "caption": "Source: Public sector finances from the Office for Budget Responsibility (OBR) and the Office for National Statistics", + "download": { + 'title': 'Download Figure 1 data', + 'itemsList': [ + { + "text": "Excel spreadsheet (XLSX format, 18KB)", + "url": "#" + }, + { + "text": "Simple text file (CSV format, 25KB)", + "url": "#" + }, + { + + "text": "Image (PNG format, 25KB)", + "url": "#" + } + ]}, + "config": { + "chart": { "type": "column" }, + "legend": { "enabled": true }, + "series": [ + { + "animation": false, + "data": [ + 37.8, 41.0, 43.0, 42.9, 41.8, 39.8, 37.9, 38.2, 37.6, 36.7, + 33.9, 31.8 + ], + "dataLabels": { + "enabled": false + }, + "name": "Public sector net debt as a % of GDP (PSND)" + } + ], + + "xAxis": { + "categories": [ + "Mar 1901", "Mar 1902", "Mar 1903", "Mar 1904", "Mar 1905", "Mar 1906", "Mar 1907", "Mar 1908", "Mar 1909", "Mar 1910", + "Mar 1911", "Mar 1912" + ], + "title": { + "enabled": true, + "text": "Years" + }, + "type": "linear" + }, + "yAxis": { + "title": { + "enabled": true, + "text": "Percentage of GDP" + } + } + } + }) +}} diff --git a/src/components/chart/example-line-chart.njk b/src/components/chart/example-line-chart.njk index e41095bd73..cd1e792bb5 100644 --- a/src/components/chart/example-line-chart.njk +++ b/src/components/chart/example-line-chart.njk @@ -18,7 +18,8 @@ }, { "text": "Simple text file (CSV format, 25KB)", - "url": "#" }, + "url": "#" + }, { "text": "Image (PNG format, 25KB)", @@ -36,7 +37,6 @@ "title": { "text": 'Sales' }, - 'reversed': true, "labels": { "format": '{value:,.f}' } @@ -168,7 +168,7 @@ 'Sep 2024', 'Oct 2024' ], - "tickInterval": 4, + "tickInterval": 12, 'type': 'linear' }, "series": [ diff --git a/src/components/chart/example-stacked-bar-chart.njk b/src/components/chart/example-stacked-bar-chart.njk new file mode 100644 index 0000000000..3063b81801 --- /dev/null +++ b/src/components/chart/example-stacked-bar-chart.njk @@ -0,0 +1,72 @@ +{% from "components/chart/_macro.njk" import onsChart %} +{{ + onsChart({ + "chartType": "bar", + "description": "Volume sales, seasonally adjusted, Great Britain, January 2022 to January 2025", + "theme": "primary", + "title": "Food stores showed a strong rise on the month, while non-food stores fell", + "subtitle": "Figure 6: Upward contribution from housing and household services (including energy) saw the annual CPIH inflation rate rise", + "uuid": "uuid", + "useStackedLayout": true, + "caption": "Source: Monthly Business Survey, Retail Sales Inquiry from the Office for National Statistics", + "download": { + 'title': 'Download Figure 1 data', + 'itemsList': [ + { + "text": "Excel spreadsheet (XLSX format, 18KB)", + "url": "#" + }, + { + "text": "Simple text file (CSV format, 25KB)", + "url": "#" + }, + { + "text": "Image (PNG format, 25KB)", + "url": "#" + } + ]}, + "config": { + "chart": { "type": "bar" }, + "legend": { "enabled": true }, + "series": [ + { + "animation": false, + "data": [5, 3, 4, 6], + "dataLabels": {"enabled": true}, + "name": "2022" + }, + { + "animation": false, + "data": [2, 2, 3, 7], + "dataLabels": {"enabled": false}, + "name": "2023" + }, + { + "animation": false, + "data": [3, 4, 4, 1], + "dataLabels": {"enabled": false}, + "name": "2024" + } + ], + "xAxis": { + "categories": [ + "Food stores", + "Non-food stores", + "Non-store retailing ", + "Automotive fuel" + ], + "title": { + "enabled": true, + "text": "" + }, + "type": "linear" + }, + "yAxis": { + "title": { + "enabled": true, + "text": "Percent (%)" + } + } + } + }) +}} diff --git a/src/components/chart/example-stacked-column-chart.njk b/src/components/chart/example-stacked-column-chart.njk new file mode 100644 index 0000000000..f4782e5850 --- /dev/null +++ b/src/components/chart/example-stacked-column-chart.njk @@ -0,0 +1,76 @@ +{% from "components/chart/_macro.njk" import onsChart %} +{{ + onsChart({ + "chartType": "column", + "description": "Volume sales, seasonally adjusted, Great Britain, January 2022 to January 2025", + "theme": "primary", + "title": "Food stores showed a strong rise on the month, while non-food stores fell", + "subtitle": "Figure 6: Upward contribution from housing and household services (including energy) saw the annual CPIH inflation rate rise", + "uuid": "uuid", + "useStackedLayout": true, + "caption": "Source: Monthly Business Survey, Retail Sales Inquiry from the Office for National Statistics", + "download": { + 'title': 'Download Figure 1 data', + 'itemsList': [ + { + "text": "Excel spreadsheet (XLSX format, 18KB)", + "url": "#" + }, + { + "text": "Simple text file (CSV format, 25KB)", + "url": "#" + }, + { + "text": "Image (PNG format, 25KB)", + "url": "#" + } + ]}, + "config": { + "chart": { "type": "column" }, + "legend": { "enabled": true }, + "series": [ + { + "animation": false, + "data": [5, 3, 4, 6, 2, 2, 3, 7], + "dataLabels": {"enabled": true}, + "name": "2022" + }, + { + "animation": false, + "data": [2, 2, 3, 7, 3, 4, 4, 1], + "dataLabels": {"enabled": false}, + "name": "2023" + }, + { + "animation": false, + "data": [3, 4, 4, 1, 5, 3, 4, 6], + "dataLabels": {"enabled": false}, + "name": "2024" + } + ], + "xAxis": { + "categories": [ + "Food stores", + "Non-food stores", + "Non-store retailing ", + "Automotive fuel", + "Household goods stores", + "Clothing stores", + "Other stores", + "All retailing" + ], + "title": { + "enabled": true, + "text": "Items" + }, + "type": "linear" + }, + "yAxis": { + "title": { + "enabled": true, + "text": "Percentage of GDP (%)" + } + } + } + }) +}} diff --git a/src/components/chart/line-chart.js b/src/components/chart/line-chart.js index 5972538a8f..5d24d7a568 100644 --- a/src/components/chart/line-chart.js +++ b/src/components/chart/line-chart.js @@ -1,24 +1,44 @@ -class LineChartPlotOptions { - static plotOptions() { - return this.plotOptions; - } - - constructor() { - this.plotOptions = { - line: { - lineWidth: 3, - linecap: 'round', - marker: { - enabled: false, - }, - states: { - hover: { - lineWidth: 3, +class LineChart { + getLineChartOptions = () => { + return { + legend: { + // Specific legend symbol width and height for line charts + symbolWidth: 20, + symbolHeight: 3, + }, + plotOptions: { + line: { + lineWidth: 3, + linecap: 'round', + marker: { + enabled: false, }, }, }, }; - } + }; + + updateLastPointMarker = (currentChart) => { + currentChart.series.forEach((series) => { + const points = series.points; + if (points && points.length > 0) { + // Show only the last point marker + const lastPoint = points[points.length - 1]; + lastPoint.update( + { + marker: { + enabled: true, + radius: 4, + symbol: 'circle', + fillColor: series.color, + lineWidth: 0, + }, + }, + false, + ); + } + }); + }; } -export default LineChartPlotOptions; +export default LineChart; diff --git a/src/components/chart/specific-chart-options.js b/src/components/chart/specific-chart-options.js index 940f7bce1e..61b79edcc3 100644 --- a/src/components/chart/specific-chart-options.js +++ b/src/components/chart/specific-chart-options.js @@ -1,27 +1,16 @@ +import ChartConstants from './chart-constants'; + +// Options that rely on the chart config but are not specific to the chart type class SpecificChartOptions { - constructor(theme, type) { - this.constants = { - primaryTheme: ['#206095', '#27a0cc', '#003c57', '#118c7b', '#a8bd3a', '#871a5b', '#f66068', '#746cb1', '#22d0b6'], - // Alternate theme colours from https://service-manual.ons.gov.uk/data-visualisation/colours/using-colours-in-charts - alternateTheme: ['#206095', '#27A0CC', '#871A5B', '#A8BD3A', '#F66068'], - labelColor: '#414042', - desktopFontSize: '1rem', - }; + constructor(theme, config) { + this.constants = ChartConstants.constants(); + this.theme = theme; + this.config = config; this.options = { - colors: theme === 'primary' ? this.constants.primaryTheme : this.constants.alternateTheme, - legend: { - align: 'left', - verticalAlign: 'top', - layout: 'horizontal', - symbolWidth: type === 'line' ? 20 : 12, - symbolHeight: type === 'line' ? 3 : 12, - margin: 30, - itemStyle: { - color: this.constants.labelColor, - fontSize: this.constants.desktopFontSize, - fontWeight: 'normal', - }, + colors: this.theme === 'primary' ? this.constants.primaryTheme : this.constants.alternateTheme, + chart: { + marginTop: this.config.legend.enabled ? undefined : 50, }, }; }