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

Time:09-26

I have a chart that uses set widths for each x axis item, and because of this the chart is scrollable when lots of dates get added.

What I want to do is have both Y axis labels always be on screen (I can't do position: fixed with CSS because the axis's are <g> elements.)

How can I have both Y axis labels always be on screen? (this is the blue and green text labels). Here's an example of what I mean: https://observablehq.com/@d3/pannable-chart) - unfortunately the code on that site codes over my head.

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>

Here's a JSFiddle: https://jsfiddle.net/8h5fxn46/

CodePudding user response:

I will preface my answer by saying that having two y-axes on the same chart is generally 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 may be better, since the reader can both get an overview of the entire line chart and also focus on specific parts of it.

With that being said, here's how you could have scrolling on a chart with two y-axes, based on the Observable example that you linked to. The basic idea is to put the y-axes in one SVG element. Then the rest of the chart will go in another SVG element, placed inside a div. The div will handle the scrolling. We'll layer the two SVGs on top of each other.

<!-- 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>

  • Related