D3.js v7 - 如何使 Y 轴标签始终显示在可滚动图表的屏幕上

D3.js v7 - How to make Y axis labels always show on screen for a scrollable chart

我有一个图表,它为每个 x 轴项目使用设置宽度,因此,当添加大量日期时,图表可以滚动。

我想要做的是让两个 Y 轴标签始终显示在屏幕上(我不能用 CSS 做 position: fixed,因为轴是 <g> 元素。)

如何让两个 Y 轴标签始终显示在屏幕上? (这是蓝色和绿色的文本标签)。这是我的意思的一个示例:https://observablehq.com/@d3/pannable-chart) - 不幸的是,该站点上的代码超出了我的理解范围。

let test = (async () => {

  // data source
  const data = JSON.parse(`{
      "2021-11-17":{
         "rawWeight":220,
         "rca":1821
      },
      "2021-05-17":{
         "rawWeight":230,
         "rca":1600
      },
      "2021-03-09":{
         "rawWeight":224,
         "rca":1800
      },
      "2020-10-30":{
         "rawWeight":234.36,
         "rca":2851
      },
      "2020-10-13":{
         "rawWeight":225.54,
         "rca":2541
      },
      "2020-09-25":{
         "rawWeight":225.4,
         "rca":2588
      },
      "2020-1-10":{
         "rawWeight":244,
         "rca":1800
      }
  }`)

  // parse the date / time
  var parseTime = d3.timeParse("%Y-%m-%d");

  //structure dataset
  let dataset = []
  for (let day in data) {
    dataset.push({
      day: day,
      date: parseTime(day),
      weight: Number(data[day].rawWeight),
      calories: data[day].rca
    })
  }

  let margin = {
    top: 10,
    right: 20,
    bottom: 0,
    left: 20
  }
  //let width = document.querySelector('.pane[data-area="weight"] .chart').clientWidth - margin.left - margin.right
  let width = (dataset.length * 230) - margin.left - margin.right
  let height = 150 - margin.top - margin.bottom

  // set the ranges
  let x = d3.scaleTime().range([0, width])
  let y0 = d3.scaleLinear().range([height, 0])
  let y1 = d3.scaleLinear().range([height, 0])

  let linecalories = d3.line()
    .curve(d3.curveCatmullRom)
    .x(d => x(d.date))
    .y(d => y0(d.calories))

  let areacalories = d3.area()
    .curve(d3.curveCatmullRom)
    .x(d => x(d.date))
    .y0(height)
    .y1(d => y0(d.calories))

  let lineweight = d3.line()
    .curve(d3.curveCatmullRom)
    .x(d => x(d.date))
    .y(d => y1(d.weight))

  let areaweight = d3.area()
    .curve(d3.curveCatmullRom)
    .x(d => x(d.date))
    .y0(height)
    .y1(d => y1(d.weight))

  let svg = d3
    .select('.pane[data-area="weight"] .chart')
    .append("svg")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)
    .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")")

  // Scale the range of the data
  x.domain(d3.extent(dataset, d => d.date))
  y0.domain([0, d3.max(dataset, (d) => {
    // let rounded = Math.floor( Math.max(d.calories) / 500) * 500
    // return rounded + 1000
    return Math.max(d.calories) + 500
  })])
  y1.domain([
    // replace this with "0" to show scale from 0
    d3.min(dataset, d => Math.min(d.weight) - 25),
    d3.max(dataset, d => Math.max(d.weight) + 25)
  ])







  // gridlines in y axis function
  function make_y_gridlines() {
    return d3.axisLeft(y1)
      .ticks(8)
  }
  svg.append("g")
    .attr("class", "grid-y")
    .call(make_y_gridlines()
      .tickSize(-width)
      .ticks(5)
    )
    .call(g => g.select(".domain").remove())
    .call(g => g.selectAll("text").remove())

  // gridlines in x axis function
  function make_x_gridlines() {
    return d3.axisBottom(x)
      .ticks(20)
  }
  svg.append("g")
    .attr("class", "grid-x")
    .attr("transform", "translate(0," + height + ")")
    .call(make_x_gridlines()
      .tickSize(-height)
    )
    .call(g => g.select(".domain").remove())
    .call(g => g.selectAll("text").remove())













  // apply weight area
  svg.append("path")
    .data([dataset])
    .attr("class", "area-weight")
    .attr("d", areaweight)

  // apply calories area
  svg.append("path")
    .data([dataset])
    .attr("class", "area-calories")
    .attr("d", areacalories)

  // apply weight line
  svg.append("path")
    .data([dataset])
    .attr("class", "line-weight")
    .attr("d", lineweight)

  // apply calories line
  svg.append("path")
    .data([dataset])
    .attr("class", "line-calories")
    .attr("d", linecalories)


  svg.append("g")
    .attr("class", "axis-dates")
    .attr("transform", "translate(0," + (height + 8) + ")")
    .call(
      d3
      .axisBottom(x)
      .tickSize(0)
      .ticks(d3.utcMonth.every(1))
      .tickSizeOuter(0)
      .tickFormat(d3.timeFormat("%b %Y"))
      .tickPadding(-30)
    )
    .call(g => g.select(".domain").remove())


  // Add the Y0 Axis
  svg.append("g")
    .attr("class", "axis-calories")
    .attr("transform", "translate( " + width + ", 0 )")
    .call(
      d3
      .axisRight()
      .scale(y0)
      .tickSize(0)
      .ticks(height / 30)
      .tickFormat(d => {
        if ((d / 1000) >= 1)
          d = d / 1000 + "K";
        return d
      })

    )
    .call(g => g.select(".domain").remove())

  // Add the Y1 Axis
  svg.append("g")
    .attr("class", "axis-weight")
    // .attr("transform", "translate( " + width + ", 0 )")
    .call(
      d3
      .axisLeft()
      .scale(y1)
      .tickSize(0)
      .ticks(height / 30)
    )
    .call(g => g.select(".domain").remove())



  // dates
  // svg.append("g")
  //    .attr("class", "axis-dates")
  //    .attr("transform", "translate(0," + (height + 8) + ")")
  //    .call(
  //        d3
  //            .axisBottom(x)
  //            .ticks(0)
  //            .tickValues(x.domain())
  //            .tickFormat(d3.timeFormat("%b %Y"))
  //            .tickPadding(-30)
  //    )
  //   .call(g => g.select(".domain").remove())
  //   .call(g => g.selectAll("line").remove())
  //   .call(g => g.select(".tick:first-of-type text").attr('transform', 'translate(32,0)'))
  //   .call(g => g.select(".tick:last-of-type text").attr('transform', 'translate(-32,0)'))

  // remove 0 label
  svg.selectAll(".tick text")
    .filter(function(d) {
      return d === 0
    })
    .remove()






  let colors = ['#56ab2f', '#a8e063']
  let grad = svg.append('defs')
    .append('linearGradient')
    .attr('id', 'calorieline')
    .attr('x1', '0%')
    .attr('x2', '100%')
    .attr('y1', '0%')
    .attr('y2', '100%');

  grad.selectAll('stop')
    .data(colors)
    .enter()
    .append('stop')
    .style('stop-color', function(d) {
      return d;
    })
    .attr('offset', function(d, i) {
      return 100 * (i / (colors.length - 1)) + '%';
    })

  colors = ['rgba(86, 171, 47, .15)', 'transparent']
  grad = svg.append('defs')
    .append('linearGradient')
    .attr('id', 'greenfade')
    .attr('x1', '0%')
    .attr('x2', '0%')
    .attr('y1', '0%')
    .attr('y2', '100%');

  grad.selectAll('stop')
    .data(colors)
    .enter()
    .append('stop')
    .style('stop-color', function(d) {
      return d;
    })
    .attr('offset', function(d, i) {
      return 100 * (i / (colors.length - 1)) + '%';
    })

  colors = ['rgba(32, 120, 227, .15)', 'transparent']
  grad = svg.append('defs')
    .append('linearGradient')
    .attr('id', 'bluefade')
    .attr('x1', '0%')
    .attr('x2', '0%')
    .attr('y1', '0%')
    .attr('y2', '100%');

  grad.selectAll('stop')
    .data(colors)
    .enter()
    .append('stop')
    .style('stop-color', function(d) {
      return d;
    })
    .attr('offset', function(d, i) {
      return 100 * (i / (colors.length - 1)) + '%';
    })

  colors = ['#2894f2', '#1d6cdc']
  grad = svg.append('defs')
    .append('linearGradient')
    .attr('id', 'goodbar')
    .attr('x1', '0%')
    .attr('x2', '25%')
    .attr('y1', '0%')
    .attr('y2', '100%');

  grad.selectAll('stop')
    .data(colors)
    .enter()
    .append('stop')
    .style('stop-color', function(d) {
      return d;
    })
    .attr('offset', function(d, i) {
      return 100 * (i / (colors.length - 1)) + '%';
    })


});

