d3 zoom consumes event and prevents mouseover - javascript

I'm attempting to make a stream graph in d3 that is both zoomable and the paths are hoverable.
From reading around I see that as of v4 d3 zoom consumes the event and therefore may be why my mouseover event no longer fires, however no amount of reordering or pointer-events: all I set seems to have an effect.
Is anybody able to help me understand what I have to do to get both my zoom and hover working in the following example? (There's also a codesandbox with the example)
const width = 500;
const height = 500;
let numberOfDataPoints = 5;
const numberOfLayers = 3;
let data = [];
for (let i = 0; i < numberOfDataPoints; i++) {
let point = [];
for (let j = 0; j < numberOfLayers; j++) {
point.push(Math.floor(Math.random() * Math.floor(120)));
}
data.push(point);
}
const x = d3
.scaleLinear()
.domain([0, numberOfDataPoints - 1])
.range([0, width]);
const y = d3.scaleLinear().range([height, 0]);
const area = d3
.area()
.x((d, i) => x(i))
.y0(d => y(d[0]))
.y1(d => y(d[1]));
const stack = d3
.stack()
.keys(d3.range(numberOfLayers))
.offset(d3.stackOffsetWiggle)
.order(d3.stackOrderNone);
let layers = stack(data);
y.domain([
d3.min(layers, l => d3.min(l, d => d[0])),
d3.max(layers, l => d3.max(l, d => d[1]))
]);
update();
const zoomContainer = d3
.select('svg')
.append('rect')
.attr('width', width)
.attr('height', height)
.attr('fill', 'none')
.style('pointer-events', 'all');
const zoom = d3
.zoom()
.scaleExtent([1, 8])
.on('zoom', zoomed);
zoomContainer.call(zoom);
function update() {
let path = d3
.select('svg')
.selectAll('path')
.data(layers);
path
.enter()
.append('path')
.attr('fill', 'red')
.merge(path)
.attr('d', d => area(d))
.on('mouseover', () => {
d3.select('svg')
.selectAll('path')
.attr('fill', 'red');
d3.select(d3.event.target).attr('fill', 'green');
});
}
function zoomed() {
let transform = d3.event.transform;
transform.y = 0;
let updatedScale = transform.rescaleX(x);
d3.select('svg')
.selectAll('path')
.attr('d', area.x((d, i) => updatedScale(i)));
}

The hypothesis for why this didn't work was correct (the zoom container consumes the event), and the fix is to just remove the zoomContainer and apply the zoom to the svg instead i.e.
const zoom = d3
.zoom()
.on('zoom', zoomed);
d3.select('svg').call(zoom);

Related

Top line obscures bottom line in d3js svg chart

I'm using d3js to draw a chart which plots two data series as two lines.
However, parts of the bottom line (the blue line) are obscured:
Hiding either line by adding display: none in the browser's devel tools shows both lines fully rendered.
The rendered SVG looks like this (sorry for the picture, hard to copy the text):
Each path is created by its own D3 function because the vertical scales are different:
var theLineFcnA = d3.line()
.x(function (d) { return xScaleT(d.t); })
.y(function (d) { return yScaleA(d.v); });
var theLineFcnB = d3.line()
.x(function (d) { return xScaleT(d.t); })
.y(function (d) { return yScaleB(d.v); });
And called like this:
function plotLineA(plotData)
{
if (theSVG === null)
return;
// plot it
var theALine = theSVG.select('.lineChart').select("path.lineA");
theALine.data([plotData]);
theSVG.select("g.x.axis").call(xAxis);
theSVG.select("g.y.axisA").call(yAxisA);
theSVG.select("path.lineA").attr("d", theLineFcnA);
}
(there is a similar function for line B)
Any idea on how to fix this? I've fiddled around with various CSS properties on the line but not sure what else to do.
Many thanks
I suppose you set the width of the bottom curve (which should be the first path laid down) to be thicker than the that of the top curve. Here's an example:
let N = 12;
let n = 5;
let cur = 0;
let pts1 = d3.range(N).map(function (x) {
let step = 2 * d3.randomInt(0, 2)() - 1;
cur = cur + step;
return [x, cur];
});
cur = pts1[n - 1][1];
let pts2 = pts1.slice(0, n);
pts2 = pts2.concat(
d3.range(n, N).map(function (x) {
let step = 2 * d3.randomInt(0, 2)() - 1;
cur = cur + step;
return [x, cur];
})
);
let [ymin, ymax] = d3.extent(pts1.concat(pts2).map((d) => d[1]));
let width = 500;
let w = d3.min([800, width]);
let h = 0.625 * w;
let pad = 20;
let x_scale = d3
.scaleLinear()
.domain([0, N])
.range([pad, w - pad]);
let y_scale = d3
.scaleLinear()
.domain([ymin, ymax])
.range([h - pad, pad]);
let pts_to_path = d3
.line()
.x((d) => x_scale(d[0]))
.y((d) => y_scale(d[1]));
let svg = d3.select('#container')
.append("svg")
.attr("width", w)
.attr("height", h);
svg
.selectAll("path")
.data([pts1, pts2])
.join("path")
.attr("d", pts_to_path)
.attr("stroke", (_, i) => d3.schemeCategory10[i])
.attr("stroke-width", (_, i) => (i == 0 ? 6 : 2))
.attr("fill", "none")
svg
.append("g")
.attr("transform", `translate(0, ${h / 2})`)
.call(d3.axisBottom(x_scale));
svg
.append("g")
.attr("transform", `translate(${pad})`)
.call(d3.axisLeft(y_scale).ticks(4));
<script src="https://cdn.jsdelivr.net/npm/d3#7"></script>
<script src="https://cdn.jsdelivr.net/npm/#observablehq/plot#0.6"></script>
<div id="container"></div>

When applying the Y axis using d3, it throws an error

I am making a simple bar chart and am currently trying to add the Y axis. When I try to use the call() function, it throws an error.
d3.v7.min.js:2 Error: <path> attribute d: Expected number, "M-6,NaNH0VNaNH-6".
Does anyone know what's wrong? I provided my code bellow.
let dataNumsOnly = [];
let labels = [];
fetch('https://raw.githubusercontent.com/freeCodeCamp/ProjectReferenceData/master/GDP-data.json')
.then(response => response.json())
.then(data => {
let dataForChart = data;
dataForChart = dataForChart.data;
for (let i = 0; i < dataForChart.length; i++) { //grabs data and labels.
dataNumsOnly.push(dataForChart[i][1]);
labels.push(dataForChart[i][0]);
}
let svg = d3.select('body')
.append('svg')
.attr('width', 962)
.attr('height', 600);
svg.selectAll('rect')
.data(dataNumsOnly)
.enter()
.append('rect')
.attr('width', 3)
.attr('height', d => d / 32)
.attr("x", (d, i) => i * 3.5)
.attr('y', d => 600 - d / 32)
.style('fill', "rgb(51, 173, 255)");
let yScale = d3.scaleLinear()
.domain(0, 100)
.range(600, 0);
const yAxis = d3.axisLeft()
.scale(yScale);
svg.append('g')
.call(yAxis);
});
Thank you.
Never mind, I got it! I forgot the brackets for the domain and range!
let dataNumsOnly = [];
let labels = [];
fetch('https://raw.githubusercontent.com/freeCodeCamp/ProjectReferenceData/master/GDP-data.json')
.then(response => response.json())
.then(data => {
let dataForChart = data;
dataForChart = dataForChart.data;
for (let i = 0; i < dataForChart.length; i++) { //grabs data and labels.
dataNumsOnly.push(dataForChart[i][1]);
labels.push(dataForChart[i][0]);
}
let svg = d3.select('body')
.append('svg')
.attr('width', 962)
.attr('height', 600);
svg.selectAll('rect')
.data(dataNumsOnly)
.enter()
.append('rect')
.attr('width', 3)
.attr('height', d => d / 32)
.attr("x", (d, i) => i * 3.5)
.attr('y', d => 600 - d / 32)
.style('fill', "rgb(51, 173, 255)");
let yScale = d3.scaleLinear()
.domain([0, 1000])
.range([600, 0]);
const yAxis = d3.axisLeft()
.scale(yScale);
svg.append('g')
.attr('transform', 'translate(1)')
.call(yAxis)
});

Translate zoom after domain change

I have some time-series data whose domain changes: I can take the last 6-months, last year, the last 2 years, and so on. I've created a D3 chart that just displays data.
However, you can also zoom this chart, but when you zoom then change the domain, the zoom "resets" but works again when you click.
When the domain changes, I'd like to keep the current zoom: since it's timeseries data, I'd like it to be in the same place. How can I accomplish this?
<head>
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
</head>
<body>
<script>
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 600
const width = 800
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
let x;
let y;
const zoomed = (event) => {
let xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr("viewBox", [0, 0, width, height]);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
gX.call(xAxis, x);
gY.call(yAxis, y);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
}
// Buttons
const data = randomData()
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
</script>
</body>
Problem
If you use d3.zoom to zoom/pan, you need to let d3.zoom know when you've manually gone ahead and altered the pan/zoom. It doesn't "know" what sort of tampering you do outside of it. Further, if you are going to update the zoom status of an element so that d3.zoom "knows" of the change, why not use d3.zoom to actually do the zooming and panning too?
In your example, you use the zoom to set the scale of the data, but when you click on the buttons, you set the zoom by merely filtering the data. d3.zoom is none the wiser. That's why the jump occurs when you use a button and then the zoom - the zoom behavior picks up where it was last left.
Lastly, you have written two methods to zoom and pan, when you could just run it all through d3.zoom.
This is not an uncommon problem - here's an example of a the same principle at play.
Solution
Only use one method to zoom/pan. This way there is no need to sync the behavior and state of two separate mechanisms for zoom/pan. You can use d3.zoom for both programmatic zooms and standard zooming quite easily.
You'll find it easiest with a reference scale when dealing with axes and scales - this way zooming is relative to the original zoom state and not the last zoom state (which can cause problems). We use the reference scale each zoom event to rescale our working scale. The working scale is passed to the axis generator and used to position the data.
So in your case, our zoom function simply looks like:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
We rescale the xScale each time to reflect the new domain shown by the zoom transform provided by the zoom event.
This works for mouse interaction with no further modification. We can invoke the programmatic zoom with svg.call(zoom.transform, someZoomTransform), all we have to do is calculate the proper transform, using your code as an example this looked something like:
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))\
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // margin.left/k : account for scale range not starting at 0.
);
Putting that together we get:
const height = 500;
const width = 500;
const margin = { top: 20, right: 0, bottom: 30, left: 40 }
const svg = d3.select("body").append("svg")
.attr("width",width)
.attr("height",height);
var data = randomData();
// Set up Scales:
let xScale = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
// Reference to hold starting version of scale:
const xReference = xScale.copy();
let yScale = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
// Set up Zoom:
const zoomed = (event) => {
xScale.domain(event.transform.rescaleX(xReference).domain());
draw(data);
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([[margin.left, 0], [width - margin.right, height]])
.translateExtent([[margin.left, -Infinity], [width - margin.right, Infinity]])
.on("zoom", zoomed);
svg.call(zoom);
// Set up axes and miscellania
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(xScale).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(yScale))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// Draw:
function draw(data) {
gX.call(xAxis, xScale);
gY.call(yAxis, yScale);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xScale(d.date))
.y(d => yScale(d.value)))
}
// Button Behavior
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const endDate = lastDataDate;
const startDate = d3.timeMonth.offset(endDate,-months);
// k = width of range needed for data set / width of range needed for area of interest
const k = (xReference.range()[1] - xReference.range()[0]) / (xReference(endDate) - xReference(startDate))
// translate to account for starting point of area of interest.
const tx = xReference(startDate);
// let the zoom handle it.
svg.call(zoom.transform, d3.zoomIdentity
.scale(k)
.translate(-tx+margin.left/k, 0) // account for scale range not starting at 0.
);
})
draw(data);
// Random data
function randomData() {
function randn_bm() {
var u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>
One way to do this would be by storing the last zoom event in a variable, and, if one exists, then instead of redrawing the axis from scratch, calling zoomed() with that last event.
EDIT: I now understand your question better. What I did below was as follows:
Whenever a button is clicked, get first the zoomed domain xz;
Then see if we need to clamp it so the domain is a subset of the new data. xz.domain() must always fall within x.domain();
If that was the case, calculate the scaling factor and the point at the centre of the viewport;
Redraw the chart completely;
Ask d3 to scale to the correct ratio, using the previously calculated scale, then ask it to pan to the correct position, using the centre point previously calculated.
Furthermore, I've changed the y-domain to always be calculated with the entire data set. This makes sure the line doesn't jump vertically when any of the buttons are pushed.
There is no jumping around the x-axis, unless your viewport covers data that is no longer available after you click a button.
Testcases
The view should remain the same with all of these:
Click 'last year', then '2 years';
Click '2 years', then zoom into range Nov-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '2 years';
Click '6 months', then 'last year', then '2 years'.
The view should change with all of these:
Click '2 years', zoom out fully, then '6 months';
Click '2 years', then zoom into range Feb-Jan 2020. Click '6 months';
Click 'last year', zoom and pan until it covers Feb-Apr 2019. Click '6 months'.
// Random data
function randomData() {
function randn_bm() {
var u = 0,
v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
let days = []
let endDate = new Date(2020, 1, 0)
for (var d = new Date(2018, 0, 0); d <= endDate; d.setDate(d.getDate() + 1)) {
days.push(new Date(d));
}
return days.map(d => ({
date: d,
value: randn_bm()
}))
}
// Chart
const height = 400
const width = 800
const margin = {
top: 20,
right: 0,
bottom: 30,
left: 40
}
let x;
let y;
let xz;
const zoomed = (event) => {
xz = event.transform.rescaleX(x);
gX.call(xAxis, xz);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => xz(d.date))
.y(d => y(d.value)))
}
const zoom = d3.zoom()
.scaleExtent([1, 32])
.extent([
[margin.left, 0],
[width - margin.right, height]
])
.translateExtent([
[margin.left, -Infinity],
[width - margin.right, Infinity]
])
.on("zoom", zoomed);
const svg = d3.select("body").append("svg")
.attr('width', width)
.attr('height', height);
svg.call(zoom)
const gLine = svg.append("g").attr("class", "series").attr("clip-path", "url(#clip)")
const gX = svg.append("g").attr("class", "x-axis")
const gY = svg.append("g").attr("class", "y-axis")
const xAxis = (g, x) => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickSizeOuter(0))
const yAxis = (g, y) => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y))
.call(g => g.select(".domain").remove())
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.top)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
function renderChart(data) {
x = d3.scaleTime()
.domain(d3.extent(data, d => d.date))
.range([margin.left, width - margin.right])
let reScale = false,
domain,
centerPoint;
if(xz !== undefined) {
domain = xz.domain();
centerPoint = xz.invert((width - margin.left - margin.right) / 2);
// If the previous center completely falls out of the current bounds, draw the chart anew.
if(domain[1] < data[0].date || domain[0] > data[data.length - 1].date) {
// Nothing
} else {
// Else, clip the domain to fit the data.
if(domain[0] < data[0].date) {
domain[0] = data[0].date;
}
if(domain[1] > data[data.length - 1].date) {
domain[1] = data[data.length - 1].date;
}
reScale = true;
}
}
gY.call(yAxis, y);
gX.call(xAxis, x);
gLine.selectAll("path")
.data([data])
.join("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", d3.line()
.x(d => x(d.date))
.y(d => y(d.value)))
if(reScale) {
const scale = (x.domain()[1] - x.domain()[0])/(domain[1] - domain[0]);
svg.call(zoom.scaleTo, scale)
.call(zoom.translateTo, centerPoint, 0);
}
}
// Buttons
const data = randomData()
// To avoid jumpy behaviour, make sure the y-domain is steady
y = d3.scaleLinear()
.domain(d3.extent(data, d => d.value)).nice()
.range([height - margin.bottom, margin.top])
const lastDataDate = new Date(2020, 1, 0)
const buttons = d3.select(".buttons")
.selectAll("button")
.data([6, 12, 24])
.join("button")
.on("click", (_, months) => {
const startDate = new Date(lastDataDate)
startDate.setMonth(startDate.getMonth() - months)
const filteredData = data.filter(d => d.date > startDate)
renderChart(filteredData)
})
renderChart(data)
<script src="https://d3js.org/d3.v6.js"></script>
<div class="buttons">
<button id="sixmo">Last 6 months</button>
<button id="oneyear">Last year</button>
<button id="twoyears">Last 2 years</button>
</div>

