From f03a6a6416727cdf0f336cb600a9a0d6ca029896 Mon Sep 17 00:00:00 2001 From: sam-ctrl Date: Wed, 21 Jan 2026 16:15:25 +0000 Subject: [PATCH 1/5] Direct label leader lines changed to elbows instead of straight lines --- lib/helpers.js | 409 +++++++++++++++++++++----- line-chart-dropdown-options/config.js | 26 ++ line-chart-dropdown-options/script.js | 19 +- line-chart-with-ci-area/config.js | 27 ++ line-chart-with-ci-area/script.js | 17 +- line-chart/config.js | 27 ++ line-chart/data.csv | 2 +- line-chart/script.js | 16 +- 8 files changed, 461 insertions(+), 82 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index c8d0214e..68c64bec 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -3059,7 +3059,13 @@ function createCleanupFunction(tooltip, overlay, svgContainer, state) { * @param {number} params.options.minSpacing - Minimum spacing between labels (default: 12) * @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 {string} params.options.leaderLineColourMode - 'series' to match series colour or 'mono' for a single colour (default: 'series') + * @param {string} params.options.leaderLineMonoColour - Colour used when leaderLineColourMode is 'mono' (default: '#707070') + * @param {number} params.options.leaderLineElbowOffset - Horizontal offset from the data point to the vertical leader segment (default: 10) + * @param {number} params.options.leaderLineEndGap - Small gap before the label edge where the leader terminates (default: 2) + * @param {number} params.options.labelGap - Horizontal gap (px) between the series endpoint and the direct label anchor (default: 10) + * @param {number} params.options.labelGapWithLeaderLines - Horizontal gap (px) for labels that require leader lines (default: labelGap) + * @param {string} params.options.labelStrategy - Strategy for positioning labels: 'last', 'lastValid', or 'lastValidRight' (default: 'lastValid') * @param {number} params.options.minLabelOffset - Minimum pixels from chart edge for labels (default: 5) */ export function createDirectLabels({ @@ -3078,13 +3084,90 @@ export function createDirectLabels({ minSpacing = 12, useLeaderLines = true, leaderLineStyle = "dashed", + leaderLineColourMode = "series", + leaderLineMonoColour = "#707070", + leaderLineElbowOffset = 10, + leaderLineEndGap = 2, + labelGap = 10, + labelGapWithLeaderLines = labelGap, labelStrategy = "lastValid", minLabelOffset = 5, } = options; // Remove any existing direct labels and leader lines before adding new ones svg.selectAll("text.directLineLabel").remove(); - svg.selectAll("line.label-leader-line").remove(); + svg.selectAll(".label-leader-line").remove(); + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + function getLeaderStroke(label) { + if (leaderLineColourMode === "mono") return leaderLineMonoColour; + return config.colourPalette[label.categoryIndex % config.colourPalette.length]; + } + + function getLabelEdgeXFromBBox(labelSelection, bbox) { + const anchor = labelSelection.attr("text-anchor") || "start"; + return anchor === "end" ? bbox.x + bbox.width : bbox.x; + } + + function updateLabelY(labelSelection, newY) { + labelSelection.attr("y", newY); + // If the label is multi-line (tspans), keep them aligned when y changes + labelSelection.selectAll("tspan").attr("y", newY); + } + + function getRightBoundX() { + return xScale.range()[1] + (margin?.right ?? 0) - minLabelOffset; + } + + function getLeftBoundX() { + return minLabelOffset; + } + + function getMinWrapWidth() { + return 30; + } + + function clampLabelXForWrap(anchor, x) { + const leftBound = getLeftBoundX(); + const rightBound = getRightBoundX(); + const minWrapWidth = getMinWrapWidth(); + + if (anchor === "end") { + // 'end' anchor means the text extends left from x + return clamp(x, leftBound + minWrapWidth, rightBound); + } + // 'start' anchor means the text extends right from x + return clamp(x, leftBound, rightBound - minWrapWidth); + } + + function getWrapWidth(anchor, x) { + const leftBound = getLeftBoundX(); + const rightBound = getRightBoundX(); + const minWrapWidth = getMinWrapWidth(); + + const width = anchor === "end" ? x - leftBound : rightBound - x; + return Math.max(minWrapWidth, width); + } + + function applyWrapAndMeasure(labelSelection, labelText, x, y) { + const anchor = labelSelection.attr("text-anchor") || "start"; + const clampedX = clampLabelXForWrap(anchor, x); + const wrapWidth = getWrapWidth(anchor, clampedX); + + labelSelection.attr("x", clampedX).attr("y", y); + labelSelection.selectAll("tspan").remove(); + labelSelection.text(labelText); + labelSelection.call(wrap2, wrapWidth, 0, 1.1, 1, true, "middle"); + + return { + x: clampedX, + bbox: labelSelection.node().getBBox(), + wrapWidth, + }; + } /** * Find the last valid (non-null, non-undefined) data point for a category @@ -3105,7 +3188,7 @@ export function createDirectLabels({ * Get the appropriate data point for labeling based on strategy * @param {Array} data - Array of data objects * @param {string} category - Category name - * @param {string} strategy - 'last' or 'lastValid' + * @param {string} strategy - 'last', 'lastValid', or 'lastValidRight' * @returns {Object|null} - Object with {datum, index} or null */ function getLabelDataPoint(data, category, strategy) { @@ -3116,7 +3199,7 @@ export function createDirectLabels({ } return null; } else { - // 'lastValid' + // 'lastValid' (and variants that still use last-valid selection) return findLastValidDataPoint(data, category); } } @@ -3134,22 +3217,32 @@ export function createDirectLabels({ const xPos = xScale(datum.date); const yPos = yScale(datum[category]); + const chartWidth = xScale.range()[1]; + const endedEarly = dataIndex !== data.length - 1; + // Calculate label x position - offset from data point or chart edge let labelX; - if (dataIndex === data.length - 1) { + let placementMode; // 'right', 'left', or 'rightMargin' + if (labelStrategy === "lastValidRight" && endedEarly) { + // If a line ends early, place its label in the right margin and connect via the chart edge. + labelX = chartWidth + labelGap; + placementMode = "rightMargin"; + } else if (dataIndex === data.length - 1) { // Last data point - place label to the right - labelX = xPos + 10; + labelX = xPos + labelGap; + placementMode = "right"; } else { // 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; + labelX = xPos + labelGap; + placementMode = "right"; } else { // Not enough space - place label to the left - labelX = xPos - 10; + labelX = xPos - labelGap; + placementMode = "left"; } } @@ -3160,18 +3253,19 @@ export function createDirectLabels({ .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("dominant-baseline", "middle") + .attr("text-anchor", placementMode === "left" ? "end" : "start") .attr("fill", textColor) - .text(category) - .call(wrap, margin.right - 10); // Use available margin space + .text(category); + + const wrapped = applyWrapAndMeasure(label, category, labelX, yPos); // Get the actual height of the text element after wrapping - const bbox = label.node().getBBox(); + const bbox = wrapped.bbox; labelData.push({ node: label, - x: labelX, + x: wrapped.x, y: yPos, originalY: yPos, dataX: xPos, // Store the actual data point x position @@ -3179,75 +3273,258 @@ export function createDirectLabels({ height: bbox.height, width: bbox.width, category: category, + labelText: category, categoryIndex: index, dataIndex: dataIndex, isLastPoint: dataIndex === data.length - 1, + endedEarly: endedEarly, + placementMode: placementMode, }); }); - // 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); - - // Simple collision detection and adjustment - for (let i = 1; i < labelData.length; i++) { - const current = labelData[i]; - const previous = labelData[i - 1]; - - // Check if current label overlaps with previous - 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 - 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; - } - } + function forceLeaderLineForLabel(label) { + return labelStrategy === "lastValidRight" && label.endedEarly === true; + } + + function needsLeaderLineForLabel(label) { + // Any label in a collision cluster should show a leader line for consistency + // (e.g. when three labels are packed, the middle one also gets a leader line). + if (label.inCluster) return true; + + // Otherwise, only when vertically displaced… + if (Math.abs(label.y - label.originalY) > 1) return true; + + // …or when 'lastValidRight' intentionally routes to the chart edge. + return forceLeaderLineForLabel(label); + } + + function shouldUseGapWithLeaderLines(label) { + // Larger gap is used for labels that are actually displaced / clustered. + // For 'lastValidRight' early-ending series, do NOT apply the larger gap + // unless the label is also being moved to accommodate other labels. + return label.inCluster || Math.abs(label.y - label.originalY) > 1; + } + + function desiredXForLabel(label) { + const gap = shouldUseGapWithLeaderLines(label) ? labelGapWithLeaderLines : labelGap; + + if (label.placementMode === "rightMargin") { + return xScale.range()[1] + gap; + } + if (label.placementMode === "left") { + return label.dataX - gap; + } + return label.dataX + gap; + } + + function layoutLabelYPositions() { + if (labelData.length <= 1) return; + + // Reset cluster flags + for (const label of labelData) { + label.inCluster = false; + } + + // Sort labels by original y for deterministic grouping + const sorted = [...labelData].sort((a, b) => a.originalY - b.originalY); + + // Build overlap clusters (based on original positions) + const clusters = []; + let currentCluster = []; + let clusterBottom = -Infinity; - // Adjust current label to fit - current.y = chartHeight - current.height; + for (const label of sorted) { + const top = label.originalY - label.height / 2; + const bottom = label.originalY + label.height / 2; + + if (currentCluster.length === 0) { + currentCluster = [label]; + clusterBottom = bottom; + continue; + } + + const needsCluster = top < clusterBottom + minSpacing; + if (needsCluster) { + currentCluster.push(label); + clusterBottom = Math.max(clusterBottom, bottom); + } else { + clusters.push(currentCluster); + currentCluster = [label]; + clusterBottom = bottom; + } + } + + if (currentCluster.length) clusters.push(currentCluster); + + // Compute adjusted y positions per cluster, aiming for symmetric offsets + for (const cluster of clusters) { + if (cluster.length <= 1) continue; + + // Mark all labels in this cluster + for (const label of cluster) { + label.inCluster = true; + } + + const n = cluster.length; + const newYs = new Array(n); + + if (n % 2 === 1) { + // Odd: keep the middle label aligned to its point + const mid = Math.floor(n / 2); + newYs[mid] = cluster[mid].originalY; + + // Place above the middle + for (let i = mid - 1; i >= 0; i--) { + const below = cluster[i + 1]; + const gap = cluster[i].height / 2 + below.height / 2 + minSpacing; + newYs[i] = newYs[i + 1] - gap; + } + + // Place below the middle + for (let i = mid + 1; i < n; i++) { + const above = cluster[i - 1]; + const gap = cluster[i].height / 2 + above.height / 2 + minSpacing; + newYs[i] = newYs[i - 1] + gap; + } + } else { + // Even: center between the two middle labels + const left = n / 2 - 1; + const right = n / 2; + const anchor = (cluster[left].originalY + cluster[right].originalY) / 2; + const midGap = + cluster[left].height / 2 + cluster[right].height / 2 + minSpacing; + + newYs[left] = anchor - midGap / 2; + newYs[right] = anchor + midGap / 2; + + for (let i = left - 1; i >= 0; i--) { + const below = cluster[i + 1]; + const gap = cluster[i].height / 2 + below.height / 2 + minSpacing; + newYs[i] = newYs[i + 1] - gap; + } + + for (let i = right + 1; i < n; i++) { + const above = cluster[i - 1]; + const gap = cluster[i].height / 2 + above.height / 2 + minSpacing; + newYs[i] = newYs[i - 1] + gap; } } + + // Shift whole cluster into bounds, preserving spacing + let minTop = Infinity; + let maxBottom = -Infinity; + for (let i = 0; i < n; i++) { + minTop = Math.min(minTop, newYs[i] - cluster[i].height / 2); + maxBottom = Math.max(maxBottom, newYs[i] + cluster[i].height / 2); + } + + let shift = 0; + if (minTop < 0) shift = -minTop; + if (maxBottom + shift > chartHeight) + shift -= maxBottom + shift - chartHeight; + + for (let i = 0; i < n; i++) { + cluster[i].y = clamp( + newYs[i] + shift, + cluster[i].height / 2, + chartHeight - cluster[i].height / 2 + ); + } + } + } + + // Layout pass 1: compute y positions based on initial wrapping + layoutLabelYPositions(); + + // If leader-line labels use a bigger gap, apply it now and rewrap with updated widths + // (then run layout again in case label heights changed due to wrapping). + let anyRewrapped = false; + for (const label of labelData) { + const newX = desiredXForLabel(label); + if (Math.abs(newX - label.x) > 0.5) { + const wrapped = applyWrapAndMeasure(label.node, label.labelText, newX, label.originalY); + label.x = wrapped.x; + label.width = wrapped.bbox.width; + label.height = wrapped.bbox.height; + anyRewrapped = true; } + } + + if (anyRewrapped) { + layoutLabelYPositions(); + } + + // Apply the final positions and draw orthogonal leader lines if needed + labelData.forEach((label) => { + updateLabelY(label.node, label.y); + + if (useLeaderLines && needsLeaderLineForLabel(label)) { + const strokeDashArray = leaderLineStyle === "dashed" ? "2,2" : "none"; + const bbox = label.node.node().getBBox(); + const labelEdgeX = getLabelEdgeXFromBBox(label.node, bbox); + const labelCenterY = bbox.y + bbox.height / 2; + + const dir = labelEdgeX >= label.dataX ? 1 : -1; + + // For early-ending series in 'lastValidRight', route the leader via the chart's right edge. + // Otherwise, use a small elbow offset from the data point. + const preferredVerticalX = + labelStrategy === "lastValidRight" && label.endedEarly === true + ? xScale.range()[1] + : null; - // Apply the adjusted positions and draw leader lines if needed - labelData.forEach((label) => { - label.node.attr("y", label.y); + const endX = labelEdgeX - dir * leaderLineEndGap; - // 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"; + // Choose where the vertical segment sits. + // For most cases, place it around the midpoint between the point and label edge so the two + // horizontal segments are visually more symmetrical. Enforce a minimum offset from the point + // so we don't get a tiny first segment. + let elbowX; + if (preferredVerticalX != null) { + elbowX = preferredVerticalX; + } else { + const midpointX = (label.dataX + endX) / 2; + const minFromPoint = label.dataX + dir * leaderLineElbowOffset; + + if (dir === 1) { + elbowX = Math.max(minFromPoint, Math.min(midpointX, endX)); + } else { + elbowX = Math.min(minFromPoint, Math.max(midpointX, endX)); + } + } + + // Special case: lastValidRight early-ending series that are not displaced/clustered. + // Draw a single horizontal leader (no vertical segment) to avoid a tiny visual "kink". + const isUndisplaced = Math.abs(label.y - label.originalY) <= 1; + const simpleHorizontalLeader = + labelStrategy === "lastValidRight" && + label.endedEarly === true && + label.inCluster !== true && + isUndisplaced; + if (simpleHorizontalLeader) { svg - .append("line") + .append("path") .attr("class", "label-leader-line") - .attr("x1", label.dataX) // Connect to actual data point - .attr("y1", label.dataY) - .attr("x2", label.x) // Connect to label position - .attr("y2", label.y) - .attr( - "stroke", - config.colourPalette[ - label.categoryIndex % config.colourPalette.length - ] - ) + .attr("d", `M${label.dataX},${label.dataY} H${endX}`) + .attr("fill", "none") + .attr("stroke", getLeaderStroke(label)) .attr("stroke-width", 1) .attr("stroke-dasharray", strokeDashArray); + return; } - }); - } + + // Leader: horizontal -> vertical -> horizontal + svg + .append("path") + .attr("class", "label-leader-line") + .attr("d", `M${label.dataX},${label.dataY} H${elbowX} V${labelCenterY} H${endX}`) + .attr("fill", "none") + .attr("stroke", getLeaderStroke(label)) + .attr("stroke-width", 1) + .attr("stroke-dasharray", strokeDashArray); + } + }); return labelData; // Return label data in case caller needs it } diff --git a/line-chart-dropdown-options/config.js b/line-chart-dropdown-options/config.js index 34206962..dd155168 100644 --- a/line-chart-dropdown-options/config.js +++ b/line-chart-dropdown-options/config.js @@ -2,6 +2,32 @@ config = { "graphicDataURL": "data.csv", "colourPalette": ONSlinePalette, "drawLegend": false, + // Direct labels (used when drawLegend=false and size != 'sm') + // These options are passed into lib/helpers.js:createDirectLabels(). + "directLabels": { + // Horizontal gap (px) from the series endpoint to the label anchor + "gap": 10, + // Horizontal gap (px) for labels that need leader lines (i.e. labels that are in a collision cluster + // or get vertically displaced) + "gapWithLeaderLines": 16, + // Minimum vertical spacing (px) between adjacent labels before they are offset + "minSpacing": 15, + // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' + "labelStrategy": "lastValid", + // Leader lines appear only when a label is vertically displaced + "useLeaderLines": true, + // 'dashed' or 'solid' + "leaderLineStyle": "dashed", + // 'series' (match series colour) or 'mono' (single colour) + "leaderLineColourMode": "series", + // Used when leaderLineColourMode is 'mono' + "leaderLineMonoColour": "#707070", + // Geometry tuning (px) + "leaderLineElbowOffset": 10, + "leaderLineEndGap": 2, + // Minimum pixels from chart edge for labels + "minLabelOffset": 5 + }, "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", // Set the default line curve type diff --git a/line-chart-dropdown-options/script.js b/line-chart-dropdown-options/script.js index 96818b7f..86c1bcea 100644 --- a/line-chart-dropdown-options/script.js +++ b/line-chart-dropdown-options/script.js @@ -268,7 +268,7 @@ function drawGraphic() { .style('opacity', 0) .remove(); - svg.selectAll('line.label-leader-line') + svg.selectAll('.label-leader-line') .transition() .duration(300) .style('opacity', 0) @@ -300,6 +300,7 @@ function drawGraphic() { return d[0]; }); } else { + const directLabels = config.directLabels || {}; createDirectLabels({ categories: categories, data: filteredData, @@ -310,11 +311,17 @@ function drawGraphic() { chartHeight: height, config: config, options: { - labelStrategy: 'lastValid', - minSpacing: 15, - useLeaderLines: true, - leaderLineStyle: 'dashed', - minLabelOffset: 5 + labelStrategy: directLabels.labelStrategy ?? 'lastValid', + minSpacing: directLabels.minSpacing ?? 15, + minLabelOffset: directLabels.minLabelOffset ?? 5, + labelGap: directLabels.gap ?? 10, + labelGapWithLeaderLines: directLabels.gapWithLeaderLines ?? (directLabels.gap ?? 10), + useLeaderLines: directLabels.useLeaderLines ?? true, + leaderLineStyle: directLabels.leaderLineStyle ?? 'dashed', + leaderLineColourMode: directLabels.leaderLineColourMode ?? 'series', + leaderLineMonoColour: directLabels.leaderLineMonoColour ?? '#707070', + leaderLineElbowOffset: directLabels.leaderLineElbowOffset ?? 10, + leaderLineEndGap: directLabels.leaderLineEndGap ?? 2 } }); } diff --git a/line-chart-with-ci-area/config.js b/line-chart-with-ci-area/config.js index 6f9ad922..df7e41b4 100644 --- a/line-chart-with-ci-area/config.js +++ b/line-chart-with-ci-area/config.js @@ -2,6 +2,33 @@ config = { "graphicDataURL": "datanumeric.csv", "colourPalette": ONSlinePalette, "drawLegend": false, + // Direct labels (used when drawLegend=false and size != 'sm') + // These options are passed into lib/helpers.js:createDirectLabels(). + "directLabels": { + // Horizontal gap (px) from the series endpoint to the label anchor + "gap": 10, + // Horizontal gap (px) for labels that need leader lines (i.e. labels that are in a collision cluster + // or get vertically displaced) + "gapWithLeaderLines": 16, + // Minimum vertical spacing (px) between adjacent labels before they are offset + // Note: this chart previously used 0 to allow tight packing. + "minSpacing": 0, + // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' + "labelStrategy": "lastValid", + // Leader lines appear only when a label is vertically displaced + "useLeaderLines": true, + // 'dashed' or 'solid' + "leaderLineStyle": "dashed", + // 'series' (match series colour) or 'mono' (single colour) + "leaderLineColourMode": "series", + // Used when leaderLineColourMode is 'mono' + "leaderLineMonoColour": "#707070", + // Geometry tuning (px) + "leaderLineElbowOffset": 10, + "leaderLineEndGap": 2, + // Minimum pixels from chart edge for labels + "minLabelOffset": 5 + }, "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", // Set the default line curve type diff --git a/line-chart-with-ci-area/script.js b/line-chart-with-ci-area/script.js index 05869fdb..8d958a27 100644 --- a/line-chart-with-ci-area/script.js +++ b/line-chart-with-ci-area/script.js @@ -197,6 +197,7 @@ function drawGraphic() { }); if (!config.drawLegend && size !== 'sm') { + const directLabels = config.directLabels || {}; createDirectLabels({ categories: categories, data: graphicData, @@ -207,11 +208,17 @@ function drawGraphic() { chartHeight: height, config: config, options: { - minSpacing: 0, - useLeaderLines: true, - leaderLineStyle: 'dashed', - labelStrategy: 'lastValid', - minLabelOffset: 5 + labelStrategy: directLabels.labelStrategy ?? 'lastValid', + minSpacing: directLabels.minSpacing ?? 0, + minLabelOffset: directLabels.minLabelOffset ?? 5, + labelGap: directLabels.gap ?? 10, + labelGapWithLeaderLines: directLabels.gapWithLeaderLines ?? (directLabels.gap ?? 10), + useLeaderLines: directLabels.useLeaderLines ?? true, + leaderLineStyle: directLabels.leaderLineStyle ?? 'dashed', + leaderLineColourMode: directLabels.leaderLineColourMode ?? 'series', + leaderLineMonoColour: directLabels.leaderLineMonoColour ?? '#707070', + leaderLineElbowOffset: directLabels.leaderLineElbowOffset ?? 10, + leaderLineEndGap: directLabels.leaderLineEndGap ?? 2 } }); } diff --git a/line-chart/config.js b/line-chart/config.js index 22efcf5d..30f781ac 100644 --- a/line-chart/config.js +++ b/line-chart/config.js @@ -2,6 +2,33 @@ config = { "graphicDataURL": "data.csv", "colourPalette": ONSlinePalette, "drawLegend": false, + // Direct labels (used when drawLegend=false and size != 'sm') + // These options are passed into lib/helpers.js:createDirectLabels(). + "directLabels": { + // Horizontal gap (px) from the series endpoint to the label anchor + "gap": 10, + // Horizontal gap (px) for labels that need leader lines (i.e. labels that are in a collision cluster + // or get vertically displaced). Note: for 'lastValidRight' early-ending series, this is ONLY applied + // if the label is also displaced/clustered. + "gapWithLeaderLines": 23, + // Minimum vertical spacing (px) between adjacent labels before they are offset + "minSpacing": 12, + // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' + "labelStrategy": "lastValidRight", + // Leader lines appear only when a label is vertically displaced + "useLeaderLines": true, + // 'dashed' or 'solid' + "leaderLineStyle": "dashed", + // 'series' (match series colour) or 'mono' (single colour) + "leaderLineColourMode": "series", + // Used when leaderLineColourMode is 'mono' + "leaderLineMonoColour": "#707070", + // Geometry tuning (px) + "leaderLineElbowOffset": 10, + "leaderLineEndGap": 2, + // Minimum pixels from chart edge for labels (currently used by some charts) + "minLabelOffset": 10 + }, "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", // Set the default line curve type diff --git a/line-chart/data.csv b/line-chart/data.csv index 35d3b08e..b26e291d 100644 --- a/line-chart/data.csv +++ b/line-chart/data.csv @@ -10,4 +10,4 @@ date,category one,category two,category three,category four two lines,category f 09/03/2028,29000,19000,11000,1000,11000 10/03/2029,15000,21000,10000,6000,9000 11/03/2030,12000,24000,7000,19000,21000 -12/03/2031,4000,28000,13000,18000,23000 +12/03/2031,4000,,3900,18000,4100 diff --git a/line-chart/script.js b/line-chart/script.js index e2d4a8fe..861a4a7c 100644 --- a/line-chart/script.js +++ b/line-chart/script.js @@ -150,6 +150,7 @@ function drawGraphic() { return d[0]; }); } else { + const directLabels = config.directLabels || {}; createDirectLabels({ categories: categories, data: graphicData, @@ -160,10 +161,17 @@ function drawGraphic() { chartHeight: height, config: config, options: { - labelStrategy: 'lastValid', - minSpacing: 12, - useLeaderLines: true, - leaderLineStyle: 'dashed' + labelStrategy: directLabels.labelStrategy ?? 'lastValid', + minSpacing: directLabels.minSpacing ?? 12, + minLabelOffset: directLabels.minLabelOffset ?? 5, + labelGap: directLabels.gap ?? 10, + labelGapWithLeaderLines: directLabels.gapWithLeaderLines ?? (directLabels.gap ?? 10), + useLeaderLines: directLabels.useLeaderLines ?? true, + leaderLineStyle: directLabels.leaderLineStyle ?? 'dashed', + leaderLineColourMode: directLabels.leaderLineColourMode ?? 'series', + leaderLineMonoColour: directLabels.leaderLineMonoColour ?? '#707070', + leaderLineElbowOffset: directLabels.leaderLineElbowOffset ?? 10, + leaderLineEndGap: directLabels.leaderLineEndGap ?? 2 } }); } From 22e59b0dd28cad513fa336e579b246972e0da355 Mon Sep 17 00:00:00 2001 From: sam-ctrl Date: Wed, 21 Jan 2026 17:03:07 +0000 Subject: [PATCH 2/5] renamed "labelStrategy" to "labelLocation" and changed the associated parameters to make it a bit clearer --- lib/helpers.js | 61 +++++++++++++++++++-------- line-chart-dropdown-options/config.js | 7 ++- line-chart-dropdown-options/script.js | 2 +- line-chart-with-ci-area/config.js | 7 ++- line-chart-with-ci-area/script.js | 2 +- line-chart/config.js | 9 ++-- line-chart/script.js | 2 +- 7 files changed, 62 insertions(+), 28 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index 68c64bec..4dd830d9 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -3065,7 +3065,12 @@ function createCleanupFunction(tooltip, overlay, svgContainer, state) { * @param {number} params.options.leaderLineEndGap - Small gap before the label edge where the leader terminates (default: 2) * @param {number} params.options.labelGap - Horizontal gap (px) between the series endpoint and the direct label anchor (default: 10) * @param {number} params.options.labelGapWithLeaderLines - Horizontal gap (px) for labels that require leader lines (default: labelGap) - * @param {string} params.options.labelStrategy - Strategy for positioning labels: 'last', 'lastValid', or 'lastValidRight' (default: 'lastValid') + * @param {string} params.options.labelLocation - Where the direct label should live. + * Allowed values: + * - 'margin': label sits in the right margin, aligned to final x. + * - 'lastPoint': label sits just right of the series endpoint (last valid point). + * - 'marginLeader': label sits in right margin; early-ending series get a leader routed via chart edge. + * (default: 'lastPoint') * @param {number} params.options.minLabelOffset - Minimum pixels from chart edge for labels (default: 5) */ export function createDirectLabels({ @@ -3090,10 +3095,20 @@ export function createDirectLabels({ leaderLineEndGap = 2, labelGap = 10, labelGapWithLeaderLines = labelGap, - labelStrategy = "lastValid", + labelLocation, minLabelOffset = 5, } = options; + function normaliseLabelLocation(location) { + if (!location) return "lastPoint"; + if (location === "margin") return "margin"; + if (location === "marginLeader") return "marginLeader"; + if (location === "lastPoint") return "lastPoint"; + return "lastPoint"; + } + + const resolvedLocation = normaliseLabelLocation(labelLocation); + // Remove any existing direct labels and leader lines before adding new ones svg.selectAll("text.directLineLabel").remove(); svg.selectAll(".label-leader-line").remove(); @@ -3188,27 +3203,33 @@ export function createDirectLabels({ * Get the appropriate data point for labeling based on strategy * @param {Array} data - Array of data objects * @param {string} category - Category name - * @param {string} strategy - 'last', 'lastValid', or 'lastValidRight' + * @param {string} location - 'margin', 'lastPoint', or 'marginLeader' * @returns {Object|null} - Object with {datum, index} or null */ - function getLabelDataPoint(data, category, strategy) { - if (strategy === "last") { + function getLabelDataPoint(data, category, location) { + if (location === "margin") { + // Prefer the final x point if available, otherwise fall back to last valid so a label + // always renders (even if the series is missing on the last row). const lastDatum = data[data.length - 1]; if (lastDatum[category] !== null && lastDatum[category] !== undefined) { return { datum: lastDatum, index: data.length - 1 }; } - return null; - } else { - // 'lastValid' (and variants that still use last-valid selection) return findLastValidDataPoint(data, category); } + + if (location === "lastPoint" || location === "marginLeader") { + return findLastValidDataPoint(data, category); + } + + // Unknown strategy - fall back safely + return findLastValidDataPoint(data, category); } let labelData = []; // Create all labels first and collect their data categories.forEach(function (category, index) { - const labelPoint = getLabelDataPoint(data, category, labelStrategy); + const labelPoint = getLabelDataPoint(data, category, resolvedLocation); // Skip if no valid data point found if (!labelPoint) return; @@ -3223,10 +3244,14 @@ export function createDirectLabels({ // Calculate label x position - offset from data point or chart edge let labelX; let placementMode; // 'right', 'left', or 'rightMargin' - if (labelStrategy === "lastValidRight" && endedEarly) { - // If a line ends early, place its label in the right margin and connect via the chart edge. + if (resolvedLocation === "margin" || resolvedLocation === "marginLeader") { + // Always place the label in the right margin. labelX = chartWidth + labelGap; placementMode = "rightMargin"; + } else if (resolvedLocation === "lastPoint") { + // Always place the label to the right of the series endpoint. + labelX = xPos + labelGap; + placementMode = "right"; } else if (dataIndex === data.length - 1) { // Last data point - place label to the right labelX = xPos + labelGap; @@ -3283,7 +3308,7 @@ export function createDirectLabels({ }); function forceLeaderLineForLabel(label) { - return labelStrategy === "lastValidRight" && label.endedEarly === true; + return resolvedLocation === "marginLeader" && label.endedEarly === true; } function needsLeaderLineForLabel(label) { @@ -3294,13 +3319,13 @@ export function createDirectLabels({ // Otherwise, only when vertically displaced… if (Math.abs(label.y - label.originalY) > 1) return true; - // …or when 'lastValidRight' intentionally routes to the chart edge. + // …or when 'marginLeader' intentionally routes to the chart edge. return forceLeaderLineForLabel(label); } function shouldUseGapWithLeaderLines(label) { // Larger gap is used for labels that are actually displaced / clustered. - // For 'lastValidRight' early-ending series, do NOT apply the larger gap + // For 'marginLeader' early-ending series, do NOT apply the larger gap // unless the label is also being moved to accommodate other labels. return label.inCluster || Math.abs(label.y - label.originalY) > 1; } @@ -3466,10 +3491,10 @@ export function createDirectLabels({ const dir = labelEdgeX >= label.dataX ? 1 : -1; - // For early-ending series in 'lastValidRight', route the leader via the chart's right edge. + // For early-ending series in 'marginLeader', route the leader via the chart's right edge. // Otherwise, use a small elbow offset from the data point. const preferredVerticalX = - labelStrategy === "lastValidRight" && label.endedEarly === true + resolvedLocation === "marginLeader" && label.endedEarly === true ? xScale.range()[1] : null; @@ -3493,11 +3518,11 @@ export function createDirectLabels({ } } - // Special case: lastValidRight early-ending series that are not displaced/clustered. + // Special case: marginLeader early-ending series that are not displaced/clustered. // Draw a single horizontal leader (no vertical segment) to avoid a tiny visual "kink". const isUndisplaced = Math.abs(label.y - label.originalY) <= 1; const simpleHorizontalLeader = - labelStrategy === "lastValidRight" && + resolvedLocation === "marginLeader" && label.endedEarly === true && label.inCluster !== true && isUndisplaced; diff --git a/line-chart-dropdown-options/config.js b/line-chart-dropdown-options/config.js index dd155168..40585731 100644 --- a/line-chart-dropdown-options/config.js +++ b/line-chart-dropdown-options/config.js @@ -12,8 +12,11 @@ config = { "gapWithLeaderLines": 16, // Minimum vertical spacing (px) between adjacent labels before they are offset "minSpacing": 15, - // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' - "labelStrategy": "lastValid", + // Where labels should sit: + // - 'margin' + // - 'lastPoint' + // - 'marginLeader' + "labelLocation": "lastPoint", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, // 'dashed' or 'solid' diff --git a/line-chart-dropdown-options/script.js b/line-chart-dropdown-options/script.js index 86c1bcea..823622ee 100644 --- a/line-chart-dropdown-options/script.js +++ b/line-chart-dropdown-options/script.js @@ -311,7 +311,7 @@ function drawGraphic() { chartHeight: height, config: config, options: { - labelStrategy: directLabels.labelStrategy ?? 'lastValid', + labelLocation: directLabels.labelLocation ?? 'lastPoint', minSpacing: directLabels.minSpacing ?? 15, minLabelOffset: directLabels.minLabelOffset ?? 5, labelGap: directLabels.gap ?? 10, diff --git a/line-chart-with-ci-area/config.js b/line-chart-with-ci-area/config.js index df7e41b4..51b89d59 100644 --- a/line-chart-with-ci-area/config.js +++ b/line-chart-with-ci-area/config.js @@ -13,8 +13,11 @@ config = { // Minimum vertical spacing (px) between adjacent labels before they are offset // Note: this chart previously used 0 to allow tight packing. "minSpacing": 0, - // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' - "labelStrategy": "lastValid", + // Where labels should sit: + // - 'margin' + // - 'lastPoint' + // - 'marginLeader' + "labelLocation": "lastPoint", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, // 'dashed' or 'solid' diff --git a/line-chart-with-ci-area/script.js b/line-chart-with-ci-area/script.js index 8d958a27..d7613cd8 100644 --- a/line-chart-with-ci-area/script.js +++ b/line-chart-with-ci-area/script.js @@ -208,7 +208,7 @@ function drawGraphic() { chartHeight: height, config: config, options: { - labelStrategy: directLabels.labelStrategy ?? 'lastValid', + labelLocation: directLabels.labelLocation ?? 'lastPoint', minSpacing: directLabels.minSpacing ?? 0, minLabelOffset: directLabels.minLabelOffset ?? 5, labelGap: directLabels.gap ?? 10, diff --git a/line-chart/config.js b/line-chart/config.js index 30f781ac..6c58154e 100644 --- a/line-chart/config.js +++ b/line-chart/config.js @@ -8,13 +8,16 @@ config = { // Horizontal gap (px) from the series endpoint to the label anchor "gap": 10, // Horizontal gap (px) for labels that need leader lines (i.e. labels that are in a collision cluster - // or get vertically displaced). Note: for 'lastValidRight' early-ending series, this is ONLY applied + // or get vertically displaced). Note: for 'marginLeader' early-ending series, this is ONLY applied // if the label is also displaced/clustered. "gapWithLeaderLines": 23, // Minimum vertical spacing (px) between adjacent labels before they are offset "minSpacing": 12, - // Strategy for which point gets labelled: 'last', 'lastValid', or 'lastValidRight' - "labelStrategy": "lastValidRight", + // Where labels should sit: + // - 'margin' + // - 'lastPoint' + // - 'marginLeader' + "labelLocation": "marginLeader", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, // 'dashed' or 'solid' diff --git a/line-chart/script.js b/line-chart/script.js index 861a4a7c..fb270679 100644 --- a/line-chart/script.js +++ b/line-chart/script.js @@ -161,7 +161,7 @@ function drawGraphic() { chartHeight: height, config: config, options: { - labelStrategy: directLabels.labelStrategy ?? 'lastValid', + labelLocation: directLabels.labelLocation ?? 'lastPoint', minSpacing: directLabels.minSpacing ?? 12, minLabelOffset: directLabels.minLabelOffset ?? 5, labelGap: directLabels.gap ?? 10, From b668a3027a85c6b00c5f92ff010e14d7a7620ab8 Mon Sep 17 00:00:00 2001 From: sam-ctrl Date: Wed, 21 Jan 2026 17:35:08 +0000 Subject: [PATCH 3/5] changed config comments a bit --- line-chart/config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/line-chart/config.js b/line-chart/config.js index 6c58154e..5c633bba 100644 --- a/line-chart/config.js +++ b/line-chart/config.js @@ -14,9 +14,9 @@ config = { // Minimum vertical spacing (px) between adjacent labels before they are offset "minSpacing": 12, // Where labels should sit: - // - 'margin' - // - 'lastPoint' - // - 'marginLeader' + // - 'margin' (labels in margin, no leader lines) + // - 'marginLeader' (labels in margin, with leader lines if displaced) + // - 'lastPoint' (labels at last data point) "labelLocation": "marginLeader", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, From fb31d69ae61a9efd39249ebe5cc058d03e214c93 Mon Sep 17 00:00:00 2001 From: sam-ctrl Date: Wed, 21 Jan 2026 17:52:59 +0000 Subject: [PATCH 4/5] changed some of the config and example data --- line-chart/config.js | 2 +- line-chart/data.csv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/line-chart/config.js b/line-chart/config.js index 5c633bba..d01940ef 100644 --- a/line-chart/config.js +++ b/line-chart/config.js @@ -61,7 +61,7 @@ config = { "aspectRatio": { "sm": [1, 1], "md": [1, 1], - "lg": [1, 1] + "lg": [1.5, 1] }, "margin": { "sm": { diff --git a/line-chart/data.csv b/line-chart/data.csv index b26e291d..f51ecf93 100644 --- a/line-chart/data.csv +++ b/line-chart/data.csv @@ -10,4 +10,4 @@ date,category one,category two,category three,category four two lines,category f 09/03/2028,29000,19000,11000,1000,11000 10/03/2029,15000,21000,10000,6000,9000 11/03/2030,12000,24000,7000,19000,21000 -12/03/2031,4000,,3900,18000,4100 +12/03/2031,8300,,7500,18000,6500 From a87670c73654cfe86f5a8621a8260892acde72e8 Mon Sep 17 00:00:00 2001 From: sam-ctrl Date: Thu, 22 Jan 2026 13:27:32 +0000 Subject: [PATCH 5/5] Improved collision handling for label positioning (accounts for top/bottom of chart) --- lib/helpers.js | 50 +++++++++++++++++++++++++++ line-chart-dropdown-options/config.js | 6 ++-- line-chart-with-ci-area/config.js | 6 ++-- line-chart/config.js | 6 ++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/helpers.js b/lib/helpers.js index 4dd830d9..becc902f 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -3456,6 +3456,56 @@ export function createDirectLabels({ ); } } + + // Global collision pass: + // If a cluster is pushed into bounds (e.g. near the top), it can end up overlapping labels + // that were not originally in the same cluster. Resolve overlaps against the chart bounds + // and between all labels using a couple of forward/backward sweeps. + // Preserve the natural vertical ordering based on original positions so labels don't + // unexpectedly swap order after cluster/bounds adjustments. + const all = [...labelData].sort((a, b) => a.originalY - b.originalY); + + const minYFor = (label) => label.height / 2; + const maxYFor = (label) => chartHeight - label.height / 2; + + const clampAllIntoBounds = () => { + for (const label of all) { + label.y = clamp(label.y, minYFor(label), maxYFor(label)); + } + }; + + const forwardSeparate = () => { + for (let i = 1; i < all.length; i++) { + const prev = all[i - 1]; + const cur = all[i]; + const minY = + prev.y + prev.height / 2 + cur.height / 2 + minSpacing; + if (cur.y < minY) cur.y = minY; + } + }; + + const backwardSeparate = () => { + for (let i = all.length - 2; i >= 0; i--) { + const next = all[i + 1]; + const cur = all[i]; + const maxY = + next.y - next.height / 2 - cur.height / 2 - minSpacing; + if (cur.y > maxY) cur.y = maxY; + } + }; + + // A few sweeps is enough for our small label counts and avoids over-complication. + for (let iter = 0; iter < 3; iter++) { + clampAllIntoBounds(); + forwardSeparate(); + // Ensure we don't push the stack past the bottom. + all[all.length - 1].y = Math.min(all[all.length - 1].y, maxYFor(all[all.length - 1])); + backwardSeparate(); + // Ensure we don't push the stack past the top. + all[0].y = Math.max(all[0].y, minYFor(all[0])); + } + + clampAllIntoBounds(); } // Layout pass 1: compute y positions based on initial wrapping diff --git a/line-chart-dropdown-options/config.js b/line-chart-dropdown-options/config.js index 40585731..0f050752 100644 --- a/line-chart-dropdown-options/config.js +++ b/line-chart-dropdown-options/config.js @@ -13,9 +13,9 @@ config = { // Minimum vertical spacing (px) between adjacent labels before they are offset "minSpacing": 15, // Where labels should sit: - // - 'margin' - // - 'lastPoint' - // - 'marginLeader' + // - 'margin' (labels in margin, no leader lines for early-ending series) + // - 'marginLeader' (labels in margin, with long leader lines for early-ending series) + // - 'lastPoint' (labels at last data point of early ending series) "labelLocation": "lastPoint", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, diff --git a/line-chart-with-ci-area/config.js b/line-chart-with-ci-area/config.js index 51b89d59..7ee33a3e 100644 --- a/line-chart-with-ci-area/config.js +++ b/line-chart-with-ci-area/config.js @@ -14,9 +14,9 @@ config = { // Note: this chart previously used 0 to allow tight packing. "minSpacing": 0, // Where labels should sit: - // - 'margin' - // - 'lastPoint' - // - 'marginLeader' + // - 'margin' (labels in margin, no leader lines for early-ending series) + // - 'marginLeader' (labels in margin, with long leader lines for early-ending series) + // - 'lastPoint' (labels at last data point of early ending series) "labelLocation": "lastPoint", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true, diff --git a/line-chart/config.js b/line-chart/config.js index d01940ef..34045dfa 100644 --- a/line-chart/config.js +++ b/line-chart/config.js @@ -14,9 +14,9 @@ config = { // Minimum vertical spacing (px) between adjacent labels before they are offset "minSpacing": 12, // Where labels should sit: - // - 'margin' (labels in margin, no leader lines) - // - 'marginLeader' (labels in margin, with leader lines if displaced) - // - 'lastPoint' (labels at last data point) + // - 'margin' (labels in margin, no leader lines for early-ending series) + // - 'marginLeader' (labels in margin, with long leader lines for early-ending series) + // - 'lastPoint' (labels at last data point of early ending series) "labelLocation": "marginLeader", // Leader lines appear only when a label is vertically displaced "useLeaderLines": true,