0
votes

I have a project where I am using D3 js to create a few charts. I am trying to make these charts responsive when the window size changes. To do this I already used viewbox to define the svg:

var svg = d3
      .select(this.$refs["chart"])
      .classed("svg-container", true)
      .append("svg")
      .attr("class", "chart")
      .attr(
        "viewBox",
        `0 0 ${width + margin.left + margin.right} ${height +
          margin.top +
          margin.bottom}`
      )
      .attr("preserveAspectRatio", "xMinYMin meet")
      .classed("svg-content-responsive", true)
      .append("g")
      .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

I also use to set the width and height the same as the div where the SVG is inside. So that this chart uses the same size as the div it is inside:

 width = this.$refs["chart"].clientWidth - margin.left - margin.right,
 height = this.$refs["chart"].clientHeight - margin.top - margin.bottom;

The width and height of this div is set to 100% of it's parent div. So when I am resizing the window the div where the svg is in can change size and aspect ratio. So this is what the chart looks initially when the page is loaded. So it's getting its height and width from the div it is in:

enter image description here enter image description here

But when i resize the chart shrinks to still fit inside the new width of the parent div. But the height changes with it. So I assume that the aspect ratio stays the same:

enter image description here enter image description here

I have tried to update the svg viewport when the window resizes. But the vieuwport isn't being updated when I inspect the SVG element in DOM of the developer tools in Chrome. I have added console logs to check if the width and height of the parent also change and they seem to change. But the updated viewport doesn't gets applied to the svg:

d3.select(window).on("resize", () => {
      svg.attr(
        "viewBox",
        `0 0 ${this.$refs["chart"].clientWidth} ${this.$refs["chart"].clientHeight}`
      );
    });

<!DOCTYPE html>
<html>

<head>
  <title></title>
  <script src="https://unpkg.com/vue"></script>
  <script src="https://d3js.org/d3.v6.js"></script>
  <style>
    .area {
      fill: url(#area-gradient);
      stroke-width: 0px;
    }
    
    body{
      width: 100%;
      height: 100%;
    }
    
    .app{
      width: 100%;
      height: 100%;
    }
    
    #page{
      width: 100%;
      height: 100%;
    }
    
    .my_dataviz{
      width: 100%;
      height: 100%;
    }
  </style>
</head>

