在哪里可以找到适合折线图的良好标签放置算法?
Where can I find a good label placement algorithm for line charts?
我希望自动将系列标签放在折线图的线条附近,如经济学人的以下示例所示。我正在使用 D3,但如果我可以翻译成 JavaScript 或从中汲取灵感,我会很高兴使用其他语言的好的解决方案。
我发现一些 good solutions that place the labels to the right of the lines, for example using force-directed layout. I've also attempted my own algorithm 可以做类似的事情。这通常很有效,但当几个系列的最右边的点靠得很近时会导致问题。我觉得我们可以通过诸如“如果一个系列有几个连续的值高于图中所有其他系列的值,将标签放在这些值之上”这样的规则走得更远——就像上图中的英国或美国。
我非常感谢任何指向执行此类操作的实现的指针!
下面是一个简单的 D3 代码段,它试图通过寻找系列中的异常点来模仿经济学人的示例,这些点在该点的所有系列中都是 inter-quartile range 的异常值。此外,通过检查最高离群值是否比最低离群值更离群,您可以决定标签的最佳点。
例如对于折线图,您将拥有一组系列,例如
[
{
"id": "A",
"values": [{"index": 1, "value": 10}, ... {"index": n, "value": 35}]
},
{
"id": "B",
"values": [{"index": 1, "value": 20}, ... {"index": n, "value": 200}]
},
... etc
]
因此您可以计算每个索引的 IQR 的高点和低点(使用 d3.quantile(arr, x)
,其中 x
是 0.25
或 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);
});
然后,对于每个系列,寻找低于低点或高于高点的点。然后,如果最高离群值大于最低离群值,您可以将标签放在该点上方。或者,如果最低离群值大于最高离群值,则将标签放在下方,用于该点。
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";
}
});
我为下面的示例生成随机数据的方式会导致命中和未命中。但希望能给你一些启发。
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>
我使用了 d3.randomIrwinHall
和一个带有指数平方根的微妙增长项。这种将标签位置向右倾斜。显然,这种方法的成功在很大程度上取决于您要可视化的数据类型。
如果系列在 IQR 之外没有点(即 compareLows
和 compareHighs
都是 0
),则此方法不起作用。对于这种情况,需要做出一些选择。也许按照您的方法在系列的最后添加标签?
我希望自动将系列标签放在折线图的线条附近,如经济学人的以下示例所示。我正在使用 D3,但如果我可以翻译成 JavaScript 或从中汲取灵感,我会很高兴使用其他语言的好的解决方案。
我发现一些 good solutions that place the labels to the right of the lines, for example using force-directed layout. I've also attempted my own algorithm 可以做类似的事情。这通常很有效,但当几个系列的最右边的点靠得很近时会导致问题。我觉得我们可以通过诸如“如果一个系列有几个连续的值高于图中所有其他系列的值,将标签放在这些值之上”这样的规则走得更远——就像上图中的英国或美国。
我非常感谢任何指向执行此类操作的实现的指针!
下面是一个简单的 D3 代码段,它试图通过寻找系列中的异常点来模仿经济学人的示例,这些点在该点的所有系列中都是 inter-quartile range 的异常值。此外,通过检查最高离群值是否比最低离群值更离群,您可以决定标签的最佳点。
例如对于折线图,您将拥有一组系列,例如
[
{
"id": "A",
"values": [{"index": 1, "value": 10}, ... {"index": n, "value": 35}]
},
{
"id": "B",
"values": [{"index": 1, "value": 20}, ... {"index": n, "value": 200}]
},
... etc
]
因此您可以计算每个索引的 IQR 的高点和低点(使用 d3.quantile(arr, x)
,其中 x
是 0.25
或 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);
});
然后,对于每个系列,寻找低于低点或高于高点的点。然后,如果最高离群值大于最低离群值,您可以将标签放在该点上方。或者,如果最低离群值大于最高离群值,则将标签放在下方,用于该点。
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";
}
});
我为下面的示例生成随机数据的方式会导致命中和未命中。但希望能给你一些启发。
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>
我使用了 d3.randomIrwinHall
和一个带有指数平方根的微妙增长项。这种将标签位置向右倾斜。显然,这种方法的成功在很大程度上取决于您要可视化的数据类型。
如果系列在 IQR 之外没有点(即 compareLows
和 compareHighs
都是 0
),则此方法不起作用。对于这种情况,需要做出一些选择。也许按照您的方法在系列的最后添加标签?