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 @@ - + Slope chart diff --git a/slope-chart/script.js b/slope-chart/script.js index fea95878..23cba66e 100644 --- a/slope-chart/script.js +++ b/slope-chart/script.js @@ -1,52 +1,39 @@ -import { initialise, wrap, addSvg, addSource, calculateAutoBounds, adjustColorForContrast } from "../lib/helpers.js"; +import { initialise, addSvg, addSource, createDirectLabels, calculateAutoBounds } from "../lib/helpers.js"; let graphic = d3.select('#graphic'); -//console.log(`Graphic selected: ${graphic}`); +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, config); + 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 xDataType; - - if (Object.prototype.toString.call(graphicData[0].date) === '[object Date]') { - xDataType = 'date'; + if (size == "sm") { + margin.left = 50 + margin.right = parseInt(graphic.style('width')) - 230; } else { - xDataType = 'numeric'; + let slopeMargin = parseInt(graphic.style('width')) / 2 - 90; + + margin.left = slopeMargin + margin.right = slopeMargin } - // console.log(xDataType) - // Define the x and y scales + let chartWidth = parseInt(graphic.style('width')) - margin.left - margin.right; + let height = (aspectRatio[1] / aspectRatio[0]) * chartWidth; - let x; + const columns = graphicData.columns.slice(1) - 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`); + let x = d3.scalePoint().domain(columns) + .range([0, chartWidth]) + + let xLabels = d3.scalePoint().domain(config.xAxisLabels) + .range([0, chartWidth]) const y = d3 .scaleLinear() @@ -55,228 +42,187 @@ function drawGraphic() { // Calculate Y-axis bounds based on data and config const { minY, maxY } = calculateAutoBounds(graphicData, config); - y.domain([minY, maxY]); - + if (config.showZeroAxis) { + y.domain([d3.min([0,minY]), maxY]); + } else { + y.domain([minY, maxY]); + } // 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[config.lineCurveType]); + + function direction(data) { + if (+data[columns[0]] > +data[columns[columns.length - 1]]) { + return 0 + } else if (+data[columns[columns.length - 1]] > +data[columns[0]]) { + return 2 + } else if (+data[columns[0]] == +data[columns[columns.length - 1]]) { + return 1 + } + } + + // 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(" + -20 + ",0)") + .attr('class', 'y axis') + .call( + d3 + .axisLeft(y) + .tickValues([0]) + .tickSize(-220) + // .tickFormat('') + ) + + svg.append('g').attr('transform', "translate(" + (chartWidth + 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) + .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) + + 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)) + ] ); + }) - // 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; + 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 + } + }); - //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; - } // 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.colourPalette[categories.indexOf(category) % config.colourPalette.length] + : config.colourPalette[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', - adjustColorForContrast( - config.colourPalette[categories.indexOf(category) % config.colourPalette.length], - 4.5 - ) - ) - .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', - adjustColorForContrast( - config.colourPalette[categories.indexOf(category) % config.colourPalette.length], - 4.5 - ) - ) - .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', - adjustColorForContrast( - config.colourPalette[categories.indexOf(category) % config.colourPalette.length], - 4.5 + 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.colourPalette[categories.indexOf(category) % config.colourPalette.length] + : config.colourPalette[direction(graphicData.find(d => d.name === category))] ) - ) - .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}`); - + .attr('r', 6) + .raise() + }) }); - // 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'); - } - }) - // // 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)"); //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.utcParse(config.dateFormat)(d.date) !== null) { - return { - date: d3.utcParse(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); + graphicData = rawData - // console.log(`Data from CSV processed`); - - // console.log('Final data structure:'); - // console.log(graphicData); + rawData.columns.slice(1).forEach(column=> + graphicData.forEach(d=>d[column] = +d[column]) + ) // 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`); + });