From 9a06a72a71518064aa181363bd36076be8a8f6c0 Mon Sep 17 00:00:00 2001
From: Henry Lau <2945099+henryjameslau@users.noreply.github.com>
Date: Wed, 7 Jan 2026 14:32:45 +0000
Subject: [PATCH 1/4] feat: Add slope chart configuration and update related
files
---
chart-menu/chartConfig.js | 11 ++
slope-chart/README.md | 2 +-
slope-chart/chart.css | 12 +-
slope-chart/config.js | 55 +++----
slope-chart/data.csv | 9 +-
slope-chart/index.html | 2 +-
slope-chart/script.js | 332 ++++++++++++++------------------------
7 files changed, 165 insertions(+), 258 deletions(-)
diff --git a/chart-menu/chartConfig.js b/chart-menu/chartConfig.js
index 8e78d0ff..8887764d 100644
--- a/chart-menu/chartConfig.js
+++ b/chart-menu/chartConfig.js
@@ -240,6 +240,17 @@ const chartConfig = [
dataFiles: [
{ name: "data.csv", path: "data.csv" }
]
+ },
+ {
+ name: "Slope chart",
+ url: "https://onsdigital.github.io/Charts/slope-chart/",
+ tags: {
+ comparision: true,
+ "change-over-time": true
+ },
+ dataFiles: [
+ { name: "data.csv", path: "data.csv" }
+ ]
}
];
diff --git a/slope-chart/README.md b/slope-chart/README.md
index 5b17d9a2..5a8cfe5f 100644
--- a/slope-chart/README.md
+++ b/slope-chart/README.md
@@ -1 +1 @@
-# Slope chart
\ No newline at end of file
+# Line chart
\ No newline at end of file
diff --git a/slope-chart/chart.css b/slope-chart/chart.css
index 1781473a..46bc3212 100644
--- a/slope-chart/chart.css
+++ b/slope-chart/chart.css
@@ -3,14 +3,4 @@
.axis path,
.axis line {
stroke: none; /* Changes the color of the axis lines */
- }
-
-
- .directLineLabelBold {
- font-size: 14px;
- font-weight: 700;
- }
- .directLineLabelRegular {
- font-size: 14px;
- font-weight: 500;
- }
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/slope-chart/config.js b/slope-chart/config.js
index ac491191..3afa15cc 100644
--- a/slope-chart/config.js
+++ b/slope-chart/config.js
@@ -1,50 +1,43 @@
config = {
"graphicDataURL": "data.csv",
- "colourPalette": ONSlinePalette,
+ "colourScheme":"direction", //"categories" or "direction"
+ "categoryPalette": ONSlinePalette,
+ "directionPalette": ONScolours.diverging.negativeToPositiveAlt["3"],
"textColourPalette": ONStextPalette,
"sourceText": "Office for National Statistics",
"accessibleSummary": "The chart canvas is hidden from screen readers. The main message is summarised by the chart title and the data behind the chart is available to download below.",
- "lineCurveType": "curveLinear",
- "yDomain": [0, 550000],
- // either "auto" or an array for the x domain e.g. [0,2000]
- "xAxisTickFormat": {
- "sm": "%b %y",
- "md": "%b %y",
- "lg": "%B %Y"
+ "yDomainMax": "auto", //"auto" for automatic y-axis max, or a number for fixed max
+ "showZeroAxis":true,
+ "yAxisLabel": "y axis label",
+ "yAxisTicks":{
+ "sm": 5,
+ "md": 5,
+ "lg": 5
},
- "xAxisNumberFormat": ".0f",
- "yAxisNumberFormat": ",.0f",
- "dateFormat": "%d-%m-%Y",
- // default is 75
- "chartHeight": {
- "sm": 350,
- "md": 350,
- "lg": 350
- },
- "chartWidth": {
- "sm": 75,
- "md": 75,
- "lg": 75
+ "xAxisLabels": ["Q1 2018","Q1 2021"],
+ "aspectRatio": {
+ "sm": [1, 1],
+ "md": [1, 1],
+ "lg": [1, 1]
},
"margin": {
"sm": {
"top": 30,
- "right": 0, //Not needed - right margin calculated from chartwidth etc.
- "bottom": 25,
- "left": 70
+ "right": 0,
+ "bottom": 50,
+ "left": 0
},
"md": {
"top": 30,
- "right": 0, //Not needed - right margin calculated from chartwidth etc.
- "bottom": 15,
- "left": 70
+ "right": 100,
+ "bottom": 50,
+ "left": 80
},
"lg": {
"top": 30,
- "right": 0, //Not needed - right margin calculated from chartwidth etc.
- "bottom": 15,
- "left": 70
+ "right": 100,
+ "bottom": 50,
+ "left": 60
}
},
- "elements": { "select": 0, "nav": 0, "legend": 1, "titles": 0 }
};
diff --git a/slope-chart/data.csv b/slope-chart/data.csv
index 991c234f..b3c8083f 100644
--- a/slope-chart/data.csv
+++ b/slope-chart/data.csv
@@ -1,3 +1,6 @@
-date,All Sikh,Ethnic group and religion,Religion only,Ethnic group only
-2011,430020,76500,346658,6862
-2021,525865,97910,426230,1725
+name,leftValue,rightValue
+Germany:,17.025,12.468
+United States:,9.099,7.582
+Netherlands:,10.283,7.093
+France:,7.1,5.166
+China:,10.224,16.928
diff --git a/slope-chart/index.html b/slope-chart/index.html
index 242159b4..7940c009 100644
--- a/slope-chart/index.html
+++ b/slope-chart/index.html
@@ -2,7 +2,7 @@
-
+
Slope chart
diff --git a/slope-chart/script.js b/slope-chart/script.js
index 10befe62..0144da4d 100644
--- a/slope-chart/script.js
+++ b/slope-chart/script.js
@@ -1,282 +1,192 @@
-import { initialise, wrap, addSvg, addSource } from "../lib/helpers.js";
+import { initialise, addSvg, addSource, createDirectLabels } from "../lib/helpers.js";
let graphic = d3.select('#graphic');
-//console.log(`Graphic selected: ${graphic}`);
+let legend = d3.select('#legend');
+let graphicData, size;
let pymChild = null;
-let graphicData, size;
function drawGraphic() {
//Set up some of the basics and return the size value ('sm', 'md' or 'lg')
size = initialise(size);
+ const aspectRatio = config.aspectRatio[size]
// Define the dimensions and margin, width and height of the chart.
let margin = config.margin[size];
- // let width = parseInt(graphic.style('width')) - margin.left - margin.right;
- let height = config.chartHeight[size];
- let width = config.chartWidth[size];
- // console.log(parseInt(graphic.style('width')) - width - margin.left - 75)
- // console.log(`Margin, width, and height set: ${margin}, ${width}, ${height}`);
-
- // Get categories from the keys used in the stack generator
- const categories = Object.keys(graphicData[0]).filter((k) => k !== 'date');
- // console.log(`Categories retrieved: ${categories}`);
+ let chartWidth = parseInt(graphic.style('width')) - margin.left - margin.right;
+ let height = (aspectRatio[1] / aspectRatio[0]) * chartWidth;
- let xDataType;
+ const columns = graphicData.columns.slice(1)
- if (Object.prototype.toString.call(graphicData[0].date) === '[object Date]') {
- xDataType = 'date';
- } else {
- xDataType = 'numeric';
- }
+ const startPoint = size == "sm" ? 50 : chartWidth / 2 - 90;
+ const endPoint = size == "sm" ? chartWidth - 160 : chartWidth / 2 + 90;
- // console.log(xDataType)
+ let x = d3.scalePoint().domain(columns)
+ .range([startPoint, endPoint])
- // Define the x and y scales
+ let xLabels = d3.scalePoint().domain(config.xAxisLabels)
+ .range([startPoint, endPoint]);
- let x;
-
- if (xDataType == 'date') {
- x = d3.scaleTime()
- .domain(d3.extent(graphicData, (d) => d.date))
- .range([0, width]);
- } else {
- x = d3.scaleLinear()
- .domain(d3.extent(graphicData, (d) => +d.date))
- .range([0, width]);
- }
- //console.log(`x defined`);
const y = d3
.scaleLinear()
.range([height, 0]);
- if (config.yDomain == "auto") {
- let minY = d3.min(graphicData, (d) => Math.min(...categories.map((c) => d[c])))
- let maxY = d3.max(graphicData, (d) => Math.max(...categories.map((c) => d[c])))
- y.domain([minY, maxY])
+
+ if (config.showZeroAxis) {
+ y.domain([0, d3.max(graphicData.flatMap(d => columns.map(col => Number(d[col]))))]);
} else {
- y.domain(config.yDomain)
+ y.domain(d3.extent(graphicData.flatMap(d => columns.map(col => Number(d[col])))));
}
-
// Create an SVG element
const svg = addSvg({
svgParent: graphic,
- chartWidth: parseInt(graphic.style('width')) - margin.left - margin.right,
+ chartWidth: chartWidth,
height: height + margin.top + margin.bottom,
margin: margin
})
- const lastDatum = graphicData[graphicData.length - 1];
- const firstDatum = graphicData[0];
+ const categories = graphicData.map(d => d.name)
+
+ const lineGenerator = d3
+ .line()
+ .x(d => x(d.x))
+ .y(d => y(d.y))
+ .defined(d => d.y !== null && !isNaN(d.y))
+ .curve(d3.curveLinear);
+
+ function direction(data) {
+ if (+data[columns[0]] > +data[columns[columns.length - 1]]) {
+ return "decreasing"
+ } else if (+data[columns[columns.length - 1]] > +data[columns[0]]) {
+ return "increasing"
+ } else if (+data[columns[0]] == +data[columns[columns.length - 1]]) {
+ return "same"
+ }
+ }
+
+ const colourDirectionScale = d3.scaleOrdinal().domain(["decreasing", "same", "increasing"]).range(config.directionPalette)
+
+
+
+ // add grid lines to y axis
+ if (config.showZeroAxis) {
+ const zeroAxis = svg
+ .append('g')
+ .attr('transform', "translate(" + (startPoint - 20) + ",0)")
+ .attr('class', 'y axis')
+ .call(
+ d3
+ .axisLeft(y)
+ .tickValues([0])
+ .tickSize(-(endPoint - startPoint) - 40)
+ // .tickFormat('')
+ )
+
+ svg.append('g').attr('transform', "translate(" + (endPoint + 20) + ",0)")
+ .attr('class', 'y axis')
+ .call(d3.axisRight(y).tickValues([0]).tickSize(0))
+
+
+ zeroAxis.selectAll('g.tick line')
+ .attr('class', 'zero-line')
+
+ }
+
// Add the x-axis
- svg
+ const xAxis = svg
.append('g')
.attr('class', 'x axis')
- .attr('transform', "translate(0," + (height + 5) + ")")
+ .attr('transform', `translate(0, ${height})`)
.call(
d3
- .axisTop(x)
- // .tickValues(tickValues)
- .tickFormat((d) => xDataType == 'date' ? d3.timeFormat(config.xAxisTickFormat[size])(d)
- : d3.format(config.xAxisNumberFormat)(d))
- .tickValues([firstDatum.date, lastDatum.date])
- .tickSize(height + 10)
- );
-
- // Add text labels to the right of the circles
- let xOffset = 8;
- let textLength;
- let rightWrapWidth = parseInt(graphic.style('width')) - margin.left - width - xOffset - 75;
-
- //Calculating where to place the category label
- function getTextLength(thing) {
- // textLength = thing._groups[0][0].clientWidth + xOffset; <-- this has some issues once in Florence/live - better method below
- textLength = thing.node().getComputedTextLength() + xOffset;
+ .axisTop(xLabels)
+ .tickSize(height)
+ )
+
+ xAxis.selectAll(".tick text")
+ .attr('dy', "-0.5em")
+ .style("font-weight", 700)
+ .style("fill", ONScolours.grey100);
+
+ xAxis.selectAll(".tick line")
+ .style("stroke", ONScolours.grey40)
- }
// create lines and circles for each category
- categories.forEach(function (category) {
- const lineGenerator = d3
- .line()
- .x((d) => x(d.date))
- .y((d) => y(d[category]))
- .curve(d3[config.lineCurveType]) // I used bracket notation here to access the curve type as it's a string
- .context(null);
+ categories.forEach(function (category, index) {
+
+ const itemData = columns.map(col => ({
+ x: col,
+ y: Number(graphicData.find(d => d.name === category)[col])
+ }));
svg
.append('path')
- .datum(graphicData)
+ .datum(itemData)
.attr('fill', 'none')
.attr(
- 'stroke',
- config.colourPalette[
- categories.indexOf(category) % config.colourPalette.length
- ]
+ 'stroke', config.colourScheme == "categories" ?
+ config.categoryPalette[
+ categories.indexOf(category) % config.categoryPalette.length
+ ] : colourDirectionScale(direction(graphicData.find(d => d.name === category)))
)
- .attr('stroke-width', 3)
+ .attr('stroke-width', 2.5)
.attr('d', lineGenerator)
.style('stroke-linejoin', 'round')
.style('stroke-linecap', 'round');
- //console.log(`Path appended for category: ${category}`);
- // Add text labels to the right of the circles
- svg
- .append('text')
- .attr(
- 'transform',
- `translate(${x(lastDatum.date)}, ${y(lastDatum[category])})`
- )
- .attr('x', xOffset)
- .attr('dy', '.35em')
- .attr('text-anchor', 'start')
- .attr(
- 'fill',
- config.textColourPalette[
- categories.indexOf(category) % config.textColourPalette.length
- ]
- )
- .text(d3.format(config.yAxisNumberFormat)((lastDatum[category]))) /* (Math.round((lastDatum[category]) / 100) * 100) */
- .attr('id', 'lastDateLabel')
- .attr("class", "directLineLabelBold")
- .call(getTextLength, this) //Work out the width of this bit of text for positioning the next bit
- .append('tspan')
- .attr('x', xOffset + textLength)
- .attr('dy', '.35em')
- .attr('text-anchor', 'start')
- .attr(
- 'fill',
- config.textColourPalette[
- categories.indexOf(category) % config.textColourPalette.length
- ]
- )
- .text(category)
- .attr("class", "directLineLabelRegular")
- .call(wrap, rightWrapWidth); //wrap function for the direct labelling.
-
- //Add text labels to the left of the first circles
- svg
- .append('text')
- .attr(
- 'transform',
- `translate(${x(firstDatum.date)}, ${y(firstDatum[category])})`
- )
- .attr('x', -xOffset)
- .attr('dy', '0.35em')
- .attr('text-anchor', 'end')
- .attr(
- 'fill',
- config.textColourPalette[
- categories.indexOf(category) % config.textColourPalette.length
- ]
- )
- .text(d3.format(config.yAxisNumberFormat)(firstDatum[category]))
- .attr("class", "directLineLabelBold")
-
- //Add the circles
- svg
- .append('circle')
- .attr('cx', x(firstDatum.date))
- .attr('cy', y(firstDatum[category]))
- .attr('r', 4)
- .attr(
- 'fill',
- config.colourPalette[
- categories.indexOf(category) % config.colourPalette.length
- ]
- );
- svg
- .append('circle')
- .attr('cx', x(lastDatum.date))
- .attr('cy', y(lastDatum[category]))
- .attr('r', 4)
- .attr(
- 'fill',
- config.colourPalette[
- categories.indexOf(category) % config.colourPalette.length
- ]
- );
- // console.log(`Circle appended for category: ${category}`);
-
- });
-
- // add grid lines to y axis
- svg
- .append('g')
- .attr('class', 'grid')
- .call(
- d3
- .axisLeft(y)
- .tickValues([0])
- .tickSize(-width)
- .tickFormat('')
- )
- .lower();
-
- d3.selectAll('g.tick line')
- .each(function (e) {
- if (e == 0) {
- d3.select(this).attr('class', 'zero-line');
- }
+ itemData.forEach(data => {
+ svg.append('circle')
+ .datum(data)
+ .attr('cx', d => x(d.x))
+ .attr('cy', d => y(d.y))
+ .style('fill', config.colourScheme == "categories" ?
+ config.categoryPalette[
+ categories.indexOf(category) % config.categoryPalette.length
+ ] : colourDirectionScale(direction(graphicData.find(d => d.name === category))))
+ .attr('r', 6)
+ .raise()
})
+ });
- // // Add the y-axis
- // svg
- // .append('g')
- // .attr('class', 'y axis')
- // .call(d3.axisRight(y).ticks(config.yAxisTicks[size])
- // .tickValues([])
- // .tickFormat(d3.format(config.yAxisNumberFormat)))
- // .attr('transform', "translate(" + margin.left + ", 0)");
+ createDirectLabels({
+ categories: categories,
+ data: graphicData,
+ svg: svg,
+ xScale: x,
+ yScale: y,
+ margin: margin,
+ chartHeight: height,
+ config: config,
+ options: {
+ labelStrategy: 'all',
+ minSpacing: 12,
+ useLeaderLines: true,
+ leaderLineStyle: 'dashed'
+ }
+ });
//create link to source
addSource('source', config.sourceText);
- // console.log(`Link to source created`);
//use pym to calculate chart dimensions
if (pymChild) {
pymChild.sendHeight();
}
- // console.log(`PymChild height sent`);
-}
+}
// Load the data
d3.csv(config.graphicDataURL).then((rawData) => {
- graphicData = rawData.map((d) => {
- if (d3.timeParse(config.dateFormat)(d.date) !== null) {
- return {
- date: d3.timeParse(config.dateFormat)(d.date),
- ...Object.entries(d)
- .filter(([key]) => key !== 'date')
- .map(([key, value]) => [key, +value])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
- }
- } else {
- return {
- date: (+d.date),
- ...Object.entries(d)
- .filter(([key]) => key !== 'date')
- .map(([key, value]) => [key, +value])
- .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
- }
- }
- });
-
- // console.log(graphicData);
-
- // console.log(`Data from CSV processed`);
-
- // console.log('Final data structure:');
- // console.log(graphicData);
+ graphicData = rawData
// Use pym to create an iframed chart dependent on specified variables
pymChild = new pym.Child({
renderCallback: drawGraphic
});
- // console.log(`PymChild created with renderCallback to drawGraphic`);
+
});
From b091f0241571b973d198c376ecc355f7821e3ab7 Mon Sep 17 00:00:00 2001
From: henryjameslau
Date: Fri, 9 Jan 2026 15:59:34 +0000
Subject: [PATCH 2/4] remove duplicate coralPink
---
lib/colours.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/lib/colours.js b/lib/colours.js
index ca0cf673..2fa3beb5 100644
--- a/lib/colours.js
+++ b/lib/colours.js
@@ -47,7 +47,6 @@ const ONScolours = {
leafGreenTint: "#e7f3ec",
leafGreenVibrant: "#10ca64",
leafGreenDark10: "#073d20",
- coralPink:"#F66068",
leafGreenDark5: "#0c6b37",
rubyRedTint: "#fae6e8",
rubyRedVibrant: "#fd112d",
From e5e90dd3c00dcf0f517aef03fe14d8222e859e52 Mon Sep 17 00:00:00 2001
From: henryjameslau
Date: Fri, 9 Jan 2026 16:00:46 +0000
Subject: [PATCH 3/4] Refactor slope chart + adjust helpers
---
lib/helpers.js | 332 +++++++++++++++++++++++++++++-------------
slope-chart/config.js | 23 ++-
slope-chart/data.csv | 10 +-
slope-chart/script.js | 110 +++++++++-----
4 files changed, 317 insertions(+), 158 deletions(-)
diff --git a/lib/helpers.js b/lib/helpers.js
index 3a01f6ce..dc73a362 100644
--- a/lib/helpers.js
+++ b/lib/helpers.js
@@ -51,7 +51,7 @@ export function calculateChartWidth({
// Chart width calculation allowing for {chartGap}px left margin between the charts
const chartWidth =
(screenWidth - chartMargin.left - (chartEvery - 1) * chartGap) /
- chartEvery -
+ chartEvery -
chartMargin.right;
return chartWidth;
} else {
@@ -93,8 +93,8 @@ export function addDataLabels({
? xScaleFunction(d.value)
: Math.abs(xScaleFunction(d.value) - xScaleFunction(0)) <
chartWidth / labelPositionFactor
- ? xScaleFunction(0)
- : xScaleFunction(d.value)
+ ? xScaleFunction(0)
+ : xScaleFunction(d.value)
)
.attr("dx", (d) =>
d.value > 0
@@ -107,8 +107,8 @@ export function addDataLabels({
.attr("y", (d) =>
y2function
? yScaleFunction(d.name) +
- y2function(d.category) +
- y2function.bandwidth() / 2
+ y2function(d.category) +
+ y2function.bandwidth() / 2
: yScaleFunction(d.name) + yScaleFunction.bandwidth() / 2
)
.attr("dominant-baseline", "middle")
@@ -122,7 +122,7 @@ export function addDataLabels({
)
.attr("fill", (d) =>
Math.abs(xScaleFunction(d.value) - xScaleFunction(0)) <
- chartWidth / labelPositionFactor
+ chartWidth / labelPositionFactor
? "#414042"
: "#ffffff"
)
@@ -382,6 +382,61 @@ export function wrap2(
});
}
+export function wrapStyled(text, width, lineHeightEms = 1.1) {
+ text.each(function () {
+ const textSel = d3.select(this);
+ const x = textSel.attr("x");
+ const y = textSel.attr("y");
+ let lineCount = 0;
+
+ // Extract inline tspans (styled parts)
+ const parts = [];
+ textSel.selectAll("tspan").each(function () {
+ const t = d3.select(this);
+ parts.push({
+ text: t.text(),
+ fontWeight: t.attr("font-weight") || "400",
+ dx: t.attr("dx")
+ });
+ });
+
+ textSel.text(null); // clear everything
+
+ let lineTspan = textSel.append("tspan").attr("dy", "0.35em");
+ lineCount++
+
+
+ parts.forEach(part => {
+ const words = part.text.split(/\s+/);
+
+ words.forEach((word, i) => {
+ const spacer = i === 0 ? "" : " ";
+ const testTspan = lineTspan.append("tspan")
+ .attr("font-weight", part.fontWeight)
+ .attr('dx', i == 0 ? part.dx : 0)
+ .text(spacer + word);
+
+ if (lineTspan.node().getComputedTextLength() > width) {
+ testTspan.remove();
+
+ lineTspan = textSel.append("tspan")
+ .attr("x", x)
+ .attr("dy", lineHeightEms + "em");
+ lineCount++
+
+ lineTspan.append("tspan")
+ .attr("font-weight", part.fontWeight)
+ .text(word);
+
+ }
+ });
+ });
+
+ textSel.attr("data-line-count",lineCount);
+ });
+}
+
+
//Annotations
// / arrowhead.js
@@ -1653,14 +1708,14 @@ function setupMouseHandlers(
multiHighlight
? updateHighlightAll(svgContainer, nearestPoint, state)
: updateHighlight(
- svgContainer,
- nearestPoint,
- xScale,
- yScale,
- shape,
- getSymbolSize,
- sizeScale
- );
+ svgContainer,
+ nearestPoint,
+ xScale,
+ yScale,
+ shape,
+ getSymbolSize,
+ sizeScale
+ );
updateTooltip(tooltip, nearestPoint, config);
}
@@ -2026,7 +2081,6 @@ function createCleanupFunction(tooltip, overlay, svgContainer, state) {
* @param {boolean} params.options.useLeaderLines - Whether to draw leader lines for displaced labels (default: true)
* @param {string} params.options.leaderLineStyle - Style for leader lines: 'dashed' or 'solid' (default: 'dashed')
* @param {string} params.options.labelStrategy - Strategy for positioning labels: 'last' or 'lastValid' (default: 'lastValid')
- * @param {number} params.options.minLabelOffset - Minimum pixels from chart edge for labels (default: 5)
*/
export function createDirectLabels({
categories,
@@ -2045,7 +2099,9 @@ export function createDirectLabels({
useLeaderLines = true,
leaderLineStyle = "dashed",
labelStrategy = "lastValid",
- minLabelOffset = 5,
+ includeDataValue = false,
+ valueFormatter = d3.format(",~f"),
+ colourMap = null
} = options;
// Remove any existing direct labels and leader lines before adding new ones
@@ -2075,15 +2131,39 @@ export function createDirectLabels({
* @returns {Object|null} - Object with {datum, index} or null
*/
function getLabelDataPoint(data, category, strategy) {
- if (strategy === "last") {
+ if (strategy === "firstLast") {
+ const points = [];
+
+ // Find first valid point
+ for (let i = 0; i < data.length; i++) {
+ if (data[i][category] !== null && data[i][category] !== undefined) {
+ points.push({ datum: data[i], index: i, position: 'first' });
+ break;
+ }
+ }
+
+ // Find last valid point
+ for (let i = data.length - 1; i >= 0; i--) {
+ if (data[i][category] !== null && data[i][category] !== undefined) {
+ // Only add if different from first point
+ if (points.length === 0 || points[0].index !== i) {
+ points.push({ datum: data[i], index: i, position: 'last' });
+ }
+ break;
+ }
+ }
+
+ return points;
+ } else if (strategy === "last") {
const lastDatum = data[data.length - 1];
if (lastDatum[category] !== null && lastDatum[category] !== undefined) {
- return { datum: lastDatum, index: data.length - 1 };
+ return [{ datum: lastDatum, index: data.length - 1, position: 'last' }];
}
- return null;
+ return [];
} else {
// 'lastValid'
- return findLastValidDataPoint(data, category);
+ const result = findLastValidDataPoint(data, category);
+ return result ? [{ ...result, position: "last" }] : []
}
}
@@ -2091,142 +2171,192 @@ export function createDirectLabels({
// Create all labels first and collect their data
categories.forEach(function (category, index) {
- const labelPoint = getLabelDataPoint(data, category, labelStrategy);
+ const labelPoints = getLabelDataPoint(data, category, labelStrategy);
- // Skip if no valid data point found
- if (!labelPoint) return;
+ labelPoints.forEach(labelPoint => {
+ const { datum, index: dataIndex, position } = labelPoint;
- const { datum, index: dataIndex } = labelPoint;
- const xPos = xScale(datum.date);
- const yPos = yScale(datum[category]);
+ const xPos = xScale(datum.date);
+ const yPos = yScale(datum[category]);
- // Calculate label x position - offset from data point or chart edge
- let labelX;
- if (dataIndex === data.length - 1) {
- // Last data point - place label to the right
- labelX = xPos + 10;
- } else {
- // Not the last point - check if there's room on the right
- const chartWidth = xScale.range()[1];
- const remainingSpace = chartWidth - xPos;
+ // Calculate label x position - offset from data point or chart edge
+ let labelX;
+ let anchor;
- if (remainingSpace > 60) {
- // Enough space for label
+ if (position === 'first') {
+ labelX = xPos - 10;
+ anchor = "end";
+ } else if (dataIndex === data.length - 1) {
+ // Last data point - place label to the right
labelX = xPos + 10;
+ anchor = "start"
} else {
- // Not enough space - place label to the left
- labelX = xPos - 10;
+ // Not the last point - check if there's room on the right
+ const chartWidth = xScale.range()[1];
+ const remainingSpace = chartWidth - xPos;
+
+ if (remainingSpace > 60) {
+ // Enough space for label
+ labelX = xPos + 10;
+ anchor = "start"
+ } else {
+ // Not enough space - place label to the left
+ labelX = xPos - 10;
+ anchor = "end"
+ }
}
- }
- const label = svg
- .append("text")
- .attr("class", "directLineLabel")
- .attr("x", labelX)
- .attr("y", yPos)
- .attr("dy", ".35em")
- .attr("text-anchor", labelX > xPos ? "start" : "end") // Adjust anchor based on position
- .attr(
- "fill",
- config.textColourPalette[index % config.textColourPalette.length]
- )
- .text(category)
- .call(wrap, margin.right - 10); // Use available margin space
-
- // Get the actual height of the text element after wrapping
- const bbox = label.node().getBBox();
-
- labelData.push({
- node: label,
- x: labelX,
- y: yPos,
- originalY: yPos,
- dataX: xPos, // Store the actual data point x position
- dataY: yPos,
- height: bbox.height,
- width: bbox.width,
- category: category,
- categoryIndex: index,
- dataIndex: dataIndex,
- isLastPoint: dataIndex === data.length - 1,
+ let labelText = [];
+ if (includeDataValue) {
+ const value = datum[category];
+ const formattedValue = valueFormatter(value)
+ if (position == "first") { labelText = [category, formattedValue] }
+ else { labelText = [formattedValue, category] }
+ }
+
+
+ const label = svg
+ .append("text")
+ .attr("class", "directLineLabel")
+ .attr("x", labelX)
+ .attr("y", yPos)
+ .attr("dy", ".35em")
+ .attr("text-anchor", anchor) // Adjust anchor based on position
+ .attr(
+ "fill",
+ colourMap?.get(category) ?? config.textColourPalette[index % config.textColourPalette.length]
+ )
+
+ if (includeDataValue) {
+ labelText.forEach((text, i) => {
+ label.append("tspan")
+ .attr("font-weight", position == "first" && i == 1 || position == "last" && i == 0 ? 700 : 400)
+ .attr('dx', position == 'first' && i == 1 || position == 'last' && i == 1 ? 5 : 0)
+ .text(text)
+ })
+
+ label.call(wrapStyled, position == "first" ? margin.left - 10 : margin.right - 10)
+ } else {
+ label.text(category)
+ .call(wrap, position == "first" ? margin.left - 10 : margin.right - 10); // Use available margin space
+
+ }
+
+ // Get the actual height of the text element after wrapping
+ label.node().getComputedTextLength();
+ const bbox = includeDataValue ? getHeightWidthFromTextwithNestedTspan(label) : label.node().getBBox();
+
+ labelData.push({
+ node: label,
+ x: labelX,
+ y: yPos,
+ originalY: yPos,
+ dataX: xPos, // Store the actual data point x position
+ dataY: yPos,
+ height: bbox.height,
+ width: bbox.width,
+ category: category,
+ categoryIndex: index,
+ dataIndex: dataIndex,
+ isLastPoint: dataIndex === data.length - 1,
+ position: position
+ });
});
});
- // Only run collision detection if we have multiple labels
- if (labelData.length > 1) {
- // Sort labels by their y position for easier collision detection
- labelData.sort((a, b) => a.y - b.y);
+ // Helper function to resolve collisions and draw leader lines for a set of labels
+ function processSideLabels(labels) {
+ if (labels.length <= 1) return;
- // Simple collision detection and adjustment
- for (let i = 1; i < labelData.length; i++) {
- const current = labelData[i];
- const previous = labelData[i - 1];
+ // Sort by y position
+ labels.sort((a, b) => a.y - b.y);
- // Check if current label overlaps with previous
+ for (let i = 1; i < labels.length; i++) {
+ const current = labels[i];
+ const previous = labels[i - 1];
+
+ // Check overlap
const overlap = previous.y + previous.height + minSpacing - current.y;
if (overlap > 0) {
- // Move current label down
current.y += overlap;
- // Make sure it doesn't go below chart bounds
+ // Keep within chart bounds
if (current.y + current.height > chartHeight) {
- // If it would go below, try moving previous labels up
const pushUp = current.y + current.height - chartHeight;
- // Move all previous labels up by the required amount
for (let j = i - 1; j >= 0; j--) {
- labelData[j].y -= pushUp;
- // Don't let them go above the chart
- if (labelData[j].y < 0) {
- labelData[j].y = 0;
- }
+ labels[j].y -= pushUp;
+ if (labels[j].y < 0) labels[j].y = 0;
}
- // Adjust current label to fit
current.y = chartHeight - current.height;
}
}
}
- // Apply the adjusted positions and draw leader lines if needed
- labelData.forEach((label) => {
+ // Apply positions and draw leader lines
+ labels.forEach((label) => {
label.node.attr("y", label.y);
- // Draw a leader line if the label is offset vertically from the end point and useLeaderLines is true
if (useLeaderLines && Math.abs(label.y - label.originalY) > 1) {
const strokeDashArray = leaderLineStyle === "dashed" ? "2,2" : "none";
svg
.append("line")
.attr("class", "label-leader-line")
- .attr("x1", label.dataX) // Connect to actual data point
+ .attr("x1", label.dataX)
.attr("y1", label.dataY)
- .attr("x2", label.x) // Connect to label position
+ .attr("x2", label.x)
.attr("y2", label.y)
.attr(
"stroke",
- config.colourPalette[
- label.categoryIndex % config.colourPalette.length
- ]
+ colourMap?.get(label.category) ?? config.colourPalette[label.categoryIndex % config.colourPalette.length]
)
.attr("stroke-width", 1)
.attr("stroke-dasharray", strokeDashArray);
}
});
}
+ // Filter and process each side
+ processSideLabels(labelData.filter(label => label.position === "first"));
+ processSideLabels(labelData.filter(label => label.position === "last"));
+
return labelData; // Return label data in case caller needs it
}
+export function getHeightWidthFromTextwithNestedTspan(textSel) {
+ const textNode = textSel.node();
+ const lineCount = +textSel.attr("data-line-count") || 1;
+
+ const fontSize = parseFloat(
+ window.getComputedStyle(textNode).fontSize
+ );
+
+ const lineHeightEm = 1.1; // MUST match wrap
+ const height = lineCount * fontSize * lineHeightEm;
+
+ let width = 0;
+
+ textSel.selectAll("tspan").each(function () {
+ const w = this.getComputedTextLength();
+ if (w > width) width = w;
+ });
+
+
+ return { width, height };
+}
+
+
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
- r: parseInt(result[1], 16),
- g: parseInt(result[2], 16),
- b: parseInt(result[3], 16),
- }
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16),
+ }
: null;
}
diff --git a/slope-chart/config.js b/slope-chart/config.js
index 3afa15cc..7a3b4e6a 100644
--- a/slope-chart/config.js
+++ b/slope-chart/config.js
@@ -1,8 +1,7 @@
config = {
"graphicDataURL": "data.csv",
"colourScheme":"direction", //"categories" or "direction"
- "categoryPalette": ONSlinePalette,
- "directionPalette": ONScolours.diverging.negativeToPositiveAlt["3"],
+ "colourPalette":[ONScolours.coralPink,ONScolours.grey30,ONScolours.oceanBlue],
"textColourPalette": ONStextPalette,
"sourceText": "Office for National Statistics",
"accessibleSummary": "The chart canvas is hidden from screen readers. The main message is summarised by the chart title and the data behind the chart is available to download below.",
@@ -16,28 +15,28 @@ config = {
},
"xAxisLabels": ["Q1 2018","Q1 2021"],
"aspectRatio": {
- "sm": [1, 1],
- "md": [1, 1],
- "lg": [1, 1]
+ "sm": [1, 2],
+ "md": [1, 2],
+ "lg": [1, 2]
},
"margin": {
"sm": {
"top": 30,
"right": 0,
- "bottom": 50,
+ "bottom": 10,
"left": 0
},
"md": {
"top": 30,
- "right": 100,
- "bottom": 50,
- "left": 80
+ "right": 0,
+ "bottom": 10,
+ "left": 0
},
"lg": {
"top": 30,
- "right": 100,
- "bottom": 50,
- "left": 60
+ "right": 0,
+ "bottom": 10,
+ "left": 0
}
},
};
diff --git a/slope-chart/data.csv b/slope-chart/data.csv
index b3c8083f..f4d625ec 100644
--- a/slope-chart/data.csv
+++ b/slope-chart/data.csv
@@ -1,6 +1,6 @@
name,leftValue,rightValue
-Germany:,17.025,12.468
-United States:,9.099,7.582
-Netherlands:,10.283,7.093
-France:,7.1,5.166
-China:,10.224,16.928
+Germany,17.025,12.468
+United States,9.099,7.582
+Netherlands,10.283,7.093
+France,7.1,5.166
+China,10.224,16.928
diff --git a/slope-chart/script.js b/slope-chart/script.js
index 0144da4d..111471ab 100644
--- a/slope-chart/script.js
+++ b/slope-chart/script.js
@@ -14,26 +14,32 @@ function drawGraphic() {
// Define the dimensions and margin, width and height of the chart.
let margin = config.margin[size];
+ if (size == "sm") {
+ margin.left = 50
+ margin.right = parseInt(graphic.style('width')) - 230;
+ } else {
+ let slopeMargin = parseInt(graphic.style('width')) / 2 - 90;
+
+ margin.left = slopeMargin
+ margin.right = slopeMargin
+ }
+
+
let chartWidth = parseInt(graphic.style('width')) - margin.left - margin.right;
let height = (aspectRatio[1] / aspectRatio[0]) * chartWidth;
const columns = graphicData.columns.slice(1)
- const startPoint = size == "sm" ? 50 : chartWidth / 2 - 90;
- const endPoint = size == "sm" ? chartWidth - 160 : chartWidth / 2 + 90;
-
let x = d3.scalePoint().domain(columns)
- .range([startPoint, endPoint])
+ .range([0, chartWidth])
let xLabels = d3.scalePoint().domain(config.xAxisLabels)
- .range([startPoint, endPoint]);
-
+ .range([0, chartWidth])
const y = d3
.scaleLinear()
.range([height, 0]);
-
if (config.showZeroAxis) {
y.domain([0, d3.max(graphicData.flatMap(d => columns.map(col => Number(d[col]))))]);
} else {
@@ -59,33 +65,31 @@ function drawGraphic() {
function direction(data) {
if (+data[columns[0]] > +data[columns[columns.length - 1]]) {
- return "decreasing"
+ return 0
} else if (+data[columns[columns.length - 1]] > +data[columns[0]]) {
- return "increasing"
+ return 2
} else if (+data[columns[0]] == +data[columns[columns.length - 1]]) {
- return "same"
+ return 1
}
}
- const colourDirectionScale = d3.scaleOrdinal().domain(["decreasing", "same", "increasing"]).range(config.directionPalette)
-
-
+ // const colourDirectionScale = d3.scaleOrdinal().domain(["decreasing", "same", "increasing"]).range(config.colourPalette)
// add grid lines to y axis
if (config.showZeroAxis) {
const zeroAxis = svg
.append('g')
- .attr('transform', "translate(" + (startPoint - 20) + ",0)")
+ .attr('transform', "translate(" + -20 + ",0)")
.attr('class', 'y axis')
.call(
d3
.axisLeft(y)
.tickValues([0])
- .tickSize(-(endPoint - startPoint) - 40)
+ .tickSize(-220)
// .tickFormat('')
)
- svg.append('g').attr('transform', "translate(" + (endPoint + 20) + ",0)")
+ svg.append('g').attr('transform', "translate(" + (chartWidth + 20) + ",0)")
.attr('class', 'y axis')
.call(d3.axisRight(y).tickValues([0]).tickSize(0))
@@ -115,6 +119,48 @@ function drawGraphic() {
xAxis.selectAll(".tick line")
.style("stroke", ONScolours.grey40)
+ const transposeData = columns.map(key => {
+ const newObj = { date: key };
+
+ graphicData.forEach(item => {
+ newObj[item.name] = +item[key];
+ });
+
+ return newObj;
+ })
+
+ const colourMap = new Map();
+ categories.forEach(category => {
+ colourMap.set(category, config.colourScheme === "categories"
+ ? config.colourPalette[
+ categories.indexOf(category) % config.colourPalette.length
+ ]
+ : config.colourPalette[
+ direction(graphicData.find(d => d.name === category))
+ ]
+ );
+ })
+
+ createDirectLabels({
+ categories: categories,
+ data: transposeData,
+ svg: svg,
+ xScale: x,
+ yScale: y,
+ margin: margin,
+ chartHeight: height,
+ config: config,
+ options: {
+ labelStrategy: size == "sm" ? 'last' : 'firstLast',
+ minSpacing: 12,
+ useLeaderLines: true,
+ leaderLineStyle: 'dashed',
+ colourMap:colourMap,
+ includeDataValue:true
+ }
+ });
+
+
// create lines and circles for each category
categories.forEach(function (category, index) {
@@ -129,10 +175,9 @@ function drawGraphic() {
.datum(itemData)
.attr('fill', 'none')
.attr(
- 'stroke', config.colourScheme == "categories" ?
- config.categoryPalette[
- categories.indexOf(category) % config.categoryPalette.length
- ] : colourDirectionScale(direction(graphicData.find(d => d.name === category)))
+ 'stroke', config.colourScheme == "categories"
+ ? config.colourPalette[categories.indexOf(category) % config.colourPalette.length]
+ : config.colourPalette[direction(graphicData.find(d => d.name === category))]
)
.attr('stroke-width', 2.5)
.attr('d', lineGenerator)
@@ -144,31 +189,16 @@ function drawGraphic() {
.datum(data)
.attr('cx', d => x(d.x))
.attr('cy', d => y(d.y))
- .style('fill', config.colourScheme == "categories" ?
- config.categoryPalette[
- categories.indexOf(category) % config.categoryPalette.length
- ] : colourDirectionScale(direction(graphicData.find(d => d.name === category))))
+ .style('fill', config.colourScheme == "categories"
+ ? config.colourPalette[categories.indexOf(category) % config.colourPalette.length]
+ : config.colourPalette[direction(graphicData.find(d => d.name === category))]
+ )
.attr('r', 6)
.raise()
})
});
- createDirectLabels({
- categories: categories,
- data: graphicData,
- svg: svg,
- xScale: x,
- yScale: y,
- margin: margin,
- chartHeight: height,
- config: config,
- options: {
- labelStrategy: 'all',
- minSpacing: 12,
- useLeaderLines: true,
- leaderLineStyle: 'dashed'
- }
- });
+
//create link to source
addSource('source', config.sourceText);
From e2193aa86fe78a551267306e1de13e0032d8154b Mon Sep 17 00:00:00 2001
From: Henry Lau
Date: Thu, 12 Feb 2026 16:03:04 +0000
Subject: [PATCH 4/4] Resolve conflicts
---
bar-chart-with-dropdown/script.js | 24 +++++++++++++++++++-----
lib/helpers.js | 3 ++-
2 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/bar-chart-with-dropdown/script.js b/bar-chart-with-dropdown/script.js
index f7fabe55..6157c812 100644
--- a/bar-chart-with-dropdown/script.js
+++ b/bar-chart-with-dropdown/script.js
@@ -1,4 +1,4 @@
-import { initialise, wrap, addSvg, addDataLabels, addAxisLabel, addSource } from "../lib/helpers.js";
+import { initialise, wrap, addSvg, addDataLabels, addAxisLabel, addSource, getDataLabelX } from "../lib/helpers.js";
let graphic = d3.select('#graphic');
let select = d3.select('#select');
@@ -97,7 +97,15 @@ function drawGraphic() {
// Store the current positions of the labels in the map
svg.selectAll('text.dataLabels').each(function (d) {
- labelPositions.set(d.name, x(d.value));
+ labelPositions.set(
+ d.name,
+ getDataLabelX({
+ datum: d,
+ chartWidth: chartWidth,
+ labelPositionFactor: 7,
+ xScaleFunction: x,
+ })
+ );
});
// Enter and update
@@ -143,14 +151,20 @@ function drawGraphic() {
.duration(1200)
.ease(d3.easeCubic)
.tween('text', function (d) {
- // Parse this.textContent as a float and multiply it by 0.001 to get the start value. This need to match the data.
+ // Parse this.textContent as a float and multiply it by 0.001 to get the start value. This needs to match the data.
let startValue = parseFloat(this.textContent) * 0.001;
// Create an interpolator
const i = d3.interpolate(startValue, d.value);
- // Create a position interpolator
- const xi = d3.interpolate(labelPositions.get(d.name) || x(0), x(d.value) - (x(d.value) - x(0) < chartWidth / 10 ? -3 : 3));
+ // Create a position interpolator based on the helper label positions
+ const targetX = getDataLabelX({
+ datum: d,
+ chartWidth: chartWidth,
+ labelPositionFactor: 7,
+ xScaleFunction: x,
+ });
+ const xi = d3.interpolate(labelPositions.get(d.name) || x(0), targetX);
return function (t) {
// Calculate the interpolated value
diff --git a/lib/helpers.js b/lib/helpers.js
index 73296623..af3898a8 100644
--- a/lib/helpers.js
+++ b/lib/helpers.js
@@ -21,7 +21,8 @@ export function calculateAutoBounds(graphicData, config) {
// Get categories from the data, excluding date and series columns (includes CI columns)
const categories = Object.keys(graphicData[0]).filter((k) =>
k !== 'date' &&
- k !== 'series'
+ k !== 'series' &&
+ k !== 'name'
);
// Calculate data min/max across all categories