<body>
  <div id="app">
    <div class="page">
        <div id="my_dataviz" ref="chart"></div>
    </div>
  </div>

  <script>
    new Vue({
      el: '#app',
      data: {
        type: Array,
        required: true,
      },
      mounted() {

        const minScale = 0,
          maxScale = 35;

        var data = [{
            key: 'One',
            value: 33,
          },
          {
            key: 'Two',
            value: 30,
          },
          {
            key: 'Three',
            value: 37,
          },
          {
            key: 'Four',
            value: 28,
          },
          {
            key: 'Five',
            value: 25,
          },
          {
            key: 'Six',
            value: 15,
          },
        ];

        console.log(this.$refs["chart"].clientHeight)

        // set the dimensions and margins of the graph
        var margin = {
            top: 20,
            right: 0,
            bottom: 30,
            left: 40
          },
          width =
          this.$refs["chart"].clientWidth - margin.left - margin.right,
          height =
          this.$refs["chart"].clientHeight - margin.top - margin.bottom;

        // set the ranges
        var x = d3.scaleBand().range([0, width]).padding(0.3);
        var y = d3.scaleLinear().range([height, 0]);

        // append the svg object to the body of the page
        // append a 'group' element to 'svg'
        // moves the 'group' element to the top left margin
        var svg = d3
          .select(this.$refs['chart'])
          .classed('svg-container', true)
          .append('svg')
          .attr('class', 'chart')
          .attr(
            'viewBox',
            `0 0 ${width + margin.left + margin.right} ${
                height + margin.top + margin.bottom
              }`
          )
          .attr('preserveAspectRatio', 'xMinYMin meet')
          .classed('svg-content-responsive', true)
          .append('g')
          .attr(
            'transform',
            'translate(' + margin.left + ',' + margin.top + ')'
          );

        // format the data
        data.forEach(function(d) {
          d.value = +d.value;
        });

        // Scale the range of the data in the domains
        x.domain(
          data.map(function(d) {
            return d.key;
          })
        );
        y.domain([minScale, maxScale]);

        //Add horizontal lines
        let oneFourth = (maxScale - minScale) / 4;

        svg
          .append('svg:line')
          .attr('x1', 0)
          .attr('x2', width)
          .attr('y1', y(oneFourth))
          .attr('y2', y(oneFourth))
          .style('stroke', 'gray');

        svg
          .append('svg:line')
          .attr('x1', 0)
          .attr('x2', width)
          .attr('y1', y(oneFourth * 2))
          .attr('y2', y(oneFourth * 2))
          .style('stroke', 'gray');

        svg
          .append('svg:line')
          .attr('x1', 0)
          .attr('x2', width)
          .attr('y1', y(oneFourth * 3))
          .attr('y2', y(oneFourth * 3))
          .style('stroke', 'gray');

        //Defenining the tooltip div
        let tooltip = d3
          .select('body')
          .append('div')
          .attr('class', 'tooltip')
          .style('position', 'absolute')
          .style('top', 0)
          .style('left', 0)
          .style('opacity', 0);

        // append the rectangles for the bar chart
        svg
          .selectAll('.bar')
          .data(data)
          .enter()
          .append('rect')
          .attr('class', 'bar')
          .attr('x', function(d) {
            return x(d.key);
          })
          .attr('width', x.bandwidth())
          .attr('y', function(d) {
            return y(d.value);
          })
          .attr('height', function(d) {

            console.log(height, y(d.value))
            return height - y(d.value);
          })
          .attr('fill', '#206BF3')
          .attr('rx', 5)
          .attr('ry', 5)
          .on('mouseover', (e, i) => {
            d3.select(e.currentTarget).style('fill', 'white');
            tooltip.transition().duration(500).style('opacity', 0.9);
            tooltip
              .html(
                `<div><h1>${i.key} ${
                    this.year
                  }</h1><p>${converter.addPointsToEveryThousand(
                    i.value
                  )} kWh</p></div>`
              )
              .style('left', e.pageX + 'px')
              .style('top', e.pageY - 28 + 'px');
          })
          .on('mouseout', (e) => {
            d3.select(e.currentTarget).style('fill', '#206BF3');
            tooltip.transition().duration(500).style('opacity', 0);
          });

        // Add the X Axis and styling it
        let xAxis = svg
          .append('g')
          .attr('transform', 'translate(0,' + height + ')')
          .call(d3.axisBottom(x));

        xAxis
          .select('.domain')
          .attr('stroke', 'gray')
          .attr('stroke-width', '3px');
        xAxis.selectAll('.tick text').attr('color', 'gray');
        xAxis.selectAll('.tick line').attr('stroke', 'gray');

        // add the y Axis and styling it also only show 0 and max tick
        let yAxis = svg.append('g').call(
          d3
          .axisLeft(y)
          .tickValues([this.minScale, this.maxScale])
          .tickFormat((d) => {
            if (d > 1000) {
              d = Math.round(d / 1000);
              d = d + 'K';
            }
            return d;
          })
        );

        yAxis
          .select('.domain')
          .attr('stroke', 'gray')
          .attr('stroke-width', '3px');
        yAxis.selectAll('.tick text').attr('color', 'gray');
        yAxis.selectAll('.tick line').attr('stroke', 'gray');

        d3.select(window).on('resize', () => {
          svg.attr(
            'viewBox',
            `0 0 ${this.$refs['chart'].clientWidth} ${this.$refs['chart'].clientHeight}`
          );
        });
      },
    });
  </script>
</body>

</html>
1
I attempted to recreate your issue here. Adding in some missing variables from your code snippet above and your code seems to work just fine.Mark
@Mark Thank you for getting my code to work in a browser editor. The problem I am getting is that when the parent div changes size due to resizing the window. Then graph will shrink but the aspect ratio doesn't change. Then it does not take the full height of the parent div any more. It does still fit in the parent div. But I want to have the graph the dynamically update the height and width to always fully be displayed in the parent div.Stephen
@Mark I also believe that the problem might be because I use .attr("preserveAspectRatio", "xMinYMin meet").Stephen

1 Answers

1
votes

There are different approaches to "responsivity" with SVG and in D3 in particular. Using viewBox is one way to handle it, listening for resize events and redrawing the svg is another. If you're going to listen for resize events and re-render, you'll want to make sure you're using the D3 general update pattern.

1. Behavior you're seeing is expected when using viewBox and preserveAspectRatio.

2. In your example Vue and D3 seem to be in conflict over who is in control of the DOM.

enter image description here

Here are some examples to dynamic resizing using different approaches. Run them in full-size windows and use the console to log out the viewport dimensions.

