diff --git a/cms/datavis/models/charts.py b/cms/datavis/models/charts.py index bc701245..59d0546d 100644 --- a/cms/datavis/models/charts.py +++ b/cms/datavis/models/charts.py @@ -89,13 +89,14 @@ def media(self) -> Media: "https://code.highcharts.com/modules/exporting.js", "https://code.highcharts.com/modules/export-data.js", "https://code.highcharts.com/modules/accessibility.js", + "https://code.highcharts.com/modules/annotations.js", ] ) def get_context(self, request: Optional["HttpRequest"] = None, **kwargs: Any) -> dict[str, Any]: config = self.get_component_config(self.primary_data_source.headers, self.primary_data_source.rows) - # theme = self.theme - return super().get_context(request, config=config, **kwargs) + annotations_values = self.get_annotations_values() + return super().get_context(request, config=config, annotations_values=annotations_values, **kwargs) general_panels: ClassVar[Sequence["Panel"]] = [ FieldPanel("name"), @@ -199,13 +200,6 @@ def get_component_config(self, headers: Sequence[str], rows: Sequence[list[str | }, "xAxis": self.get_x_axis_config(headers, rows), "yAxis": self.get_y_axis_config(headers, rows), - "navigation": { - "enabled": False, - }, - "annotations": self.get_annotations_config(), - "credits": { - "enabled": False, - }, "series": self.get_series_data(headers, rows), } @@ -253,38 +247,18 @@ def get_y_axis_config( config["max"] = self.y_max return config - def get_annotations_config(self) -> list[dict[str, Any]]: - config: list[dict[str, Any]] = [] - annotation_group: dict[str, Any] = { - "draggable": "", - "labelOptions": { - "backgroundColor": "rgba(255,255,255,0.5)", - "verticalAlign": "top", - }, - } - # TODO: It's likely we'll want to support a few different style - # options for annotations, in which case, we'd split annotations - # into multiple groups, each with a separate 'labelOptions' value - # to control the styling. - group_labels: list[dict[str, Any]] = [] + def get_annotations_values(self) -> list[dict[str, Any]]: + annotation_values: list[dict[str, Any]] = [] for item in self.annotations.raw_data: # pylint: disable=no-member value = item["value"] - group_labels.append( + annotation_values.append( { "text": value["label"], - "point": { - "x": numberfy(value["x_position"]), - "y": numberfy(value["y_position"]), - "xAxis": 0, - "yAxis": 0, - }, + "xValue": numberfy(value["x_position"]), + "yValue": numberfy(value["y_position"]), } ) - - if group_labels: - annotation_group["labels"] = group_labels - config.append(annotation_group) - return config + return annotation_values def get_series_data(self, headers: Sequence[str], rows: Sequence[list[str | int | float]]) -> list[dict[str, Any]]: series = [] diff --git a/cms/jinja2/templates/datavis/_macro.njk b/cms/jinja2/templates/datavis/_macro.njk new file mode 100644 index 00000000..25df8090 --- /dev/null +++ b/cms/jinja2/templates/datavis/_macro.njk @@ -0,0 +1,36 @@ +{% macro onsChart(params) %} +
+
+

{{ params.title }}

+

{{ params.subtitle }}

+
+ {% if params.caption %} +
{{ params.caption }}
+ {% endif %} +
+ + + {% set config_scriptid = ["config--", params.uuid] | join %} + {{ params.config|json_script(config_scriptid) }} + + {% if params.annotations_values %} + {% set annotations_values_scriptid = ["annotations-values--", params.uuid] | join %} + {{ params.annotations_values|json_script(annotations_values_scriptid) }} + {% endif %} + + {% if params.download_title and (params.download_image or params.download_csv or params.download_excel) %} + {% if params.download_title %}

{{ params.download_title }}

{% endif %} +
    + {% if params.download_excel %} +
  1. Excel spreadsheet (XLSX format{% if params.download_excel_size %}, {{ params.download_excel_size }}{% endif %})
  2. + {% endif %} + {% if params.download_csv %} +
  3. Simple text file (CSV format{% if params.download_csv_size %}, {{ params.download_csv_size }}{% endif %})
  4. + {% endif %} + {% if params.download_image %} +
  5. Image (PNG format{% if params.download_image_size %}, {{ params.download_image_size }}{% endif %})
  6. + {% endif %} +
