diff --git a/bar-chart-with-dropdown/script.js b/bar-chart-with-dropdown/script.js index df8aa855..13b867f3 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/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/lib/colours.js b/lib/colours.js index 96ba0bef..5d892f40 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", diff --git a/lib/helpers.js b/lib/helpers.js index f683eb6f..a603d34b 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 @@ -179,24 +180,18 @@ export function addDataLabels({ const isSmall = d => Math.abs(xScaleFunction(d.value) - x0) < threshold; - const padding = 4; - - const barStart = d => - Math.min(xScaleFunction(0), xScaleFunction(d.value)); - - const barEnd = d => - Math.max(xScaleFunction(0), xScaleFunction(d.value)); - svgContainer .selectAll("text.dataLabels") .data(data) .join('text') .attr('class', 'dataLabels') - .attr('x', d => isSmall(d) ? x0 : xScaleFunction(d.value)) .attr('x', d => - isSmall(d) - ? (d.value > 0 ? barEnd(d) + padding : barStart(d) - padding) - : (d.value > 0 ? barEnd(d) - padding : barStart(d) + padding) + getDataLabelX({ + datum: d, + chartWidth, + labelPositionFactor, + xScaleFunction, + }) ) .attr('y', d => y2function @@ -234,6 +229,25 @@ export function addDataLabels({ ); } +export function getDataLabelX({ + datum, + chartWidth, + labelPositionFactor = 7, + xScaleFunction = x, +}) { + const x0 = xScaleFunction(0); + const threshold = chartWidth / labelPositionFactor; + const isSmall = + Math.abs(xScaleFunction(datum.value) - x0) < threshold; + const padding = 4; + const barStart = Math.min(x0, xScaleFunction(datum.value)); + const barEnd = Math.max(x0, xScaleFunction(datum.value)); + + return isSmall + ? (datum.value > 0 ? barEnd + padding : barStart - padding) + : (datum.value > 0 ? barEnd - padding : barStart + padding); +} + export function addDataLabelsVertical({ svgContainer = svg, data, @@ -628,6 +642,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 // ========== UTILITY FUNCTIONS ========== @@ -3458,6 +3527,9 @@ function createCleanupFunction(tooltip, overlay, svgContainer, state, instructio * @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) * @param {number} params.options.xOffset - Horizontal offset applied to all direct labels (default: 0) + * @param {boolean} params.options.includeDataValue - Whether to include data values in labels (default: false) + * @param {Function} params.options.valueFormatter - Formatter for data values (default: d3.format(",~f")) + * @param {Object|null} params.options.colourMap - Map of categories to line colors (default: null) */ export function createDirectLabels({ categories, @@ -3478,6 +3550,9 @@ export function createDirectLabels({ labelStrategy = "lastValid", minLabelOffset = 5, xOffset = 0, + includeDataValue = false, + valueFormatter = d3.format(",~f"), + colourMap = null } = options; // Remove any existing direct labels and leader lines before adding new ones @@ -3507,15 +3582,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" }] : [] } } @@ -3523,135 +3622,186 @@ 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]+margin.right; - 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"; + } } - } - labelX += xOffset; - - const lineColor = config.colourPalette[index % config.colourPalette.length]; - const textColor = adjustColorForContrast(lineColor, 4.5); - 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", textColor) - .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 lineColor = config.colourPalette[index % config.colourPalette.length]; + const textColor = adjustColorForContrast(lineColor, 4.5); + 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) ?? textColor + ) + .text(category) + + 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 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 051991fc..967a5ffe 100644 --- a/slope-chart/chart.css +++ b/slope-chart/chart.css @@ -4,8 +4,7 @@ .axis line { stroke: none; /* Changes the color of the axis lines */ } - - + .directLineLabelBold { font-size: 14px; font-weight: 700; diff --git a/slope-chart/config.js b/slope-chart/config.js index f72117e2..ebcd6557 100644 --- a/slope-chart/config.js +++ b/slope-chart/config.js @@ -1,50 +1,38 @@ config = { "graphicDataURL": "data.csv", - "colourPalette": ONSlinePalette, + "colourScheme":"direction", //"categories" or "direction" + "colourPalette":[ONScolours.coralPink,ONScolours.grey30,ONScolours.oceanBlue], "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", "yDomainMin": "auto", "yDomainMax": "auto", - // yDomainMin and yDomainMax can be "auto", "data", or a numeric value - "xAxisTickFormat": { - "sm": "%b %y", - "md": "%b %y", - "lg": "%B %Y" - }, - "xAxisNumberFormat": ".0f", - "yAxisNumberFormat": ",.0f", - "dateFormat": "%d-%m-%Y", - // default is 75 - "chartHeight": { - "sm": 350, - "md": 350, - "lg": 350 + // yDomainMin and yDomainMax can be "auto", "data", or a numeric value + "showZeroAxis":true, + "yAxisLabel": "y axis label", + "yAxisTicks":{ + "sm": 5, + "md": 5, + "lg": 5 }, - "chartWidth": { - "sm": 75, - "md": 75, - "lg": 75 + "lineCurveType": "curveLinear", + "xAxisLabels": ["Q1 2018","Q1 2021"], + "aspectRatio": { + "sm": [1, 2], + "md": [1, 2], + "lg": [1, 2] }, - "margin": { + "margin": {//left and right are set by the script "sm": { "top": 30, - "right": 0, //Not needed - right margin calculated from chartwidth etc. - "bottom": 25, - "left": 70 + "bottom": 10, }, "md": { "top": 30, - "right": 0, //Not needed - right margin calculated from chartwidth etc. - "bottom": 15, - "left": 70 + "bottom": 10, }, "lg": { "top": 30, - "right": 0, //Not needed - right margin calculated from chartwidth etc. - "bottom": 15, - "left": 70 + "bottom": 10, } }, - "elements": { "select": 0, "nav": 0, "legend": 1, "titles": 0 } }; diff --git a/slope-chart/data.csv b/slope-chart/data.csv index 991c234f..f4d625ec 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 @@
- +