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