Sara Soueidan's article Understanding SVG Coordinate Systems is really good. Curran Kelleher's example here uses the general update pattern for something that's more idiomatic.

Really hope this helps and good luck with the project! If you find that this answers your question, please mark it as the accepted answer. 👍

Forcing D3 to recalculate the size of the rects and axes on resize events ("sticky" to size of container):

const margin = {top: 20, right: 20, bottom: 50, left: 20}
const width = document.body.clientWidth
const height = document.body.clientHeight
  
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;

const minScale = 0,
      maxScale = 35;

const xScale = d3.scaleBand()
    .range([0, width])
    .padding(0.3);;

const yScale = d3.scaleLinear()
    .range([0, height]);

const xAxis = d3.axisBottom(xScale)

const yAxis = d3.axisLeft(yScale)

const svg = d3.select("#chart")
    .attr("width", width + margin.left + margin.right)
    .attr("height", height + margin.top + margin.bottom)
  .append("g")
    .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

const data = [
  {
    key: 'One',
    value: 33,
  },
  {
    key: 'Two',
    value: 30,
  },
  {
    key: 'Three',
    value: 37,
  },
  {
    key: 'Four',
    value: 28,
  },
  {
    key: 'Five',
    value: 25,
  },
  {
    key: 'Six',
    value: 15,
  },
];

// format the data
data.forEach((d) => {
  d.value = +d.value;
});

// Scale the range of the data in the domains
xScale.domain(data.map((d) => d.key));
yScale.domain([minScale, maxScale]);

svg.append("g")
    .attr("class", "y axis")
    .call(yAxis);

svg.append("g")
    .attr("class", "x axis")
    .call(xAxis)
    .attr("transform", "translate(0," + height + ")")
  .append("text")
    .attr("class", "label")
    .attr("transform", "translate(" + width / 2 + "," + margin.bottom / 1.5 + ")")
    .style("text-anchor", "middle")
    .text("X Axis");


svg.selectAll(".bar")
    .data(data)
  .enter().append("rect")
    .attr("class", "bar")
    .attr("width", xScale.bandwidth())
    .attr('x', (d) => xScale(d.key))
    .attr("y", d => yScale(d.value))
    .attr('height', function (d) {
      return height - yScale(d.value);
    })
    .attr('fill', '#206BF3')
    .attr('rx', 5)
    .attr('ry', 5);



// Define responsive behavior
function resize() {
  var width = parseInt(d3.select("#chart").style("width")) - margin.left - margin.right,
  height = parseInt(d3.select("#chart").style("height")) - margin.top - margin.bottom;

  // Update the range of the scale with new width/height
  xScale.rangeRound([0, width], 0.1);
  yScale.range([height, 0]);

  // Update the axis and text with the new scale
  svg.select(".x.axis")
    .call(xAxis)
    .attr("transform", "translate(0," + height + ")")
    .select(".label")
      .attr("transform", "translate(" + width / 2 + "," + margin.bottom / 1.5 + ")");

  svg.select(".y.axis")
    .call(yAxis);

  // Force D3 to recalculate and update the line
  svg.selectAll(".bar")
    .attr("width", xScale.bandwidth())
    .attr('x', (d) => xScale(d.key))
    .attr("y", d => yScale(d.value))
    .attr('height', (d) => height - yScale(d.value));
};

// Call the resize function whenever a resize event occurs
d3.select(window).on('resize', resize);

// Call the resize function
resize();
.bar {
  fill: #206BF3;
}

.bar:hover {
  fill: red;
  cursor: pointer;
}

.axis {
  font: 10px sans-serif;
}

.axis path,
.axis line {
  fill: none;
  stroke: #000;
  shape-rendering: crispEdges;
}

#chart {
  width: 100%;
  height: 100%;
  position: absolute;
}
<!DOCTYPE html>
<meta charset="utf-8">
<head>
  <link rel="stylesheet" type="text/css" href="./style.css" />
</head>
<body>
<svg id="chart"></svg>
<script src="https://d3js.org/d3.v6.js"></script>

<script src="./chart.js"></script>
</body>

Using general update pattern (with transition to illustrate changes):

let data = [
  {letter: 'A', frequency: 20},
  {letter: 'B', frequency: 60},
  {letter: 'C', frequency: 30},
  {letter: 'D', frequency: 20},
];

chart(data);