How to build a compartmentalized heatmap

Using D3, I want to take the data visualization type of a classical heatmap...
.. onto a compartmentalized version of several heatmap groups drawing data from a single data source.
Technically this should be one heatmap element drawing its data from a single source - separation and thus clustering/grouping is supposed to happen through sorting the data in the *.csv file (group one, group two, group three..) and the D3 *.JS file handling the styling.
While generating a single map:
// Build X scales and axis:
const x = d3.scaleBand()
.range([0, width])
.domain(myGroups)
.padding(0.00);
svg.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x));
// Build Y scales and axis:
const y = d3.scaleBand()
.range([height, 0])
.domain(myVars)
.padding(0.00);
svg.append('g')
.call(d3.axisLeft(y));
assigning a color:
// Assign color scale
const myColor = d3.scaleLinear()
.range(['red', '#750606'])
.domain([1, 100]);
and fetching (sample) data:
// Read the data
d3.csv('https://raw.githubusercontent.com/holtzy/D3-graph-gallery/master/DATA/heatmap_data.csv', (data) => {
data.sort(function(a, b) {
return myVars.indexOf(b.variable) - myVars.indexOf(a.variable) || myGroups.indexOf(a.group) - myGroups.indexOf(b.group)
});
Has been working like a charm:
CodePen
I'm struggling to expand this basic structure onto the generation of multiple groups as described above. Expanding the color scheme, trying to build several additional X and Y axis that cover different ranges result in a complete break of the D3 element rendering the map unable to be displayed at all.
Can someone point me in the right direction on how to generate multiple heatmap groups without breaking the heatmap?
I was able to solve the compartmentalization using a row and column based procedure to construct the compartments:
// Dimensions
const numCategoryCols = 4;
const numCategoryRows = Math.ceil(grouped.length / numCategoryCols);
const numEntryCols = 3;
const numEntryRows = Math.ceil(grouped[0].values.length / numEntryCols);
const gridSize = 20;
const width = gridSize * numCategoryCols * numEntryCols;
const height = gridSize * numCategoryRows * numEntryRows;
const tooltipArrowSize = 8;
// Containers
const container = d3
.select("#" + containerId)
.classed("heatmap-grid", true)
.style("position", "relative");
const svg = container
.append("svg")
.style("display", "block")
.style("width", "100%")
.attr("viewBox", [0, 0, width, height])
.style("opacity", 0);
svg.transition()
.duration(3000)
.delay((d,i) => i*200)
.style("opacity", 1)
// Heatmap
const gCategory = svg
.selectAll(".category-g")
.data(grouped, (d) => d.key)
.join("g")
.attr("class", "category-g")
.attr("fill", (d) => color(d.key))
.attr("transform", (_, i) => {
const y = Math.floor(i / numCategoryCols);
const x = i % numCategoryCols;
return `translate(${gridSize * numEntryCols * x},${
gridSize * numEntryRows * y
})`;
});
const gEntry = gCategory
.selectAll(".entry-g")
.data((d) => d.values)
.join("g")
.attr("class", "entry-g")
.attr("transform", (_, i) => {
const y = Math.floor(i / numEntryCols);
const x = i % numEntryCols;
return `translate(${gridSize * x},${gridSize * y})`;
});
const entry = gEntry
.append("rect")
.attr("width", gridSize)
.attr("height", gridSize)
.attr("fill-opacity", (d) => d.Severity / 100)
.on("mouseenter", showTooltip)
.on("mouseleave", hideTooltip);

d3 zoom behavior when window is resized

I am using D3 to set up my chart area that makes use of the zoomable behavior. The chart includes both x and y axis.
The chart area should be responsive when window is resized. In this case, I need to reset the x and y axis domain and range on window resizing.
The issue happened with window resizing. I've noticed the zoom focus isn't lined up with the mouse anymore after the following steps:
First pan and zoom in the chart area
Then resize the window
Then pan and zoom the chart area again
After that, the above problem happened. See the following jsfiddle which has this issue.
So what's the right way of handing this? I've noticed a couple discussions about this issue such as:
d3 Preserve scale/translate after resetting range
I tried this approach but I couldn't make it working using D3 V4 API.
The main idea is the use the window.addEventListener to update the plot:
window.addEventListener('resize', () => {
// obtain the current transformation by calling the `d3.zoomTransform` function
const t0 = d3.zoomTransform(
chart.node());
// obtain the client width by evaluating the `div` (class: `zoom-chart`)
width = document.getElementById('zoom-chart').clientWidth;
height = 480;
// update the base scale
baseX.range([0, width])
.interpolate(d3.interpolateNumber);
baseY.range([height, 0])
.interpolate(d3.interpolateNumber);
// update the view port
vb = [0,0, width,height];
chart
.attr('viewBox', vb);
// replot with the original transform
chart.call(zoom.transform, t0);
});
I also added the whole solution here:
const data = [
{x:1 , y:1},
{x:9 , y:1},
{x:9 , y:9},
{x:1 , y:9},
];
var width = document.getElementById('zoom-chart').clientWidth;
var height = 480;
var baseX = d3.scaleLinear().domain([0,10])
.range([0, width])
.interpolate(d3.interpolateNumber);
var baseY = d3.scaleLinear().domain([0,10])
.range([height, 0])
.interpolate(d3.interpolateNumber);
var scaleX = baseX;
var scaleY = baseY;
const zoom = d3.zoom()
.on('zoom', () => {
const transform = d3.event.transform;
scaleX = transform.rescaleX(baseX)
.interpolate(d3.interpolateNumber)
scaleY = transform.rescaleY(baseY)
.interpolate(d3.interpolateNumber)
render();
});
const chart = d3.select('#zoom-chart')
.append('svg')
.attr('class', 'chart')
.call(zoom);
const rect = chart
.append('rect')
.attr('rx', 10).attr('ry', 10)
.attr('x', 10).attr('y', 10)
.attr('fill', '#00222b')
.attr('width', width-20)
.attr('height', height-20);
const plot = chart
.append('g')
.attr('class', 'plot-area');
function render() {
plot.selectAll('.data')
.data(data)
.join(
enter => enter.append('circle')
.attr('class', 'data')
.attr('r', 10)
.attr('stroke', '#004455')
.attr('stroke-width', 5),
update => update,
exit => exit.remove()
)
.attr('cx', d => scaleX(d.x))
.attr('cy', d => scaleY(d.y));
}
window.addEventListener('resize', () => {
const t0 = d3.zoomTransform(
chart.node());
width = document.getElementById('zoom-chart').clientWidth;
height = 480;
baseX.range([0, width])
.interpolate(d3.interpolateNumber);
baseY.range([height, 0])
.interpolate(d3.interpolateNumber);
vb = [0,0, width,height];
chart
.attr('viewBox', vb);
rect
.attr('width', width-20)
.attr('height', height-20);
chart.call(zoom.transform, t0);
});
let vb = [0,0, width,height];
chart
.attr('viewBox', vb);
chart.call(zoom.transform, d3.zoomIdentity);
<script src="https://unpkg.com/d3#5.15.0/dist/d3.min.js"></script>
<div id="zoom-chart" class="chart large">
</div>

Categories

Resources