+ {% endif %} +
+{% endmacro %} diff --git a/cms/jinja2/templates/datavis/base_highcharts_chart.html b/cms/jinja2/templates/datavis/base_highcharts_chart.html index adaceee6..9219bd34 100644 --- a/cms/jinja2/templates/datavis/base_highcharts_chart.html +++ b/cms/jinja2/templates/datavis/base_highcharts_chart.html @@ -1,15 +1,31 @@ -
- {% if not media_already_loaded and object.media %} - {{ object.media }} - {% endif %} -
-

{{ object.title }}

-

{{ object.subtitle }}

-
- {% if object.caption %} -
{{ object.caption|richtext }}
- {% endif %} -
- {% set scriptid = ["config--", object.uuid] | join %} - {{ config|json_script(scriptid) }} -
+{% from "templates/datavis/_macro.njk" import onsChart %} + +{% if not media_already_loaded and object.media %} + {{ object.media }} +{% endif %} + +{{ config|pprint }} +

+{{ annotations_values|pprint }} + +{# fmt:off #} +{{ + onsChart({ + "chartType": object.highcharts_chart_type, + "theme": object.theme, + "title": object.title, + "subtitle": object.subtitle, + "uuid": object.uuid, + "caption": object.caption|richtext, + "config": config, + "annotations_values": annotations_values, + "download_title": "Figure 1 data", + "download_image": "xyz", + "download_image_size": "18KB", + "download_csv": "xyz", + "download_csv_size": "25KB", + "download_excel": "xyz", + "download_excel_size": "25KB", + }) +}} +{# fmt:on #} diff --git a/cms/static_src/javascript/components/bar-chart-plot-options.js b/cms/static_src/javascript/components/bar-chart-plot-options.js new file mode 100644 index 00000000..9c93678e --- /dev/null +++ b/cms/static_src/javascript/components/bar-chart-plot-options.js @@ -0,0 +1,37 @@ +import ChartConstants from './chart-constants'; + +class BarChartPlotOptions { + static plotOptions() { + return this.plotOptions; + } + + constructor() { + const constants = ChartConstants.constants(); + this.plotOptions = { + bar: { + // Set the width of the bars to be 30px and the spacing between them to be 10px + pointWidth: 30, // Fixed bar height + groupPadding: 0, // No padding between groups + pointPadding: 0, // No padding within groups + borderWidth: 0, + borderRadius: 0, + spacing: [10, 0, 0, 0], // [top, right, bottom, left] spacing between bars + // 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 400 instead + fontWeight: '400', + color: constants.labelColor, + fontSize: constants.mobileFontSize, + }, + }, + }, + }; + } +} + +export default BarChartPlotOptions; diff --git a/cms/static_src/javascript/components/chart-constants.js b/cms/static_src/javascript/components/chart-constants.js new file mode 100644 index 00000000..ade71ba5 --- /dev/null +++ b/cms/static_src/javascript/components/chart-constants.js @@ -0,0 +1,30 @@ +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', + gridLineColor: '#d9d9d9', + zeroLineColor: '#b3b3b3', + // Responsive font sizes + mobileFontSize: '0.875rem', + desktopFontSize: '1rem', + }; + + return constants; + } +} + +export default ChartConstants; diff --git a/cms/static_src/javascript/components/column-chart-plot-options.js b/cms/static_src/javascript/components/column-chart-plot-options.js new file mode 100644 index 00000000..4f624a92 --- /dev/null +++ b/cms/static_src/javascript/components/column-chart-plot-options.js @@ -0,0 +1,17 @@ +class ColumnChartPlotOptions { + static plotOptions() { + return this.plotOptions; + } + + constructor() { + this.plotOptions = { + column: { + groupPadding: 0.2, + borderWidth: 0, + borderRadius: 0, + }, + }; + } +} + +export default ColumnChartPlotOptions; diff --git a/cms/static_src/javascript/components/common-chart-options.js b/cms/static_src/javascript/components/common-chart-options.js index eaf12f5c..3f518f73 100644 --- a/cms/static_src/javascript/components/common-chart-options.js +++ b/cms/static_src/javascript/components/common-chart-options.js @@ -1,34 +1,13 @@ -class ChartOptions { - static options() { - return this.options; - } +/* this contains global options for all charts */ +import ChartConstants from './chart-constants'; +class CommonChartOptions { constructor(theme, title, type) { - // Primary theme colours from https://service-manual.ons.gov.uk/data-visualisation/colours/using-colours-in-charts - const 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 - const alternateTheme = ['#206095', '#27A0CC', '#871A5B', '#A8BD3A', '#F66068']; - const labelColor = '#414042'; - const axisLabelColor = '#707071'; - const gridLineColor = '#d9d9d9'; - const zeroLineColor = '#b3b3b3'; - // Responsive font sizes - const mobileFontSize = '0.875rem'; - const desktopFontSize = '1rem'; + this.constants = ChartConstants.constants(); // These options can be set globally for all charts this.options = { - colors: theme === 'primary' ? primaryTheme : alternateTheme, + colors: theme === 'primary' ? this.constants.primaryTheme : this.constants.alternateTheme, chart: { backgroundColor: 'transparent', style: { @@ -44,8 +23,8 @@ class ChartOptions { symbolHeight: type === 'line' ? 3 : 12, margin: 30, itemStyle: { - color: labelColor, - fontSize: desktopFontSize, + color: this.constants.labelColor, + fontSize: this.constants.desktopFontSize, fontWeight: 'normal', }, }, @@ -62,72 +41,37 @@ class ChartOptions { // Remove Highcharts watermark enabled: false, }, - plotOptions: { - bar: { - // Set the width of the bars to be 30px and the spacing between them to be 10px - pointWidth: 30, // Fixed bar height - groupPadding: 0, // No padding between groups - pointPadding: 0, // No padding within groups - borderWidth: 0, - borderRadius: 0, - spacing: [10, 0, 0, 0], // [top, right, bottom, left] spacing between bars - // 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 400 instead - fontWeight: '400', - color: labelColor, - fontSize: mobileFontSize, - }, - }, - }, - line: { - lineWidth: 3, // Sets the line thickness to 3px - linecap: 'round', - marker: { - enabled: false, - }, - states: { - hover: { - lineWidth: 3, // Maintain line width on hover - }, - }, - }, - column: { - groupPadding: 0.2, - borderWidth: 0, - borderRadius: 0, + // disabled the download button for now + navigation: { + buttonOptions: { + enabled: false, }, }, yAxis: { labels: { style: { - color: axisLabelColor, - fontSize: desktopFontSize, + color: this.constants.axisLabelColor, + fontSize: this.constants.desktopFontSize, }, }, - lineColor: gridLineColor, - gridLineColor: gridLineColor, - zeroLineColor: zeroLineColor, + lineColor: this.constants.gridLineColor, + gridLineColor: this.constants.gridLineColor, + zeroLineColor: this.constants.zeroLineColor, }, xAxis: { labels: { style: { - color: axisLabelColor, - fontSize: desktopFontSize, + color: this.constants.axisLabelColor, + fontSize: this.constants.desktopFontSize, }, }, - lineColor: gridLineColor, - gridLineColor: gridLineColor, - zeroLineColor: zeroLineColor, + lineColor: this.constants.gridLineColor, + gridLineColor: this.constants.gridLineColor, + zeroLineColor: this.constants.zeroLineColor, // Add tick marks tickWidth: 1, tickLength: 6, - tickColor: gridLineColor, + tickColor: this.constants.gridLineColor, }, // Adjust font size for smaller width of chart // Note this is not the same as the viewport width @@ -140,20 +84,20 @@ class ChartOptions { chartOptions: { legend: { itemStyle: { - fontSize: mobileFontSize, + fontSize: this.constants.mobileFontSize, }, }, xAxis: { labels: { style: { - fontSize: mobileFontSize, + fontSize: this.constants.mobileFontSize, }, }, }, yAxis: { labels: { style: { - fontSize: mobileFontSize, + fontSize: this.constants.mobileFontSize, }, }, }, @@ -163,6 +107,21 @@ class ChartOptions { }, }; } + + getOptions() { + return this.options; + } + + getAnnotationLabelOptions() { + return { + style: { + color: this.constants.axisLabelColor, + fontSize: this.constants.desktopFontSize, + }, + borderWidth: 0, + backgroundColor: undefined, + }; + } } -export default ChartOptions; +export default CommonChartOptions; diff --git a/cms/static_src/javascript/components/highcharts-base-chart.js b/cms/static_src/javascript/components/highcharts-base-chart.js index 877ccf33..e0fdbdcf 100644 --- a/cms/static_src/javascript/components/highcharts-base-chart.js +++ b/cms/static_src/javascript/components/highcharts-base-chart.js @@ -1,5 +1,8 @@ /* global Highcharts */ -import ChartOptions from './common-chart-options'; +import CommonChartOptions from './common-chart-options'; +import LineChartPlotOptions from './line-chart-plot-options'; +import BarChartPlotOptions from './bar-chart-plot-options'; +import ColumnChartPlotOptions from './column-chart-plot-options'; class HighchartsBaseChart { static selector() { @@ -13,25 +16,65 @@ class HighchartsBaseChart { this.title = this.node.dataset.highchartsTitle; const chartNode = this.node.querySelector('[data-highcharts-chart]'); const chartId = chartNode.dataset.highchartsId; + // We start with some config in the correct Highcharts format supplied by Wagtail + // This gets some further modifications this.apiConfig = JSON.parse(this.node.querySelector(`#config--${chartId}`).textContent); - this.chartOptions = new ChartOptions(this.theme, this.title, this.chartType); + if (this.node.querySelector(`#annotations-values--${chartId}`)) { + this.annotationsValues = JSON.parse( + this.node.querySelector(`#annotations-values--${chartId}`).textContent, + ); + } + + this.commonChartOptions = new CommonChartOptions(this.theme, this.title, this.chartType); + + // Configure the chart styling options common to all charts + // Will only run once per page load + this.setCommonChartOptions(); + if (this.chartType === 'bar') { this.updateBarChartHeight(); this.postLoadDataLabels(); } - Highcharts.setOptions(this.chartOptions.options); + + // Configure any annotations that have been specified + if (this.annotationsValues) { + this.configureAnnotations(); + } + + // Create the chart Highcharts.chart(chartNode, this.apiConfig); } + // Set up the global Highcharts options + setCommonChartOptions() { + if (window.commonChartOptionsSet === undefined) { + const chartOptions = this.commonChartOptions.getOptions(); + chartOptions.plotOptions = new LineChartPlotOptions().plotOptions; + chartOptions.plotOptions = { + ...chartOptions.plotOptions, + ...new BarChartPlotOptions().plotOptions, + }; + chartOptions.plotOptions = { + ...chartOptions.plotOptions, + ...new ColumnChartPlotOptions().plotOptions, + }; + + // Apply the options globally + Highcharts.setOptions(chartOptions); + } + window.commonChartOptionsSet = true; + } + updateBarChartHeight() { // dynamically set the height of the chart based on the number of categories. Bars are 30px wide, with 10px spacing, so we allow 40px per bar, with an extra 100px for margins. // Todo: Needs more fine tuning, e.g. calculating the height of the legend - this.chartOptions.options.chart.height = this.apiConfig.xAxis.categories.length * 40 + 100; + this.apiConfig.chart.height = this.apiConfig.xAxis.categories.length * 40 + 100; } - // For this to work, we need an option to include data lables and a format for them + // 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() { - this.chartOptions.options.chart.events = { + this.apiConfig.chart.events = { // Move data labels inside bars if the bar is wide enough load() { const points = this.series[0].data; @@ -56,6 +99,48 @@ class HighchartsBaseChart { }, }; } + + // Updates the config object to include any annotations that have been specified + configureAnnotations() { + const allAnnotations = []; + this.annotationsValues.forEach((annotation) => { + const annotationConfig = { labels: [], shapes: [] }; + annotationConfig.draggable = ''; + annotationConfig.labelOptions = this.commonChartOptions.getAnnotationLabelOptions(); + annotationConfig.labels.push({ + text: annotation.text, + point: { + x: annotation.xValue, + y: 20, // hard coded to be 20px from the top of the chart + xAxis: 0, + yAxis: undefined, // allows the 20px offset to be relative to the overall chart, not to the y axis + }, + }); + annotationConfig.shapes.push({ + type: 'path', + points: [ + // the position of the top of the arrow + { + x: annotation.xValue, + y: 30, // hard coded to be 10px from the label + xAxis: 0, + }, + // the position of the bottom of the arrow - at the point being labelled + { + x: annotation.xValue, + y: annotation.yValue, + xAxis: 0, + yAxis: 0, + }, + ], + markerEnd: 'arrow', + stroke: '#414042', + strokeWidth: 1, + }); + allAnnotations.push(annotationConfig); + }); + this.apiConfig.annotations = allAnnotations; + } } export default HighchartsBaseChart; diff --git a/cms/static_src/javascript/components/line-chart-plot-options.js b/cms/static_src/javascript/components/line-chart-plot-options.js new file mode 100644 index 00000000..b6d0596e --- /dev/null +++ b/cms/static_src/javascript/components/line-chart-plot-options.js @@ -0,0 +1,27 @@ +class LineChartPlotOptions { + static plotOptions() { + return this.plotOptions; + } + + constructor() { + this.plotOptions = { + line: { + lineWidth: 3, // Sets the line thickness to 3px + linecap: 'round', + marker: { + enabled: false, + radius: 4, // Sets circle radius to 4px (8px diameter) + // currently the marker style is configurable but this may change + // symbol: 'circle', + }, + states: { + hover: { + lineWidth: 3, // Maintain line width on hover + }, + }, + }, + }; + } +} + +export default LineChartPlotOptions;