D3.js idles between each mousewheel event - javascript

I am having an issue with d3.js when I try to zoom in and out on a graph. The zoom is very slow and laggy. I am trying to debug by using the profiling tool (Opera/Chrome). I was expecting my zoom callback function to be the limiting factor but it turns out there is a lot of idle time between each mousewheel scroll events.
Motus operandum: I start the profiling, then give a big sharp scroll on the mousewheel (5sec on the graph). The graph lags for several seconds(from 5sec to 8.5sec on the graph) then calls my zoom callback periodically (from 8.5 to 14sec on the graph). I checked the stack calls and all my zooming callbacks are executed in order, synchronously, which makes me think the are done executing during the idle time. I think the profiler does not record some of the system/browser calls and qualifies those as idle, so I tried using interruptions ( event.preventDefault() etc...) to make sure nothing was executed on zoomend. It improved a little bit the performance, but there is still a lot of idle time:
Can someone please help me figure out why there is so much idle time?
Here is my relevant code:
without interruption
d3Zoom = d3.behavior.zoom()
.x(element.self.xScale)
.y(element.self.yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomend", updateSelection);
with interruption
var delayTimer=0;
d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomstart", function () {
//prevent recalculating heavyCalculations too often
window.clearTimeout(delayTimer);
var evt = e ? e : window.event;
return cancelDefaultAction(evt);
})
.on("zoomend", function () {
// only start heavy calculations if user hasn't zoomed for 0.75sec
delayTimer = window.setTimeout(updateSelection, 750);
});
function cancelDefaultAction(e) {
var evt = e ? e : window.event;
if (evt.preventDefault) evt.preventDefault();
evt.returnValue = false;
return false;
}`
EDIT: Here is an example of working code. Both semanticZoom and update selection are more complex in my project than in this example but they involve custom AngularJS directives, d3 brushes, warped geometry, aggregation etc... I have cropped semanticZoom to just perform an enter/exit/update pattern based on a quadtree (it might behave funny in this the example, but it's just to show the kind of operations I do). UpdateSelection updates the visible data to an angular directive to perform calculations (various statistics etc...). I did not populate it here but it is not actually very intensive.
var size = 100;
var dataset = d3.range(10).map(function(d, idx) {
return {
x: d3.random.normal(size / 2, size / 4)(),
y: d3.random.normal(size / 2, size / 4)(),
uuid: idx
};
});
//
// Init Scales
//
var xScale = d3.scale.linear()
.domain([0, size])
.range([0, 100]);
var yScale = d3.scale.linear()
.domain([0, size])
.range([0, 100]);
//
// Init Axes
//
var xAxis = d3.svg.axis()
.scale(xScale)
.ticks(10)
.orient("bottom")
.tickSize(-size);
var yAxis = d3.svg.axis()
.scale(yScale)
.ticks(10)
.orient("left")
.tickSize(-size);
//
// Init Zoom
//
var d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
.on("zoomend", updateSelection);
var quadtree = d3.geom.quadtree(dataset);
//------------------------ Callbacks --------------------------------
function semanticZoom() {
var s = 1;
var t = [0, 0];
if (d3.event) {
s = (d3.event.scale) ? d3.event.scale : 1;
t = (d3.event.translate) ? d3.event.translate : [0, 0];
}
// set zoom boundaries
// center of the zoom in svg coordinates
var center = [(size / 2 - t[0]) / s, (size / 2 - t[1]) / s];
// half size of the window in svg coordinates
var halfsize = size / (2 * s);
// top left corner in svg coordinates
var tl = [center[0] - halfsize, center[1] - halfsize];
// bottom right corner in svg coordinates
var br = [center[0] + halfsize, center[1] + halfsize];
/*
//
// Constrain zoom
//
if (!(tl[0] > -10 &&
tl[1] > -10 &&
br[0] < size + 10 &&
br[1] < size + 10)) {
// limit zoom-window corners
tl = [Math.max(0, tl[0]), Math.max(0, tl[1])];
br = [Math.min(size, br[0]), Math.min(size, br[1])];
// get restrained center
center = [(tl[0] + br[0]) / 2, (tl[1] + br[1]) / 2];
// scale center
t = [size / 2 - s * center[0], size / 2 - s * center[1]];
// update svg
svg.transition()
.duration(1)
.call( d3Zoom.translate(t).event );
}
*/
//
// Store zoom extent
//
d3Zoom.extent = [tl, br];
d3Zoom.scaleFactor = s;
d3Zoom.translation = t;
//
// Update some heavy duty stuff
// (create a quadtree, search that quadtree and update an attribute for the elements found)
//
// Prune non visible data
var displayedData = search(quadtree,
d3Zoom.extent[0][0], d3Zoom.extent[0][1],
d3Zoom.extent[1][0], d3Zoom.extent[1][1]);
redrawSubset(displayedData);
//
// Update axes
//
d3.select(".x.axis").call(xAxis);
d3.select(".y.axis").call(yAxis);
}
function redrawSubset(subset) {
//Attach new data
var elements = d3.select(".data_container")
.selectAll(".datum")
.data(subset, function(d) {
return d.uuid;
});
//enter
elements.enter()
.append("circle")
.attr("class", "datum")
.attr("r", 1)
.style("fill", "black");
//exit
elements.exit().remove();
//update
elements.attr("transform", ScaleData);
}
function updateSelection() {
// some not so heavy duty stuff
}
function ScaleData(d) {
return "translate(" + [xScale(d.x), yScale(d.y)] + ")";
}
//
// search quadtree
//
function search(qt, x0, y0, x3, y3) {
var pts = [];
qt.visit(function(node, x1, y1, x2, y2) {
var p = node.point;
if ((p) && (p.x >= x0) && (p.x <= x3) && (p.y >= y0) && (p.y <= y3)) {
pts.push(p);
}
return x1 >= x3 || y1 >= y3 || x2 < x0 || y2 < y0;
});
return pts;
}
//------------------------- DOM Manipulation -------------------------
var svg = d3.select("body").append("svg")
.attr("width", size)
.attr("height", size)
.append("g")
.attr("class", "data_container")
.call(d3Zoom);
svg.append("rect")
.attr("class", "overlay")
.attr("width", size)
.attr("height", size)
.style("fill", "none")
.style("pointer-events", "all");
var circle = svg.selectAll("circle")
.data(dataset, function(d) {
return d.uuid;
}).enter()
.append("circle")
.attr("r", 1)
.attr("class", "datum")
.attr("transform", ScaleData);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
SemanticZoom and UpdateSelection have both been unit tested and run in times comparable to the profiler graphs above (50-100ms) for large datasets.

If you add a few zeros to the circle count and make the svg big enough to be useful, then the zoom slows down to what you describe. But it's hardly surprising since it has a bunch of work to do visiting the nodes in the quad tree and writing to the DOM to manage the svg components. I don't understand why you are transforming individual circles instead of grouping them and transforming the g. If you did that then you could just let the svg element clip the image and avoid all of the svg overheads which would free up 75% of your budget. If the only purpose of the quad tree is to figure out which nodes are visible then that would also be eliminated.
A key observation I guess is that this profile is markedly different from the pics you posted, judging by the profile of your pics, they seem to be all about the quad tree and the rest is idle time. It would be interesting to see your cpu and gpu loading during the profile.
You can eliminate the need for deleting and re-writing nodes by using a clip path, that way the only overhead is re-writing the transform attributes.
There was also a problem with your search. There is a much simpler way to do it that works fine and that is to use the #linear.invert(y) method of the scale.
Both these are addressed in the sample code below...
var size = 500;
var margin = {top: 30, right: 40, bottom: 30, left: 50},
width = 600 - margin.left - margin.right,
height = 200 - margin.top - margin.bottom;
d3.select("#clipButton").on("click", (function() {
var clipped = false, clipAttr = [null, "url(#clip)"],
value = ["clip", "brush"];
return function() {
circles
.attr("clip-path", clipAttr[(clipped = !clipped, +clipped)]);
this.value = value[+clipped];
}
})());
var dataset = d3.range(1000).map(function(d, idx) {
return {
x: d3.random.normal(100 / 2, 100 / 4)(),
y: d3.random.normal(100 / 2, 100 / 4)(),
uuid: idx
};
});
//
// Init Scales
//
var xScale = d3.scale.linear()
.domain([0, 100])
.range([0, width])
.nice(10);
var yScale = d3.scale.linear()
.domain([0, 100])
.range([height, 0])
.nice(10);
//
// Init Axes
//
var xAxis = d3.svg.axis()
.scale(xScale)
.ticks(10)
.orient("bottom")
.tickSize(-height);
var yAxis = d3.svg.axis()
.scale(yScale)
.ticks(10)
.orient("left")
.tickSize(-width);
//
// Init Zoom
//
var d3Zoom = d3.behavior.zoom()
.x(xScale)
.y(yScale)
.scaleExtent([0.99, Infinity])
.on("zoom", semanticZoom)
// .on("zoomend", updateSelection);
var Quadtree = d3.geom.quadtree()
.x(function(d){return d.x})
.y(function(d){return d.y});
quadtree = Quadtree(dataset);
//------------------------ Callbacks --------------------------------
function semanticZoom() {
var s = 1;
var t = [0, 0];
if (d3.event) {
s = (d3.event.scale) ? d3.event.scale : 1;
t = (d3.event.translate) ? d3.event.translate : [0, 0];
}
var tl = [xScale.invert(0), yScale.invert(height)];
var br = [xScale.invert(width), yScale.invert(0)];
//
// Store zoom extent
//
d3Zoom.extent = [tl, br];
d3Zoom.scaleFactor = s;
d3Zoom.translation = t;
//
// Update some heavy duty stuff
// (create a quadtree, search that quadtree and update an attribute for the elements found)
//
// Prune non visible data
var displayedData = search(quadtree, d3Zoom.extent);
markSubset(displayedData, circle);
updateSelection(circle);
//
// Update axes
//
d3.select(".x.axis").call(xAxis);
d3.select(".y.axis").call(yAxis);
};
function markSubset(data, nodes){
var marked = nodes.data(data, function(d){return d.uuid;});
marked.enter();
marked.classed("visible", true);
marked.exit().classed("visible", false);
}
function updateSelection(elements) {
// some not so heavy duty stuff
elements.attr("transform", ScaleData);
}
function ScaleData(d) {
return "translate(" + [xScale(d.x), yScale(d.y)] + ")";
}
//
// search quadtree
//
function search(qt, extent) {
var pts = [],
x0=extent[0][0], y0=extent[0][1],
x3=extent[1][0], y3=extent[1][1];
qt.visit(function(node, x1, y1, x2, y2) {
var p = node.point;
if ((p) && (p.x >= x0) && (p.x <= x3) && (p.y >= y0) && (p.y <= y3)) {
pts.push(p);
}
return x1 >= x3 || y1 >= y3 || x2 < x0 || y2 < y0;
});
return pts;
}
//------------------------- DOM Manipulation -------------------------
var svg = d3.select("body").append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("class", "data_container")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(d3Zoom),
plotSurface = svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.style({"fill": "steelblue", opacity: 0.8})
.style("pointer-events", "all"),
gX = svg.append("g") // Add the X Axis
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis),
gY = svg.append("g")
.attr("class", "y axis")
.call(yAxis),
clipRect = svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height),
circles = svg.append("g")/*
.attr("clip-path", "url(#clip)")*/,
circle = circles.selectAll("circle")
.data(dataset, function(d) {
return d.uuid;
});
circle.enter()
.append("circle")
.attr("r", 3)
.attr("class", "datum")
.attr("transform", ScaleData);
semanticZoom();
svg {
outline: 1px solid red;
overflow: visible;
}
.axis path {
stroke: #000;
}
.axis line {
stroke: steelblue;
stroke-opacity: .5;
}
.axis path {
fill: none;
}
.axis text {
font-size: 8px;
}
.datum {
fill: #ccc;
}
.datum.visible {
fill: black;
}
#clipButton {
position: absolute;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<input id="clipButton" type="button" value="clip">

Related

Choppy transitions: translate multiple paths (and xaxis) concurrently

The goal is to create a smooth scrolling real time plot with multiple traces.
I was able to do this for a single trace, but when I add more lines to transition, the animation seems to get messed up. I have a feeling that transitions are being looped through and colliding, but I can't figure out how to prevent this.
If you set N_CH = 1 in the snippet, things run smoothly. When it's set to N_CH = 4 then the animation becomes jerky (seems like the transitions aren't fully completing) and also (interestingly) the x-axis scrolling appears to become 4 times faster than when N_CH = 1.
You can recover the smoothness by changing the transform in the tick() function to match the number of channels (i.e. iScale(-4) for N_CH = 4) but this isn't "correct" as the translation speed is artificially fast. In the end, I need accurate time measurement in real-time.
I've tried various different approaches including:
adding traces to a group and trying to translate the group
refactoring the data object and allowing d3 to iterate through the data structure with a selectAll() call
... the results always seem to be the same.
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
.range([0, width - margin.right])
.domain([0, data[0].values.length - 1]);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
.range([margin.left, width - margin.right])
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x((d, i) => iScale(i))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
for (let ch=0; ch<N_CH; ch++) {
traces.append("path")
.datum(data[ch].values)
.attr("id", `trace-${ch}`)
.attr("class", "trace")
.attr("d", line)
.attr("stroke", colors[ch])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform", "translate(0)")
}
// end initialize ////////////////////
// animate
tick();
function tick() {
// add data to buffer
let lastData;
for (let ch = 0; ch < N_CH; ch++) {
lastData = data[ch].values[data[ch].values.length - 1];
data[ch].values.push({
x: lastData.x + 1000,
y: ch + Math.random()
});
}
// update individual trace path data
for (let ch = 0; ch < N_CH; ch++) {
traces.select(`#trace-${ch}`)
.attr("d", line)
}
// animate transition
traces
.selectAll('.trace')
.attr("transform", "translate(0)")
.transition().duration(1000).ease(d3.easeLinear)
.attr("transform", `translate(${iScale(-1)}, 0)`)
.on("end", tick)
// update the domain
xScale.domain(d3.extent(data[0].values, d => d.x));
// animate/redraw axis
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale));
for (let ch=0; ch<N_CH; ch++) {
data[ch].values.shift();
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<div id="chart"></div>
There are a few issues here:
xScale vs iScale:
You draw your data based on iScale, but draw your axis based on xScale: there's a discrepancy here right away: the ranges of each scale are different. But there is no reason why you shouldn't use the same scale for both: this way you'll never have any discrepancy between drawing and axis. If you remove the clip path and remove the tick function, you'll notice your lines aren't initially rendered where you expect them:
Misuse of transition.end()
D3's transition event listeners are for each transition. You are transitioning many elements, this is triggered when every line finishes. So after the four lines finish transitioning the first time, you trigger the tick function four times: this results in all sorts of chaos since the function is intended to be called once to transition all lines at once.
On re-read of the question, you've spotted this issue of calling the tick function 4x instead of once:
You can recover the smoothness by changing the transform in the tick()
function to match the number of channels (i.e. iScale(-4) for N_CH =
4) but this isn't "correct" as the translation speed is artificially
fast.
If we fix this so that we call the tick function once, when all line transitions are complete, we address the smoothness issue:
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set index scale for data buffer position/transition
var iScale = d3.scaleLinear()
.range([0, width - margin.right])
.domain([0, data[0].values.length - 1]);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleUtc()
.range([margin.left, width - margin.right])
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x((d, i) => iScale(i))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
for (let ch=0; ch<N_CH; ch++) {
traces.append("path")
.datum(data[ch].values)
.attr("id", `trace-${ch}`)
.attr("class", "trace")
.attr("d", line)
.attr("stroke", colors[ch])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform", "translate(0)")
}
// end initialize ////////////////////
// animate
tick();
function tick() {
// add data to buffer
let lastData;
for (let ch = 0; ch < N_CH; ch++) {
lastData = data[ch].values[data[ch].values.length - 1];
data[ch].values.push({
x: lastData.x + 1000,
y: ch + Math.random()
});
}
// update individual trace path data
for (let ch = 0; ch < N_CH; ch++) {
traces.select(`#trace-${ch}`)
.attr("d", line)
}
// animate transition
traces
.selectAll('.trace')
.attr("transform", "translate(0)")
.transition().duration(1000).ease(d3.easeLinear)
.attr("transform", `translate(${iScale(-1)}, 0)`)
.end().then(tick);
// update the domain
xScale.domain(d3.extent(data[0].values, d => d.x));
// animate/redraw axis
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale));
for (let ch=0; ch<N_CH; ch++) {
data[ch].values.shift();
}
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>
In the above I use transition.end() to return a promise when all selected elements finish transitioning. I have upped your version of D3 as this is a newer function:
.end().then(tick);
Improvements:
Your code makes use of loops to append and modify elements. This creates additional overhead: selecting elements in the DOM takes time, you have to identify each line so you can reselect it again, and you have to do some extra legwork in binding the data. Let's simplify this with the d3 enter/update cycle:
Create the lines to start:
let lines = traces.selectAll(null)
.data(data)
.enter()
.append("path")
.attr("d", d=>line(d.values))
.attr("stroke", (d,i)=>colors[i])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform","translate(0,0)");
And now in the update/tick function we can modify the bound data easily:
lines.each(function(d,i) {
d.values.push({
x: d.values[d.values.length-1].x + dt,
y: i + Math.random()
})
})
.attr("d", d=>line(d.values))
We can remove the first data point of each line with:
lines.each(d=>d.values.shift());
Generally speaking (explicit) loops are very rare in manipulating SVG elements with D3, as it runs counter to principles that D3 was designed with. See here for some discussion on why that might matter and how it might be useful.
Together with removing the iScale and using transition.end(), we might get something like:
// set up some variables
const N_CH = 4;
const N_PTS = 40;
const margin = {top: 20, right: 30, bottom: 30, left: 40};
const width = 800;
const height = 300;
const colors = ['steelblue', 'red', 'orange', 'magenta']
// instantiate data array (timestamps)
var data = [];
var channelData = [];
for (let ch = 0; ch < N_CH; ch++) {
channelData = [];
for (let i = 0; i < N_PTS; i++) {
channelData.push({
x: Date.now() + i * 1000,
y: ch + Math.random()
})
}
data.push({
name: "CH" + ch,
values: channelData
});
}
// initialize //////////////////////////////
// instantiate svg and attach to DOM element
var svg = d3
.select("#chart")
.append("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
// add clip path for smooth entry/exit
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("x", margin.left)
.attr("y", margin.bottom)
.attr("width", width - margin.left - margin.right)
.attr("height", height - margin.top - margin.bottom);
// set up x-axis scale for data x units (time)
var xScale = d3.scaleTime()
.range([margin.left, width - margin.right])
.domain(d3.extent(data[0].values,d=>d.x))
// add x-axis to svg
var xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0, ${height - margin.top})`)
.call(d3.axisBottom(xScale));
// set up y-axis
var yScale = d3.scaleLinear()
.range([height - margin.top, margin.bottom]);
// add y-axis to svg
var yAxis = svg.append("g")
.attr("class", "y-axis")
.attr("transform", `translate(${margin.left}, 0)`)
.call(d3.axisLeft(yScale));
// set the domains
xScale.domain(d3.extent(this.data[0].values, d => d.x));
// get global y domain
var flatten = [].concat.apply([], data.map(o => o.values))
yScale.domain(d3.extent(flatten, d => d.y));
// define the line
var line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y));
// make a group where we will append our paths
traces = svg.append("g")
.attr("clip-path", "url(#clip)")
// Create lines:
let lines = traces.selectAll(null)
.data(data)
.enter()
.append("path")
.attr("d", d=>line(d.values))
.attr("stroke", (d,i)=>colors[i])
.attr("fill", "none")
.attr("stroke-width", 1.5)
.attr("transform","translate(0,0)");
transition();
function transition() {
let dt = 1000; // difference in time.
let dx = xScale(d3.timeMillisecond.offset(xScale.domain()[0],dt)) - xScale.range()[0]; // difference in pixels.
lines.each(function(d,i) {
d.values.push({
x: d.values[d.values.length-1].x + dt,
y: i + Math.random()
})
})
.attr("d", d=>line(d.values))
.transition()
.duration(1000)
.attr("transform",`translate(${-dx}, 0)`)
.ease(d3.easeLinear)
.end().then(function() {
lines.each(d=>d.values.shift())
.attr("transform","translate(0,0)")
transition();
})
xScale.domain(xScale
.domain()
.map(d=>d3.timeMillisecond.offset(d,dt)))
xAxis
.transition().duration(1000).ease(d3.easeLinear)
.call(d3.axisBottom(xScale))
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<div id="chart"></div>

D3: slow zoomable heatmap

I have this zoomable heatmap, which looks too slow when zooming-in or out. Is there anything to make it faster/smoother or it is just too many points and that is the best I can have. I was wondering if there is some trick to make it lighter for the browser please while keeping enhancements like tooltips. Or maybe my code handling the zoom feature is not great .
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
</style>
<title>Bar Chart</title>
<!-- Reference style.css -->
<!-- <link rel="stylesheet" type="text/css" href="style.css">-->
<!-- Reference minified version of D3 -->
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
</head>
<body>
<div id="chart" style="width: 700px; height: 500px"></div>
<script>
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) {
return d.day;
}) -
d3.min(dataset, function(d) {
return d.day;
});
var hours = d3.max(dataset, function(d) {
return d.hour;
}) -
d3.min(dataset, function(d) {
return d.hour;
});
var tMin = d3.min(dataset, function(d) {
return d.tOutC;
}),
tMax = d3.max(dataset, function(d) {
return d.tOutC;
});
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = {
top: 0,
right: 25,
bottom: 40,
left: 25
},
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) {
return d.tOutC;
})])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
// Define Y axis
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[80, 20],
[width, height]
])
.on("zoom", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG canvas
var svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Clip path
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
// Heatmap dots
svg.append("g")
.attr("clip-path", "url(#clip)")
.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) {
return xScale(d.day);
})
.attr("cy", function(d) {
return yScale(d.hour);
})
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) {
return colorScale(d.tOutC);
})
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
//Create X axis
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
//Create Y axis
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
function zoomed() {
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
update();
}
function update() {
// update: cache rescaleX value
var rescaleX = d3.event.transform.rescaleX(xScale);
svg.selectAll("ellipse")
.attr('clip-path', 'url(#clip)')
// update: apply rescaleX value
.attr("cx", function(d) {
return rescaleX(d.day);
})
// .attr("cy", function(d) {
// return yScale(d.hour);
// })
// update: apply rescaleX value
.attr("rx", function(d) {
return (dotWidth * d3.event.transform.k);
})
.attr("fill", function(d) {
return colorScale(d.tOutC);
});
}
</script>
</body>
</html>
Thanks
The solution is not to update all the dots for the zoom but to apply the zoom transform to the group containing the dots.
Clipping of the group needs to be done on an additional parent g heatDotsGroup.
The zoom scale of y is taken care of (set it fixed to 1) with a regex replace, limit translate in y by setting the transform.y to 0, and limit the translate of x based on the current scale.
Allow a little translate past 0 to show the first dot complete when zoomed in.
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.on("zoom", zoomed);
// Heatmap dots
var heatDotsGroup = svg.append("g")
.attr("clip-path", "url(#clip)")
.append("g");
heatDotsGroup.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) { return xScale(d.day); })
.attr("cy", function(d) { return yScale(d.hour); })
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) { return colorScale(d.tOutC); })
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
function zoomed() {
d3.event.transform.y = 0;
d3.event.transform.x = Math.min(d3.event.transform.x, 5);
d3.event.transform.x = Math.max(d3.event.transform.x, (1-d3.event.transform.k) * width );
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
heatDotsGroup.attr("transform", d3.event.transform.toString().replace(/scale\((.*?)\)/, "scale($1, 1)"));
}
Try Canvas
You have 27 000 nodes. This is probably around the point where SVG performance drops off for most and Canvas starts to really shine. Sure, Canvas isn't stateful like SVG, its just pixels with no nice elements to mouse over in the DOM and tell you where and what they are. But, there are ways to address this shortcoming so that we can retain speed and interactive abilities.
For the initial rendering using your snippet, I have a average rendering time of ~440ms. But, through the magic of canvas, I can render the same heat map with an average rendering time of ~103ms. Those savings can be applied to things like zooming, animation etc.
For very small things like your ellipses there is a risk of aliasing issues that is harder to fix with canvas as opposed to SVG, though how each browser renders this will differ
Design Implications
With Canvas we can retain the enter/exit/update cycle as with SVG, but we also have the option of dropping it. At times the enter/exit/update cycle pairs extremely well with canvas: transitions, dynamic data, heirarcical data, etc. I have previously spent some time on some of the higher level differences between Canvas and SVG with regards to D3 here.
For my answer here, we'll leave the enter cycle. When we want to update the visualization we just redraw everything based on the data array itself.
Drawing the Heat Map
I'm using rectangles for the sake of brevity. Canvas's ellipse method isn't quite ready, but you can emulate it easily enough.
We need a function that draws the dataset. If you had x/y/color hard coded into the dataset we could use a very simple:
function drawNodes()
dataset.forEach(function(d) {
ctx.beginPath();
ctx.rect(d.x,d.y,width,height);
ctx.fillStyle = d.color;
ctx.fill();
})
}
But we need to scale your values, calculate a color, and we should apply the zoom. I ended up with a relatively simple:
function drawNodes()
var k = d3.event ? d3.event.transform.k : 1;
var dw = dotWidth * k;
ctx.clearRect(0,0,width,height); // erase what's there
dataset.forEach(function(d) {
var x = xScale(d.day);
var y = yScale(d.hour);
var fill = colorScale(d.tOutC);
ctx.beginPath();
ctx.rect(x,y,dw,dotHeight);
ctx.fillStyle = fill;
ctx.strokeStyle = fill;
ctx.stroke();
ctx.fill();
})
}
This can be used to initially draw the nodes (when d3.event isn't defined), or on zoom/pan events (after which this function is called each time).
What about the axes?
d3-axis is intended for SVG. So, I've just superimposed an SVG overtop of a Canvas element positioning both absolutely and disabling mouse events on the overlying SVG.
Speaking of axes, I only have one drawing function (no difference between update/initial drawing), so I use a reference x scale and a rendering x scale from the get go, rather than creating a disposable rescaled x scale in the update function
Now I Have a Canvas, How Do I Interact With It?
There are a few methods we could use take a pixel position and convert it to a specific datum:
Use a Voronoi diagram (using the .find method to locate a datum)
Use a Force layout (also using the .find method to locate a datum)
Use a hidden Canvas (using pixel color to indicate datum index)
Use a scale's invert function (when data is gridded)
The third option may be one of the most common, and while the first two look similar the find methods do differ internally (voronoi neighbors vs quad tree). The last method is fairly appropriate in this case: we have a grid of data and we can invert the mouse coordinate to get row and column data. Based on your snippet that might look like:
function mousemove() {
var xy = d3.mouse(this);
var x = Math.round(xScale.invert(xy[0]));
var y = Math.round(yScale.invert(xy[1]));
// For rounding on canvas edges:
if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
if(y > yScale.domain()[1]) y = yScale.domain()[1];
if(y < yScale.domain()[0]) y = yScale.domain()[0];
var index = --x*74 + y-1; // minus ones for non zero indexed x,y values.
var d = dataset[index];
console.log(x,y,index,d)
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
*I've used mousemove since mouseover will trigger once when moving over the canvas, we need to continuously update, if we wanted to hide the tooltip, we could just check to see if the pixel selected is white:
var p = ctx.getImageData(xy[0], xy[1], 1, 1).data; // pixel data:
if (!p[0] && !p[1] && !p[2]) { /* show tooltip */ }
else { /* hide tooltip */ }
Example
I've explicitly mentioned most of the changes above, but I've made some additional changes below. First, I need to select the canvas, position it, get the context, etc. I also have swapped rects for ellipses, so the positioning is a bit different (but you have other positioning issues to from using a linear scale (the ellipse centroids can fall on the edge of the svg as is), I've not modified this to account for the width/height of the ellipses/rects. This scale issue was far enough from the question that I didn't modify it.
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) { return d.day; }) - d3.min(dataset, function(d) { return d.day; });
var hours = d3.max(dataset, function(d) { return d.hour; }) - d3.min(dataset, function(d) { return d.hour; });
var tMin = d3.min(dataset, function(d) { return d.tOutC; }), tMax = d3.max(dataset, function(d) { return d.tOutC; });
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = { top: 20, right: 25, bottom: 40, left: 25 },
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var xScaleRef = xScale.copy();
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([height,0]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) { return d.tOutC; })])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[0,0],
[width, height]
])
.on("zoom", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG & Canvas:
var canvas = d3.select("#chart")
.append("canvas")
.attr("width", width)
.attr("height", height)
.style("left", margin.left + "px")
.style("top", margin.top + "px")
.style("position","absolute")
.on("mousemove", mousemove)
.on("mouseout", mouseout);
var svg = d3.select("#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]+")");
var ctx = canvas.node().getContext("2d");
canvas.call(zoom);
// Initial Draw:
drawNodes(dataset);
//Create Axes:
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
// Handle Zoom:
function zoomed() {
// rescale the x Axis:
xScale = d3.event.transform.rescaleX(xScaleRef); // Use Reference Scale.
// Redraw the x Axis:
renderXAxis.call(xAxis.scale(xScale));
// Clear and redraw the nodes:
drawNodes();
}
// Draw nodes:
function drawNodes() {
var k = d3.event ? d3.event.transform.k : 1;
var dw = dotWidth * k;
ctx.clearRect(0,0,width,height);
dataset.forEach(function(d) {
var x = xScale(d.day);
var y = yScale(d.hour);
var fill = colorScale(d.tOutC);
ctx.beginPath();
ctx.rect(x,y,dw,dotHeight);
ctx.fillStyle = fill;
ctx.strokeStyle = fill;
ctx.stroke();
ctx.fill();
})
}
// Mouse movement:
function mousemove() {
var xy = d3.mouse(this);
var x = Math.round(xScale.invert(xy[0]));
var y = Math.round(yScale.invert(xy[1]));
if(x > xScaleRef.domain()[1]) x = xScaleRef.domain()[1];
if(x < xScaleRef.domain()[0]) x = xScaleRef.domain()[0];
if(y > yScale.domain()[1]) y = yScale.domain()[1];
if(y < yScale.domain()[0]) y = yScale.domain()[0];
var index = --x*74 + y-1; // minus ones for non zero indexed x,y values.
var d = dataset[index];
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}
function mouseout() {
$("#tooltip").animate({duration: 500}).css("opacity",0);
};
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
svg {
position: absolute;
top: 0;
left:0;
pointer-events: none;
}
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
<div id="chart" style="width: 700px; height: 500px"></div>
The result of all following combined suggestions is not perfect, but it is subjectively slightly better:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<style>
.axis text {
font: 10px sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000000;
}
.x.axis path {
//display: none;
}
.chart rect {
fill: steelblue;
}
.chart text {
fill: white;
font: 10px sans-serif;
text-anchor: end;
}
#tooltip {
position:absolute;
background-color: #2B292E;
color: white;
font-family: sans-serif;
font-size: 15px;
pointer-events: none; /*dont trigger events on the tooltip*/
padding: 15px 20px 10px 20px;
text-align: center;
opacity: 0;
border-radius: 4px;
}
</style>
<title>Bar Chart</title>
<!-- Reference style.css -->
<!-- <link rel="stylesheet" type="text/css" href="style.css">-->
<!-- Reference minified version of D3 -->
<script src='https://d3js.org/d3.v4.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js'></script>
</head>
<body>
<div id="chart" style="width: 700px; height: 500px"></div>
<script>
var dataset = [];
for (let i = 1; i < 360; i++) {
for (j = 1; j < 75; j++) {
dataset.push({
day: i,
hour: j,
tOutC: Math.random() * 25,
})
}
};
var days = d3.max(dataset, function(d) {
return d.day;
}) -
d3.min(dataset, function(d) {
return d.day;
});
var hours = d3.max(dataset, function(d) {
return d.hour;
}) -
d3.min(dataset, function(d) {
return d.hour;
});
var tMin = d3.min(dataset, function(d) {
return d.tOutC;
}),
tMax = d3.max(dataset, function(d) {
return d.tOutC;
});
var dotWidth = 1,
dotHeight = 3,
dotSpacing = 0.5;
var margin = {
top: 0,
right: 25,
bottom: 40,
left: 25
},
width = (dotWidth * 2 + dotSpacing) * days,
height = (dotHeight * 2 + dotSpacing) * hours;
var colors = ['#2C7BB6', '#00A6CA','#00CCBC','#90EB9D','#FFFF8C','#F9D057','#F29E2E','#E76818','#D7191C'];
var xScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.day}))
.range([0, width]);
var yScale = d3.scaleLinear()
.domain(d3.extent(dataset, function(d){return d.hour}))
.range([(dotHeight * 2 + dotSpacing) * hours, dotHeight * 2 + dotSpacing]);
var colorScale = d3.scaleQuantile()
.domain([0, colors.length - 1, d3.max(dataset, function(d) {
return d.tOutC;
})])
.range(colors);
var xAxis = d3.axisBottom().scale(xScale);
// Define Y axis
var yAxis = d3.axisLeft().scale(yScale);
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([
[80, 20],
[width, height]
])
// .on("zoom", zoomed);
.on("end", zoomed);
var tooltip = d3.select("body").append("div")
.attr("id", "tooltip")
.style("opacity", 0);
// SVG canvas
var svg = d3.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.call(zoom)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// Clip path
svg.append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
// Heatmap dots
svg.append("g")
.attr("clip-path", "url(#clip)")
.selectAll("ellipse")
.data(dataset)
.enter()
.append("ellipse")
.attr("cx", function(d) {
return xScale(d.day);
})
.attr("cy", function(d) {
return yScale(d.hour);
})
.attr("rx", dotWidth)
.attr("ry", dotHeight)
.attr("fill", function(d) {
return colorScale(d.tOutC);
})
.on("mouseover", function(d){
$("#tooltip").html("X: "+d.day+"<br/>Y:"+d.hour+"<br/>Value:"+Math.round(d.tOutC*100)/100);
var xpos = d3.event.pageX +10;
var ypos = d3.event.pageY +20;
$("#tooltip").css("left",xpos+"px").css("top",ypos+"px").animate().css("opacity",1);
}).on("mouseout", function(){
$("#tooltip").animate({duration: 500}).css("opacity",0);
});
//Create X axis
var renderXAxis = svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis)
//Create Y axis
var renderYAxis = svg.append("g")
.attr("class", "y axis")
.call(yAxis);
function zoomed() {
// update: rescale x axis
renderXAxis.call(xAxis.scale(d3.event.transform.rescaleX(xScale)));
update();
}
function update() {
// update: cache rescaleX value
var rescaleX = d3.event.transform.rescaleX(xScale);
var scaledRadius = dotWidth * d3.event.transform.k;
var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i));
svg.selectAll("ellipse")
// .attr('clip-path', 'url(#clip)')
// update: apply rescaleX value
.attr("cx", d => scaledCxes[d.day])
// .attr("cy", function(d) {
// return yScale(d.hour);
// })
// update: apply rescaleX value
.attr("rx", scaledRadius)
// .attr("fill", function(d) {
// return colorScale(d.tOutC);
// });
}
</script>
</body>
</html>
Using on("end", zoomed) instead of on("zoom", zoomed):
First thing we can try is to activate the zoom change only at the end of the zoom event in order not to have these non deterministic updates jumps during a single zoom event. It has for effect to lower the required processing as only one computation happens, and it removes the global jump discomfort:
var zoom = d3.zoom()
.scaleExtent([dotWidth, dotHeight])
.translateExtent([ [80, 20], [width, height] ])
.on("end", zoomed); // instead of .on("zoom", zoomed);
Remove updates of things which remains the same during the zoom:
We can also remove from the nodes update things which stay the same such as the color of a circle which during the zoom remains the same anyway .attr("fill", function(d) { return colorScale(d.tOutC); }); and .attr('clip-path', 'url(#clip)').
Computing only once things used several times:
The new circle radius after the zoom can only be computed once instead of 27K times as it's the same for all circles:
var scaledRadius = dotWidth * d3.event.transform.k;
.attr("rx", scaledRadius)
Same for x positions, we can compute it once per possible x value (360 times) and store it in an array to access them in constant time instead of computing it 27K times:
var scaledCxes = [...Array(360).keys()].map(i => rescaleX(i));
.attr("cx", d => scaledCxes[d.day])
Last obvious option would be to reduce the number of nodes since it's the root of the issue!
If the zoom extent would have been bigger, I would have also suggested filtering nodes not visible anymore.
Do check LightningChart JS heatmaps - it's free to use non-commercially.
Here is a performance comparison of best performing heatmap web charts https://github.com/Arction/javascript-charts-performance-comparison-heatmaps
As you can see over there we are talking about visualizing heatmaps that are in range of billions of data points and user interactions still work just fine.
// Source https://www.arction.com/lightningchart-js-interactive-examples/edit/lcjs-example-0800-heatmapGrid.html
/*
* LightningChartJS example that showcases a simple XY line series.
*/
// Extract required parts from LightningChartJS.
const { lightningChart, PalettedFill, LUT, ColorRGBA, emptyLine, Themes } =
lcjs;
const { createWaterDropDataGenerator } = xydata;
// Specify the resolution used for the heatmap.
const resolutionX = 1000;
const resolutionY = 1000;
// Create a XY Chart.
const chart = lightningChart()
.ChartXY({
// theme: Themes.darkGold
})
.setTitle(
`Heatmap Grid Series ${resolutionX}x${resolutionY} (${(
(resolutionX * resolutionY) /
1000000
).toFixed(1)} million data points)`
)
.setPadding({ right: 40 });
// Create LUT and FillStyle
const palette = new LUT({
units: "intensity",
steps: [
{ value: 0, color: ColorRGBA(255, 255, 0) },
{ value: 30, color: ColorRGBA(255, 204, 0) },
{ value: 45, color: ColorRGBA(255, 128, 0) },
{ value: 60, color: ColorRGBA(255, 0, 0) },
],
interpolate: false,
});
// Generate heatmap data.
createWaterDropDataGenerator()
.setRows(resolutionX)
.setColumns(resolutionY)
.generate()
.then((data) => {
// Add a Heatmap to the Chart.
const heatmap = chart
.addHeatmapGridSeries({
columns: resolutionX,
rows: resolutionY,
start: { x: 0, y: 0 },
end: { x: resolutionX, y: resolutionY },
dataOrder: "columns",
})
// Color Heatmap using previously created color look up table.
.setFillStyle(new PalettedFill({ lut: palette }))
.setWireframeStyle(emptyLine)
.invalidateIntensityValues(data)
.setMouseInteractions(false);
// Add LegendBox.
const legend = chart.addLegendBox()
// Dispose example UI elements automatically if they take too much space. This is to avoid bad UI on mobile / etc. devices.
.setAutoDispose({
type: 'max-height',
maxHeight: 0.70,
})
.add(chart)
});
<script src="http://unpkg.com/#arction/lcjs#3.1.0/dist/lcjs.iife.js"></script>
<script src="http://unpkg.com/#arction/xydata#1.4.0/dist/xydata.iife.js"></script>

d3 - how to show duration interval instead of tickValues

Let's consider a horizontal bar chart as shown in the attached photo. I need to show the duration of each segment along with the x-axis. I am able to show the tick values using d3.svg.axis().tickValues(vals).
But I need to show the duration in between two ticks. Can anyone help me to achieve this using d3?
Thanks in advance.
Here is my solution. I'm using D3 version 3 (because you wrote d3.svg.axis() in your question) and a linear scale, just to show you the principle. You can easily change it to a time scale.
Given this data:
var data = [0, 10, 40, 45, 85, 100];
We're gonna plot the differences between the ticks, i.e.: 10, 30, 5, 40 and 15.
The first step is setting the scale:
var scale = d3.scale.linear()
.domain(d3.extent(data))
.range([margin, w - margin]);
Then, in the axis generator, we set the ticks to match the data with:
.tickValues(data)
And we calculate the correct numbers with:
.tickFormat(function(d,i){
if(i>0){
return data[i] - data[i-1];
} else { return ""};
});
The last step is translating the text to the middle position:
var ticks = d3.selectAll(".tick text").each(function(d, i) {
d3.select(this).attr("transform", function() {
if (i > 0) {
return "translate(" + (-scale(data[i] - data[i - 1]) / 2 + margin/2) + ",0)";
}
})
})
Check the demo:
var w = 500,
h = 100;
var svg = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h);
var data = [0, 10, 40, 45, 85, 100];
var margin = 20;
var scale = d3.scale.linear()
.domain(d3.extent(data))
.range([margin, w - margin]);
var colors = d3.scale.category10();
var rects = svg.selectAll(".rects")
.data(data)
.enter()
.append("rect");
rects.attr("y", 10)
.attr("height", 35)
.attr("fill", (d,i)=> colors(i))
.attr("x", d=>scale(d))
.attr("width", (d,i)=> {
return scale(data[i+1] - data[i]) - margin
});
var axis = d3.svg.axis()
.scale(scale)
.orient("bottom")
.tickValues(data)
.tickFormat(function(d, i) {
if (i > 0) {
return data[i] - data[i - 1];
} else {
return ""
};
});
var gX = svg.append("g")
.attr("transform", "translate(0,50)")
.attr("class", "axis")
.call(axis);
var ticks = d3.selectAll(".tick text").each(function(d, i) {
d3.select(this).attr("transform", function() {
if (i > 0) {
return "translate(" + (-scale(data[i] - data[i - 1]) / 2 + margin / 2) + ",0)";
}
})
})
.axis path,
.axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

Mirror d3 graph horizontally in JavaScript/html

I'm trying to flip a graph in JS without changing the axis.
JavaScript isn't my specialty and after googling for 4 hours I'm starting to think it isn't possible.
Can someone point me in the right direction please?
var width = 960,
height = 500;
d3.json("data2.json", function(error, heatmap) {
var dx = heatmap[0].length,
dy = heatmap.length;
// Fix the aspect ratio.
// var ka = dy / dx, kb = height / width;
// if (ka < kb) height = width * ka;
// else width = height / ka;
var x = d3.scale.linear()
.domain([0, dx])
.range([0, width]);
var y = d3.scale.linear()
.domain([0, dy])
.range([height, 0]);
var color = d3.scale.linear()
.domain([null, 0, 30, 60, 90, 120])
.range(["#FFFFFF", "#FF0000", "#FF9933", "#FFFF00", "#99FF33", "#00FF00"]);
var xAxis = d3.svg.axis()
.scale(x)
.orient("top")
.ticks(20);
var yAxis = d3.svg.axis()
.scale(y)
.orient("right");
d3.select("body").append("canvas")
.attr("width", dx)
.attr("height", dy)
.style("width", width + "px")
.style("height", height + "px")
.call(drawImage);
var svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + height + ")")
.call(xAxis)
.call(removeZero);
svg.append("g")
.attr("class", "y axis")
.call(yAxis)
.call(removeZero);
function drawImage(canvas) {
var context = canvas.node().getContext("2d"),
image = context.createImageData(dx, dy);
for (var y = 0, p = -1; y < dy; ++y) {
for (var x = 0; x < dx; ++x) {
var c = d3.rgb(color(heatmap[y][x]));
image.data[++p] = c.r;
image.data[++p] = c.g;
image.data[++p] = c.b;
image.data[++p] = 255;
}
}
context.putImageData(image, 0, 0);
}
function removeZero(axis) {
axis.selectAll("g").filter(function(d) { return !d; }).remove();
}
});
I see that you're actually not really using D3 for the part of the code that makes the image itself. In the nested loops, when you're manually producing the bitmap image, you can just traverse the data in reverse. Instead of indexing the row heatmap[y] directly on x, you should index on dx - x - 1, the column number counting from the right.
jsfiddle
As an aside, it seems a little strange to be mixing SVG and canvas drawing techniques here. Depending on how much data you're showing, it may be valuable to draw the heatmap with SVG as well, which would allow you to use just a single API to draw the chart and would enable interactions as well. Alternatively, you could go for drawing the whole thing on canvas, if a static image is more appropriate, for instance if the scale of data is massive.

D3 - Reset Zoom when Chart is Brushed?

I'm working on a D3 timescale/pricescale financial chart. The chart SVG itself uses zoom() to pan and scale the data geometrically and re-draw the axes. Beneath the chart is an SVG brush pane which shows the entire data set at a high level and allows panning itself. The issue I'm facing is the same behavior as shown in this fiddle (not my code): http://jsfiddle.net/p29qC/8/. Zooming after brushing results in jumpy behavior because zoom() never picked up the changes from brush().
Zoom and brush work well independently, but I'm having trouble making them work together. When the chart is brushed, I'd expect zoom to detect that so the next time the chart is zoomed, it picks up where brush left off. And visa versa.
I've managed to set up a synchronization function to get the brush to update properly when zoom is initiated, but I can't get the reverse to work - update the chart zoom when brush occurs in the navigator. I've searched for hours to no avail. Are there any patches out there to fix this? My apologies for the long code blocks but I hope that it's helpful to set the context!
Setup code (some basic variables omitted for brevity):
// Create svg
var svg = d3.select('#chart')
.append('svg')
.attr({
class: 'fcChartArea',
width: width+margin.left+margin.right,
height: height+margin.bottom,
})
.style({'margin-top': margin.top});
// Create group for the chart
var chart = svg.append('g');
// Clipping path
chart.append('defs').append('clipPath')
.attr('id', 'plotAreaClip')
.append('rect')
.attr({
width: width,
height: height
});
// Create plot area, using the clipping path
var plotArea = chart.append('g')
.attr({
class: 'plotArea',
'clip-path': 'url(#plotAreaClip)'
});
// Compute mins and maxes
var minX = d3.min(data, function (d) {
return new Date(d.startTime*1000);
});
var maxX = d3.max(data, function (d) {
return new Date(d.startTime*1000);
});
var minY = d3.min(data, function (d) {
return d.low;
});
var maxY = d3.max(data, function (d) {
return d.high;
});
// Compute scales & axes
var dateScale = d3.time.scale()
.domain([minX, maxX])
.range([0, width]);
var dateAxis = d3.svg.axis()
.scale(dateScale)
.orient('bottom');
var priceScale = d3.scale.linear()
.domain([minY, maxY])
.nice()
.range([height, 0]);
var priceAxis = d3.svg.axis()
.scale(priceScale)
.orient('right');
// Store initial scales
var initialXScale = dateScale.copy();
var initialYScale = priceScale.copy();
// Add axes to the chart
chart.append('g')
.attr('class', 'axis date')
.attr('transform', 'translate(0,' + height + ')')
.call(dateAxis);
chart.append('g')
.attr('class', 'axis price')
.attr('transform', 'translate(' + width + ',0)')
.call(priceAxis);
// Compute and append the OHLC series
var series = fc.series.ohlc('path')
.xScale(dateScale)
.yScale(priceScale);
var dataSeries = plotArea.append('g')
.attr('class', 'series')
.datum(data)
.call(series);
// Create the SVG navigator
var navChart = d3.select('#chart')
.classed('chart', true)
.append('svg')
.classed('navigator', true)
.attr('width', navWidth + margin.left + margin.right)
.attr('height', navHeight+margin.top+margin.bottom)
.style({'margin-bottom': margin.bottom})
.append('g');
// Compute scales & axes
var navXScale = d3.time.scale()
.domain([minX, maxX])
.range([0, navWidth]);
var navXAxis = d3.svg.axis()
.scale(navXScale)
.orient('bottom');
var navYScale = d3.scale.linear()
.domain([minY, maxY])
.range([navHeight, 0]);
// Add x-axis to the chart
navChart.append('g')
.attr('class', 'axis date')
.attr('transform', 'translate(0,' + navHeight + ')')
.call(navXAxis);
// Add data to the navigator
var navData = d3.svg.area()
.x(function (d) {
return navXScale(new Date(d.startTime*1000));
})
.y0(navHeight)
.y1(function (d) {
return navYScale(d.close);
});
var navLine = d3.svg.line()
.x(function (d) {
return navXScale(new Date(d.startTime*1000));
})
.y(function (d) {
return navYScale(d.close);
});
navChart.append('path')
.attr('class', 'data')
.attr('d', navData(data));
navChart.append('path')
.attr('class', 'line')
.attr('d', navLine(data));
// create brush viewport
var viewport = d3.svg.brush()
.x(navXScale)
.on("brush", brush);
// add brush viewport to the SVG navigator
navChart.append("g")
.attr("class", "viewport")
.call(viewport)
.selectAll("rect")
.attr("height", navHeight);
// set zoom behavior
var zoom = d3.behavior.zoom()
.x(dateScale)
.scaleExtent([1, 12.99])
.on('zoom', zoom);
// Create zoom pane
plotArea.append('rect')
.attr('class', 'zoom-overlay')
.attr('width', width)
.attr('height', height)
.call(zoom);
Brush and zoom functions:
// zoom - brush synchronizations
function updateBrushFromZoom() {
if ((dateScale.domain()[0] <= minX) && (dateScale.domain()[1] >= maxX)) {
viewport.clear();
} else {
viewport.extent(dateScale.domain());
}
navChart.select('.viewport').call(viewport);
}
function updateZoomFromBrush() {
// help!!
}
function brush() {
var g = d3.selectAll('svg').select('g');
var newDomain = viewport.extent();
if (newDomain[0].getTime() !== newDomain[1].getTime()) {
dateScale.domain([newDomain[0], newDomain[1]]);
var xTransform = fc.utilities.xScaleTransform(initialXScale, dateScale);
// define new data set
var range = moment().range(newDomain[0], newDomain[1]);
var rangeData = [];
for (var i = 0; i < data.length; i += 1) {
if (range.contains(new Date(data[i].startTime*1000))) {
rangeData.push(data[i]);
}
}
// define new mins and maxes
var newMinY = d3.min(rangeData, function (d) {
return d.low;
});
var newMaxY = d3.max(rangeData, function (d) {
return d.high;
});
// set new yScale
priceScale.domain([newMinY, newMaxY]);
var yTransform = fc.utilities.yScaleTransform(initialYScale, priceScale);
// draw new axes on main chart
g.select('.fcChartArea .date.axis')
.call(dateAxis);
g.select('.fcChartArea .price.axis')
.call(priceAxis);
// transform the data to fit new chart viewport
g.select('.series')
.attr('transform', 'translate(' + xTransform.translate + ',' + yTransform.translate+ ')' + ' scale(' + xTransform.scale + ',' + yTransform.scale + ')');
}
else {
// remove transformation
g.select('.series')
.attr('transform', null);
}
updateZoomFromBrush();
}
// Zoom functions
function zoom() {
var g = d3.selectAll('svg').select('g');
// set new xScale
var newDomain = dateScale.domain();
var xTransformTranslate = d3.event.translate[0];
var xTransformScale = d3.event.scale;
// define new data set
var range = moment().range(newDomain[0], newDomain[1]);
var rangeData = [];
for (var i = 0; i < data.length; i += 1) {
if (range.contains(new Date(data[i].startTime*1000))) {
rangeData.push(data[i]);
}
}
// define new max and min
var newMinY = d3.min(rangeData, function (d) {
return d.low;
});
var newMaxY = d3.max(rangeData, function (d) {
return d.high;
});
// set new yScale
priceScale.domain([newMinY, newMaxY]);
var yTransform = fc.utilities.yScaleTransform(initialYScale, priceScale);
// draw new axes on main chart
g.select('.fcChartArea .date.axis')
.call(dateAxis);
g.select('.fcChartArea .price.axis')
.call(priceAxis);
// transform the data to fit new chart viewport
g.select('.series')
.attr('transform', 'translate(' + xTransformTranslate + ',' + yTransform.translate+ ')' + ' scale(' + xTransformScale + ',' + yTransform.scale + ')');
// update SVG navigator
updateBrushFromZoom();
}
Helper functions:
fc.utilities.yScaleTransform = function(oldScale, newScale) {
var oldDomain = oldScale.domain();
var newDomain = newScale.domain();
var scale = (oldDomain[1] - oldDomain[0]) / (newDomain[1] - newDomain[0]);
var translate = scale * (oldScale.range()[1] - oldScale(newDomain[1]));
return {
translate: translate,
scale: scale
};
};
fc.utilities.xScaleTransform = function(oldScale, newScale) {
var oldDomain = oldScale.domain();
var newDomain = newScale.domain();
var scale = (oldDomain[1] - oldDomain[0]) / (newDomain[1] - newDomain[0]);
var translate = scale * (oldScale.range()[0] - oldScale(newDomain[0]));
return {
translate: translate,
scale: scale
};
};
In updateZoomFromBrush(), rebind the scale to the zoom behavior with zoom.x(dateScale).
This is needed because d3.behavior.zoom() operates on a copy of the scale you pass in, so without rebinding the scale, the behavior won't have any of the changes made to the scale's domain in brush().
See this example http://bl.ocks.org/mbostock/3892928

Categories