function chart(data) {

  var svg = d3.select("#chart"),
    margin = {top: 55, bottom: 0, left: 85, right: 0},
    width  = parseInt(svg.style("width")) - margin.left - margin.right,
    height = parseInt(svg.style("height")) - margin.top - margin.bottom;

  // const barWidth = width / data.length
  
  const xScale = d3.scaleBand()
    .domain(data.map(d => d.letter))
    .range([margin.left, width - margin.right])
    .padding(0.5)

  const yScale = d3.scaleLinear()
    .domain([0, d3.max(data, d => d.frequency)])
    .range([0, height])

  const xAxis = svg.append("g")
    .attr("class", "x-axis")

  const yAxis = svg.append("g")
    .attr("class", "y-axis")

  redraw(width, height);

  function redraw(width, height) {

    yScale.range([margin.top, height - margin.bottom])
  
    svg.selectAll(".y-axis")
      .attr("transform", `translate(${margin.left},0)`)
      .call(d3.axisLeft(yScale)
        .ticks(data, d => d.frequency)
        .tickFormat(function(d, i) {
          return data[i].frequency;
          }));
  
    xScale.rangeRound([margin.left, width - margin.right]);

    svg.selectAll(".x-axis").transition().duration(0)
      .attr("transform", `translate(0,${height})`)
      .call(d3.axisBottom(xScale));
  
    var bar = svg.selectAll(".bar")
      .data(data)

    bar.exit().remove();

    bar.enter().append("rect")
      .attr("class", "bar")
      .style("fill", "steelblue")
      .merge(bar)
      // origin of each rect is at top left corner, so width goes to right
      // and height goes to bottom :)
      .style('transform', 'scale(1, -1)')
    .transition().duration(1000)
      .attr("width", xScale.bandwidth())
      .attr("height", d => yScale(d.frequency))
      .attr("y", -height)
      .attr("x", d => xScale(d.letter))
      .attr("transform", (d, i) => `translate(${0},${0})`)
  }

  d3.select(window).on('resize', function() {
    width = parseInt(svg.style("width")) - margin.left - margin.right,
    height = parseInt(svg.style("height")) - margin.top - margin.bottom;
    redraw(width, height);
  });
}
<!DOCTYPE html>
<html>
<head>
  <title>Bar Chart - redraw on window resize</title>

  <style>
    #chart {
      outline: 1px solid red;
      position: absolute;
      width: 95%;
      height: 95%;
      overflow: visible;
    }
  </style>
</head>
<body>

<script type="text/javascript">
  var windowWidth = window.innerWidth;
  var windowHeight = window.innerHeight;
  console.log('viewport width is: '+ windowWidth + ' and viewport height is: ' + windowHeight + '. Resize the browser window to fire the resize event.');
</script>
  
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.5.0/d3.min.js"></script>
<svg id="chart"></svg>

<script src="./responsiveBarWindowWidth.js"></script>
</body>
</html>

And here is your graph, only instead of a hard-coded value of 500px for the #my_dataviz parent, assign it a value of 100vh, which allows the svg to respond to the parent container's height and adjust the width accordingly.

Plunker: https://plnkr.co/edit/sBa6VmRH27xcgNiB?preview

Assigning height of 100vh to parent container