test();
body {
  background: #1e2546;
  width: 500px;
  display: block;
}

.chart .axis-dates text {
  font-size: .7rem;
  fill: #fff;

}

[data-area=weight] .chart .axis-dates .tick {
  margin-left: -100px;
  left: 10rem;
  position: relative
}

[data-area=weight] .chart .line-calories {
  stroke-linecap: round;
  stroke-width: .2rem;
  stroke: url(#calorieline);
  fill: none
}

[data-area=weight] .chart .area-calories {
  fill: url(#greenfade);
}

[data-area=weight] .chart .area-weight {
  fill: url(#bluefade)
}
[data-area=weight] .chart .axis-calories text {
  fill: url(#calorieline);
  font-size: .6rem;
  font-weight: 500
}

[data-area=weight] .chart .line-weight {
  stroke-linecap: round;
  stroke-width: .2rem;
  stroke: url(#goodbar);
  fill: transparent
}

[data-area=weight] .chart .axis-weight text {
  font-size: .6rem;
  font-weight: 500;
  fill: url(#goodbar);

}

[data-area=weight] .chart .grid-x line,
[data-area=weight] .chart .grid-y line {
  stroke: rgba(0, 0, 0, .1);
  stroke-width: .1rem
}

[data-area=weight] .chart .label-calorie {
  font-size: .7rem
}

[data-area=weight] .chart .legend {
  -webkit-column-count: 2;
  -moz-column-count: 2;
  column-count: 2;
  -webkit-column-gap: .5rem;
  -moz-column-gap: .5rem;
  column-gap: .5rem;
  margin: .3rem auto 0 auto;
  width: 9rem;
  font-size: .9rem;
  text-align: center;
  position: relative
}

[data-area=weight] .chart .legend .i {
  display: block;
  vertical-align: top;
  width: 100%
}

[data-area=weight] .chart .legend .i:before {
  content: '';
  display: inline-block;
  width: .6rem;
  height: .3rem;
  border-radius: 5rem;
  margin-right: .3rem;
  vertical-align: .1rem
}

[data-area=weight] .chart .legend .calorie {
  color: #a7e063
}

[data-area=weight] .chart .legend .calorie:before {
  background: #a7e063
}

[data-area=weight] .chart .legend .weight {
  color: #2482e9
}

[data-area=weight] .chart .legend .weight:before {
  background: #2482e9
}
<!DOCTYPE html>
<html>

  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>JS Bin</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.4/d3.min.js" integrity="sha512-T+1zstV6Llwh/zH+uoc1rJ7Y8tf9N+DiC0T3aL0+0blupn5NkBT52Avsa0l+XBnftn/14EtxpsztAWsmiAaqfQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>

  <body>
    <div class="pane" data-area="weight">
      <div class="chart"></div>
    </div>
  </body>

</html>

这是一个 JSFiddle:https://jsfiddle.net/8h5fxn46/

我会在回答前说在同一张图表上有两个 y 轴通常 not recommended. Likewise, if you only show part of the line chart at once and force the reader to scroll to see the rest of the chart, then it will be harder for them to make comparisons and identify trends across the whole dataset. Doing so would require them to remember what the data that's not currently shown looks like. Using brush and zoom or zooming 可能更好,因为 reader 都可以得到整个折线图的概览并专注于它的特定部分。

话虽如此,根据您链接到的 Observable 示例,您可以通过以下方式在具有两个 y 轴的图表上滚动。基本思想是将 y 轴放在一个 SVG 元素中。然后图表的其余部分将放入另一个 SVG 元素中,放置在 div 中。 div 将处理滚动。我们会将两个 SVG 叠加在一起。

<!-- references https://observablehq.com/@d3/pannable-chart -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <script src="https://d3js.org/d3.v7.js"></script>
</head>

<body>
    <div id="chart"></div>

    <script>
      // data prepartion

      const rawData = {
        "2021-11-17": { rawWeight: 220, rca: 1821 },
        "2021-05-17": { rawWeight: 230, rca: 1600 },
        "2021-03-09": { rawWeight: 224, rca: 1800 },
        "2020-10-30": { rawWeight: 234.36, rca: 2851 },
        "2020-10-13": { rawWeight: 225.54, rca: 2541 },
        "2020-09-25": { rawWeight: 225.4, rca: 2588 },
        "2020-1-10": { rawWeight: 244, rca: 1800 },
      };

      const parseTime = d3.timeParse("%Y-%m-%d");

      const dataset = Object.entries(rawData).map(([date, { rawWeight, rca }]) => ({
        date: parseTime(date),
        weight: rawWeight,
        calories: rca,
      }));

      // set up

      const margin = { top: 30, bottom: 30, left: 30, right: 30 };

      const viewableWidth = 600;
      const totalWidth = dataset.length * 230;
      const height = 200;

      const parent = d3.select('#chart');

      const yAxesSvg = parent.append('svg')
          .attr('width', viewableWidth)
          .attr('height', height)
          .style('position', 'absolute')
          .style('pointer-events', 'none')
          .style('z-index', 1);

      const body = parent.append('div')
          .style('overflow-x', 'scroll')
          .style('max-width', `${viewableWidth}px`)
          .style('-webkit-overflow-scrolling', 'touch');

      const mainSvg = body.append('svg')
          .attr('width', totalWidth)
          .attr('height', height)
          .style('display', 'block');

      // scales

      const x = d3.scaleTime()
          .domain(d3.extent(dataset, d => d.date))
          .range([margin.left, totalWidth - margin.right]);

      const yWeight = d3.scaleLinear()
          .domain([0, d3.max(dataset, d => d.weight)])
          .range([height - margin.bottom, margin.top]);

      const yCalories = d3.scaleLinear()
          .domain([0, d3.max(dataset, d => d.calories)])
          .range([height - margin.bottom, margin.top]);

      // line generators

      const weightLine = d3.line()
          .x(d => x(d.date))
          .y(d => yWeight(d.weight));

      const caloriesLine = d3.line()
          .x(d => x(d.date))
          .y(d => yCalories(d.calories));

      // axes

      // x axis
      mainSvg.append('g')
          .attr('transform', `translate(0,${height - margin.bottom})`)
          .call(d3.axisBottom(x)
              .tickSize(0)
              .ticks(d3.timeMonth.every(1))
              .tickSizeOuter(0)
              .tickFormat(d3.timeFormat("%b %Y")))
          .call(g => g.select(".domain").remove());

      // weight axis
      yAxesSvg.append('g')
          .attr('transform', `translate(${margin.left},0)`)
          // add white background rectangle so that the lines won't overlap the axis
          .call(g => g.append('rect')
              .attr('fill', 'white')
              .attr('width', margin.left)
              .attr('x', -margin.left)
              .attr('y', 0)
              .attr('height', height))
          .call(d3.axisLeft(yWeight)
              .tickSize(0)
              .ticks(height / 30))
          .call(g => g.select(".domain").remove())
          // change color of tick labels
          .call(g => g.selectAll('.tick > text').attr('fill', 'blue'))
          // add axis label
          .call(g => g.append('text')
              .attr('fill', 'blue')
              .attr('text-anchor', 'start')
              .attr('dominant-baseline', 'hanging')
              .attr('font-weight', 'bold')
              .attr('y', 0)
              .attr('x', -margin.left)
              .text('Weight'));

      // calories axis
      yAxesSvg.append("g")
          .attr("transform", `translate(${viewableWidth - margin.right},0)`)
          // add white background rectangle so that the lines won't overlap the axis
          .call(g => g.append('rect')
              .attr('fill', 'white')
              .attr('x', 0)
              .attr('width', margin.right)
              .attr('y', 0)
              .attr('height', height))
          .call(d3.axisRight(yCalories)
              .tickSize(0)
              .ticks(height / 30, '~s'))
          // change color of tick labels
          .call(g => g.selectAll('.tick > text').attr('fill', 'green'))
          .call(g => g.select(".domain").remove())
          // add axis label
          .call(g => g.append('text')
              .attr('fill', 'green')
              .attr('text-anchor', 'end')
              .attr('dominant-baseline', 'hanging')
              .attr('font-weight', 'bold')
              .attr('y', 0)
              .attr('x', margin.right)
              .text('Calories'));

      // lines

      mainSvg.append('g')
        .datum(dataset)
        .append('path')
            .attr('stroke', 'blue')
            .attr('fill', 'none')
            .attr('d', weightLine);

      mainSvg.append('g')
        .datum(dataset)
        .append('path')
            .attr('stroke', 'green')
            .attr('fill', 'none')
            .attr('d', caloriesLine);
    </script>
</body>
</html>