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,
},
};
}