<!DOCTYPE html>
<html>
  <head>
    <title></title>
    <script src="https://unpkg.com/vue"></script>
    <script src="https://d3js.org/d3.v6.js"></script>
    <style>
      .area {
        fill: url(#area-gradient);
        stroke-width: 0px;
      }
      // changed from 500px:
      #my_dataviz {
        height: 100vh
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="page">
        <div class="">
          <div id="my_dataviz" ref="chart"></div>
        </div>
      </div>
    </div>

    <script>
      new Vue({
        el: '#app',
        data: {
          type: Array,
          required: true,
        },
        mounted() {

          const minScale = 0,
                maxScale = 35;

          var data = [
            {
              key: 'One',
              value: 33,
            },
            {
              key: 'Two',
              value: 30,
            },
            {
              key: 'Three',
              value: 37,
            },
            {
              key: 'Four',
              value: 28,
            },
            {
              key: 'Five',
              value: 25,
            },
            {
              key: 'Six',
              value: 15,
            },
          ];

          console.log(this.$refs["chart"].clientHeight)

          // set the dimensions and margins of the graph
          var margin = { top: 20, right: 0, bottom: 30, left: 40 },
            width =
              this.$refs["chart"].clientWidth - margin.left - margin.right,
            height =
              this.$refs["chart"].clientHeight - margin.top - margin.bottom;

          // set the ranges
          var x = d3.scaleBand().range([0, width]).padding(0.3);
          var y = d3.scaleLinear().range([height, 0]);

          // append the svg object to the body of the page
          // append a 'group' element to 'svg'
          // moves the 'group' element to the top left margin
          var svg = d3
            .select(this.$refs['chart'])
            .classed('svg-container', true)
            .append('svg')
            .attr('class', 'chart')
            .attr(
              'viewBox',
              `0 0 ${width + margin.left + margin.right} ${
                height + margin.top + margin.bottom
              }`
            )
            .attr('preserveAspectRatio', 'xMinYMin meet')
            .classed('svg-content-responsive', true)
            .append('g')
            .attr(
              'transform',
              'translate(' + margin.left + ',' + margin.top + ')'
            );

          // format the data
          data.forEach(function (d) {
            d.value = +d.value;
          });

          // Scale the range of the data in the domains
          x.domain(
            data.map(function (d) {
              return d.key;
            })
          );
          y.domain([minScale, maxScale]);

          //Add horizontal lines
          let oneFourth = (maxScale - minScale) / 4;

          svg
            .append('svg:line')
            .attr('x1', 0)
            .attr('x2', width)
            .attr('y1', y(oneFourth))
            .attr('y2', y(oneFourth))
            .style('stroke', 'gray');

          svg
            .append('svg:line')
            .attr('x1', 0)
            .attr('x2', width)
            .attr('y1', y(oneFourth * 2))
            .attr('y2', y(oneFourth * 2))
            .style('stroke', 'gray');

          svg
            .append('svg:line')
            .attr('x1', 0)
            .attr('x2', width)
            .attr('y1', y(oneFourth * 3))
            .attr('y2', y(oneFourth * 3))
            .style('stroke', 'gray');

          //Defenining the tooltip div
          let tooltip = d3
            .select('body')
            .append('div')
            .attr('class', 'tooltip')
            .style('position', 'absolute')
            .style('top', 0)
            .style('left', 0)
            .style('opacity', 0);

          // append the rectangles for the bar chart
          svg
            .selectAll('.bar')
            .data(data)
            .enter()
            .append('rect')
            .attr('class', 'bar')
            .attr('x', function (d) {
              return x(d.key);
            })
            .attr('width', x.bandwidth())
            .attr('y', function (d) {
              return y(d.value);
            })
            .attr('height', function (d) {

              console.log(height, y(d.value))
              return height - y(d.value);
            })
            .attr('fill', '#206BF3')
            .attr('rx', 5)
            .attr('ry', 5)
            .on('mouseover', (e, i) => {
              d3.select(e.currentTarget).style('fill', 'white');
              tooltip.transition().duration(500).style('opacity', 0.9);
              tooltip
                .html(
                  `<div><h1>${i.key} ${
                    this.year
                  }</h1><p>${converter.addPointsToEveryThousand(
                    i.value
                  )} kWh</p></div>`
                )
                .style('left', e.pageX + 'px')
                .style('top', e.pageY - 28 + 'px');
            })
            .on('mouseout', (e) => {
              d3.select(e.currentTarget).style('fill', '#206BF3');
              tooltip.transition().duration(500).style('opacity', 0);
            });

          // Add the X Axis and styling it
          let xAxis = svg
            .append('g')
            .attr('transform', 'translate(0,' + height + ')')
            .call(d3.axisBottom(x));

          xAxis
            .select('.domain')
            .attr('stroke', 'gray')
            .attr('stroke-width', '3px');
          xAxis.selectAll('.tick text').attr('color', 'gray');
          xAxis.selectAll('.tick line').attr('stroke', 'gray');

          // add the y Axis and styling it also only show 0 and max tick
          let yAxis = svg.append('g').call(
            d3
              .axisLeft(y)
              .tickValues([this.minScale, this.maxScale])
              .tickFormat((d) => {
                if (d > 1000) {
                  d = Math.round(d / 1000);
                  d = d + 'K';
                }
                return d;
              })
          );

          yAxis
            .select('.domain')
            .attr('stroke', 'gray')
            .attr('stroke-width', '3px');
          yAxis.selectAll('.tick text').attr('color', 'gray');
          yAxis.selectAll('.tick line').attr('stroke', 'gray');

          d3.select(window).on('resize', () => {
            svg.attr(
              'viewBox',
              `0 0 ${this.$refs['chart'].clientWidth} ${this.$refs['chart'].clientHeight}`
            );
          });
        },
      });
    </script>
  </body>
</html>