From 7cdfdc2ceda5273d09a6c628e6b8548846b0fb52 Mon Sep 17 00:00:00 2001 From: Henry Lau <2945099+henryjameslau@users.noreply.github.com> Date: Mon, 9 Mar 2026 09:32:56 +0000 Subject: [PATCH] Enhance clustering functionality and update xDomain handling in range plot --- range-ci-area-grouped/config.js | 2 +- range-ci-area-grouped/data.csv | 49 ++++++------ range-ci-area-grouped/script.js | 130 +++++++++++++++++++------------- 3 files changed, 100 insertions(+), 81 deletions(-) diff --git a/range-ci-area-grouped/config.js b/range-ci-area-grouped/config.js index 4f930794..1b526f3e 100644 --- a/range-ci-area-grouped/config.js +++ b/range-ci-area-grouped/config.js @@ -6,7 +6,7 @@ config = { "xAxisTickFormat": ".0f", "xAxisLabel": "x axis label", "xDomain": [0, 160], - // either auto or a custom domain as an array e.g [0,100] + // either auto, auto-each or a custom domain as an array e.g [0,100] "ciLegend": true, "legendIntervalText": "Likely range (95% confidence interval)", "legendEstimateText": "Estimated value", diff --git a/range-ci-area-grouped/data.csv b/range-ci-area-grouped/data.csv index bbf47356..b9768dee 100644 --- a/range-ci-area-grouped/data.csv +++ b/range-ci-area-grouped/data.csv @@ -1,28 +1,23 @@ name,group,series,value,min,max -England,Group name 1,series 1,55.15726549,44.79967469,65.51485629 -Wales,Group name 1,series 1,53,20,86 -North East,Group name 2,series 1,38.29309225,21.04544788,55.54073662 -North West,Group name 2,series 1,45.1373065,39.13579896,51.13881403 -Yorkshire and The Humber,Group name 2,series 1,60,40,80 -East Midlands,Group name 2,series 1,40.56442504,29.36591976,51.76293031 -West Midlands,Group name 2,series 1,60.02733084,39.86949419,80.18516748 -East,Group name 2,series 1,50.79985161,17.64884078,83.95086243 -London,Group name 2,series 1,50.89339154,43.91069538,57.87608769 -South West,Group name 2,series 1,29.68960375,9.379207492,50 -South East,Group name 2,series 1,35.07397305,8.973142806,61.1748033 -Another name,Group name 3,series 1,45.19066503,24.79415817,65.58717189 -And another,Group name 3,series 1,43,33,53 -England,Group name 1,series 2,110.1572655,99.79967469,120.5148563 -Wales,Group name 1,series 2,120,90,150 -North East,Group name 2,series 2,93.29309224,76.04544788,110.5407366 -North West,Group name 2,series 2,100.1373065,94.13579896,106.138814 -Yorkshire and The Humber,Group name 2,series 2,118.1506859,85.57469086,150.726681 -East Midlands,Group name 2,series 2,95.56442503,84.36591976,106.7629303 -West Midlands,Group name 2,series 2,115.0273308,94.86949419,135.1851675 -East,Group name 2,series 2,124.4754312,110,138.9508624 -London,Group name 2,series 2,105.8933915,98.91069538,112.8760877 -South West,Group name 2,series 2,92.14356115,64.37920749,119.9079148 -South East,Group name 2,series 2,90.07397306,63.97314281,116.1748033 -Another name,Group name 3,series 2,100.190665,79.79415817,120.5871719 -And another,Group name 3,series 2,97,93,101 -All,Group name 1,all,53,20,86 \ No newline at end of file +Female,Sex,Straight or Heterosexual,14.29,13.59,14.98 +Female,Sex,LGB+,50.15,44.21,56.08 +Female,Sex,Gay or Lesbian,36.96,29.27,46.04 +Female,Sex,Bisexual,63.18,52.58,73.78 +Female,Sex,Other sexual orientation,46.04,30.88,65.56 +Male,Sex,Straight or Heterosexual,28.2,27.09,29.3 +Male,Sex,LGB+,67.13,59.95,74.31 +Male,Sex,Gay or Lesbian,67.66,58.43,76.88 +Male,Sex,Bisexual,71.48,57.32,88.04 +Male,Sex,Other sexual orientation,55.65,35.67,82.62 +16 to 24 years,Age on Census Day,Straight or Heterosexual,12.33,10.96,13.7 +16 to 24 years,Age on Census Day,LGB+,28.72,22.84,35.67 +25 to 34 years,Age on Census Day,Straight or Heterosexual,17.15,15.97,18.33 +25 to 34 years,Age on Census Day,LGB+,48.4,41.01,55.8 +35 to 44 years,Age on Census Day,Straight or Heterosexual,29.38,27.83,30.92 +35 to 44 years,Age on Census Day,LGB+,84.36,71.74,96.98 +45 to 54 years,Age on Census Day,Straight or Heterosexual,35.57,33.9,37.25 +45 to 54 years,Age on Census Day,LGB+,95.48,79.38,111.58 +55 to 64 years,Age on Census Day,Straight or Heterosexual,20.78,19.45,22.1 +55 to 64 years,Age on Census Day,LGB+,70.48,54.1,90.18 +65 years and over,Age on Census Day,Straight or Heterosexual,12.26,11.29,13.22 +65 years and over,Age on Census Day,LGB+,41.79,25.33,64.82 diff --git a/range-ci-area-grouped/script.js b/range-ci-area-grouped/script.js index 2bbdf686..52e7d064 100644 --- a/range-ci-area-grouped/script.js +++ b/range-ci-area-grouped/script.js @@ -4,6 +4,7 @@ let graphic = d3.select('#graphic'); let legend = d3.select('#legend'); let pymChild = null; let graphicData, size, groups, xDomain, divs, svgs, charts; +const rectHeight = 16 function drawGraphic() { @@ -16,20 +17,18 @@ function drawGraphic() { groups = d3.groups(graphicData, (d) => d.group); - if (config.xDomain == 'auto') { - let min = 1000000; - let max = 0; - for (let i = 2; i < graphicData.columns.length; i++) { - min = d3.min([ - min, - d3.min(graphicData, (d) => +d[graphicData.columns[i]]) - ]); - max = d3.max([ - max, - d3.max(graphicData, (d) => +d[graphicData.columns[i]]) - ]); - } - xDomain = [min, max]; + function computeDomain(data, columns) { + return d3.extent(data.flatMap(d => columns.filter(d => d != 'name' && d != 'group').map(col => +d[col]))) + } + + + if (config.xDomain === 'auto') { + xDomain = computeDomain(graphicData, graphicData.columns) + } else if (config.xDomain === 'auto-each') { + xDomain = Object.fromEntries( + groups.map(([name, groupData]) => [name, computeDomain(groupData, graphicData.columns) + ]) + ) } else { xDomain = config.xDomain; } @@ -37,7 +36,52 @@ function drawGraphic() { //set up scales const x = d3.scaleLinear().range([0, chartWidth]).domain(xDomain); + const xScales = config.xDomain === "auto-each" ? Object.fromEntries( + groups.map(([name]) => [ + name, + d3.scaleLinear().range([0, chartWidth]).domain(xDomain[name]).nice() + ]) + ) : { + global: d3.scaleLinear().range([0, chartWidth]).domain(xDomain).nice() + } + + function getX(groupName) { + return config.xDomain === "auto-each" + ? xScales[groupName] + : xScales.global; + } + const series = [...new Set(graphicData.map(d => d.series))] + + const seriesByGroupAndName = {}; + + groups.forEach(([groupName, groupData]) => { + seriesByGroupAndName[groupName] = {}; + + d3.group(groupData, d => d.name).forEach((rows, name) => { + seriesByGroupAndName[groupName][name] = + [...new Set(rows.map(d => d.series))]; + }) + }) + + const clusterOffset = {}; + + Object.entries(seriesByGroupAndName).forEach(([groupName, names]) => { + clusterOffset[groupName] = {}; + + Object.entries(names).forEach(([name, seriesList]) => { + const spread = (seriesList.length - 1) * rectHeight; + + clusterOffset[groupName][name] = d3.scalePoint().domain(seriesList).range([-spread / 2, spread / 2]) + }); + }); + + const yWithCluster = (d) => { + const baseY = groups.find(g => g[0] === d.group)[3](d.name); + + return config.clustered ? baseY + clusterOffset[d.group][d.name](d.series) : baseY; + } + const colour = d3 .scaleOrdinal() .range(config.colourPalette) @@ -58,11 +102,6 @@ function drawGraphic() { d[4] = d3.axisLeft(d[3]).tickSize(0).tickPadding(10); }); - //set up xAxis generator - let xAxis = d3.axisBottom(x) - .ticks(config.xAxisTicks[size]) - .tickFormat(d3.format(config.xAxisTickFormat)); - divs = graphic.selectAll('div.categoryLabels').data(groups).join('div'); if (groups.length > 1) { divs.append('p').attr('class', 'groupLabels').html((d) => d[0]) } @@ -75,6 +114,8 @@ function drawGraphic() { }) charts.each(function (d) { + if (config.xDomain === "auto-each") { let xAxis = d[6] } + d3.select(this) .append('g') .attr('class', 'y axis') @@ -108,6 +149,14 @@ function drawGraphic() { .attr('transform', (d) => 'translate(0,' + d[2] + ')') .attr('class', 'x axis') .each(function () { + const x = getX(d[0]); + + const xAxis = d3.axisBottom(x) + .ticks(config.xAxisTicks[size]) + .tickFormat(d3.format(config.xAxisTickFormat)); + + + d3.select(this) .call(xAxis.tickSize(-d[2])) .selectAll('line') @@ -119,23 +168,15 @@ function drawGraphic() { }); }); - const rectHeight = 16 + charts .selectAll("rect") .data((d) => d[1]) .join("rect") - .attr("x", d => x(Number(d.min))) - .attr("y", (d, i) => { - const baseY = groups.filter((e) => e[0] == d.group)[0][3](d.name) - rectHeight / 2; - // if clustered is true, move series 0 up 10, series 1 down 10 only - if (config.clustered === true) { - if (d.series === series[0]) return baseY - 10; - if (d.series === series[1]) return baseY + 10; - } - return baseY; - }) - .attr("width", d => Math.abs(x(Number(d.max)) - x(Number(d.min)))) + .attr("x", d => getX(d.group)(+d.min)) + .attr("y", d => yWithCluster(d) - rectHeight / 2) + .attr("width", d => Math.abs(getX(d.group)(Number(d.max)) - getX(d.group)(Number(d.min)))) .attr("height", rectHeight) .attr("fill", d => colour(d.series)) .attr("opacity", 0.65) @@ -145,29 +186,11 @@ function drawGraphic() { .selectAll('rect.value') .data((d) => d[1]) .join('rect') - .attr('x', (d) => x(d.value) - 5) - .attr('y', (d) => { - const baseY = groups.filter((f) => f[0] == d.group)[0][3](d.name) - 5; - // if clustered is true, move series 0 up 10, series 1 down 10 only - if (config.clustered === true) { - const series = [...new Set(graphicData.map(d => d.series))]; - if (d.series === series[0]) return baseY - 10; - if (d.series === series[1]) return baseY + 10; - } - return baseY; - }) + .attr('x', (d) => getX(d.group)(d.value) - 5) + .attr('y', d => yWithCluster(d) - 5) .attr('width', 10) .attr('height', 10) - .attr('transform', (d) => { - const baseY = groups.filter((f) => f[0] == d.group)[0][3](d.name); - let y = baseY; - if (config.clustered === true) { - const series = [...new Set(graphicData.map(d => d.series))]; - if (d.series === series[0]) y = baseY - 10; - if (d.series === series[1]) y = baseY + 10; - } - return `rotate(45 ${x(d.value)} ${y})`; - }) + .attr('transform', d => `rotate(45 ${getX(d.group)(d.value)} ${yWithCluster(d)})`) .attr("fill", "white") .attr("stroke-width", "2px") .attr('stroke', d => colour(d.series)) @@ -210,7 +233,8 @@ function drawGraphic() { .attr('class', (d, i) => config.useDiamonds && i == 0 ? 'legend--icon--diamond' : 'legend--icon--circle') .style('background-color', function (d) { return d[1]; - }); + }) + .style('opacity',0.65); legenditem .append('div')