首页 > 解决方案 > 我在哪里可以找到适合折线图的标签放置算法?

问题描述

我希望将系列标签自动放置在折线图上靠近它们的线的位置,如下面的 The Economist 示例所示。我正在使用 D3,但我很乐意使用其他语言的优秀解决方案,我可以将其翻译成 JavaScript 或从中汲取灵感。

带有精美标签的折线图

我找到了一些很好的解决方案,将标签放置在线条的右侧,例如使用强制导向布局。我也尝试过我自己的算法来做类似的事情。这通常效果很好,但当几个系列的最右边的点靠近时会导致问题。我觉得我们可以通过诸如“如果一个系列有几个连续值高于该图上的所有其他系列的连续值,将标签放在这些值之上”之类的规则——比如上图中的英国或美国。

我真的很感激任何指向做这种事情的实现的指针!

标签: d3.jsdata-visualizationvisualizationlinechart

解决方案


下面是一个简单的 D3 片段,它试图通过在该点的所有系列的四分位间距范围内查找系列中的异常值来模仿经济学人示例。此外,通过检查最高离群值是否比最低离群值更离群,您可以确定标签的最佳点。

例如,对于折线图,您将拥有一组系列,例如

[
  {
    "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)where xis0.250.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 之外没有点(即 wherecompareLowscompareHighsare all 0s),这种方法将不起作用。对于这种情况,需要做出一些选择。也许根据您的方法在系列的最后添加标签?


推荐阅读