Below is a simple D3 snippet that attempts to mimic the Economist example by looking for points in a series that are outliers per the inter-quartile range for all the series at that point. Further, by checking if the highest outlier is more outlying than the lowest outlier then you can decide the best point for the label.
E.g. For a line chart you are going to have a set of series e.g.
[
{
"id": "A",
"values": [{"index": 1, "value": 10}, ... {"index": n, "value": 35}]
},
{
"id": "B",
"values": [{"index": 1, "value": 20}, ... {"index": n, "value": 200}]
},
... etc
]
So you can compute the high and low points of the IQR for each index (using d3.quantile(arr, x) where x is 0.25 or 0.75:
const iqrLows = d3.range(valueCount).map((n, i) => {
const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
return d3.quantile(values, 0.25);
});
const iqrHighs = d3.range(valueCount).map((n, i) => {
const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
return d3.quantile(values, 0.75);
});
Then, for each series, look for points that either below the lows, or above the highs. Then, if the highest outlier is bigger than the lowest outlier, you can place the label above that point. Or if the lowest outlier is bigger than the highest outlier, place the label below, for that point.
series.forEach(s => {
const compareLows = s.values.map((k, i) => k.value < iqrLows[i] ? iqrLows[i] - k.value : 0);
const compareHighs = s.values.map((k, i) => k.value > iqrHighs[i] ? k.value - iqrHighs[i] : 0);
if (d3.max(compareHighs) > d3.max(compareLows)) {
s.xLabelIndex = d3.maxIndex(compareHighs) + 1;
s.xLabelValue = s.values[d3.maxIndex(compareHighs)];
s.xLabelOrient = "above";
} else {
s.xLabelIndex = d3.maxIndex(compareLows) + 1;
s.xLabelValue = s.values[d3.maxIndex(compareLows)];
s.xLabelOrient = "below";
}
});
The way I've generated randomized data for the example below makes for hits and misses. But hopefully gives you some inspiration.
document.getElementById("generate")
.addEventListener("click", generateChart);
generateChart();
function generateChart() {
// clear chart
d3.select("#chart")
.selectAll("*")
.remove();
// random data
const data = sampleData();
// chart init
const margin = 30;
const width = 300;
const height = 120;
const svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin + margin)
.attr("height", height + margin + margin);
const gChart = svg.append("g")
.attr("transform", `translate(${margin},${margin})`);
const color = d3.scaleOrdinal(d3.schemeCategory10);
// scales and axes
const xMax = d3.max(data[0].values, d => d.index + 1);
const yMax = d3.max(data, series => {
return d3.max(series.values, d => d.value * 1.2);
});
const xScale = d3.scaleLinear()
.range([0, width])
.domain([1, xMax]);
const yScale = d3.scaleLinear()
.range([height, 0])
.domain([0, yMax]);
gChart.append("g")
.call(d3.axisLeft().scale(yScale));
gChart.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom().scale(xScale));
// draw lines
const line = d3.line()
.curve(d3.curveCardinal)
.x(d => xScale(d.index))
.y(d => yScale(d.value));
const lines = gChart.selectAll(".series")
.data(data)
.enter()
.append("g");
lines.append("path")
.attr("class", "series")
.attr("d", d => line(d.values))
.attr("stroke", d => color(d.id))
// add labels
const labels = gChart.selectAll(".labels")
.data(data)
.enter()
.append("g");
// x and y refer to iqr dependent code in sampleData()
labels.append("text")
.attr("class", "labels")
.attr("x", d => xScale(d.xLabelIndex))
.attr("y", d => yScale(d.xLabelValue.value) + (d.xLabelOrient === "above" ? -15 : 10))
//.attr("dy", ".35em")
.attr("fill", d => color(d.id))
.text(d => d.id)
}
function sampleData() {
const labels = [
"Amsterdam", "Barcelona",
"Copenhagen", "Dublin",
"Edinburgh", "Frankfurt"
]; // 6 example series
const seriesCount = d3.randomInt(2, labels.length + 1)(); // e.g. 2-6 lines
const sampleLabels = labels.slice(0, seriesCount);
const valueCount = d3.randomInt(12, 25)(); // e.g. 1-2 years
const series = sampleLabels.map(label => {
return {
"id": label,
"values": d3.range(valueCount).map((n, i) => {
return {
"index": i + 1,
"value": d3.randomIrwinHall(5)() * (i ** 0.5)
}
})
}
});
// look at points that are max outside box per series
const iqrLows = d3.range(valueCount).map((n, i) => {
const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
return d3.quantile(values, 0.25);
});
const iqrHighs = d3.range(valueCount).map((n, i) => {
const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
return d3.quantile(values, 0.75);
});
series.forEach(s => {
const compareLows = s.values.map((k, i) => k.value < iqrLows[i] ? iqrLows[i] - k.value : 0);
const compareHighs = s.values.map((k, i) => k.value > iqrHighs[i] ? k.value - iqrHighs[i] : 0);
if (d3.max(compareHighs) > d3.max(compareLows)) {
s.xLabelIndex = d3.maxIndex(compareHighs) + 1;
s.xLabelValue = s.values[d3.maxIndex(compareHighs)];
s.xLabelOrient = "above";
} else {
s.xLabelIndex = d3.maxIndex(compareLows) + 1;
s.xLabelValue = s.values[d3.maxIndex(compareLows)];
s.xLabelOrient = "below";
}
});
//console.log(series);
return series;
}
.series {
fill: none;
stroke-width: 2px;
}
.labels {
text-anchor: middle;
font: 10px sans-serif;
font-weight: bold;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<button id="generate">Generate</button>
<div id="chart"></div>
I've gone with d3.randomIrwinHall and a subtle growth term with a square root of the index. This kind of skews the label placement rightward. Obviously very much of the success of this approach depends on the type of data you are visualizing.
This approach isn't going to work where a series has no points outside the IQR (i.e. where compareLows and compareHighs are all 0s). For this case, some choice needs to be made. Perhaps adding the label at the very end of the series per your approach ?