Reputation: 1183
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:
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:
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>
Upvotes: 1
Views: 5133
Reputation: 518
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.
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>
Upvotes: 4