Is it possible to find the distance between ticks in D3.js? - javascript

Is there a way to find out the distance between the tick marks on the x axis? I'm using the ordinal scale with rangeRoundBands with tells me it doesn't have a tick function.
var x= d3.scale.ordinal().rangePoints([_margin.left, cWidth]);
x.domain(['Dec','Jan']);
var testTicks = x.ticks(2);
It generates the axis fine (can't post an image) but I can't figure out how to get the distance
(edit: added x.domain)

var data = [45, 31, 23], // whatever your data is
graphHeight = 400,
// however many ticks you want to set
numberTicksY = 4,
// set y scale
// (hardcoded domain in this example to min and max of data vals > you should use d3.max real life)
y = d3.scale.linear().range(graphHeight, 0]).domain(23, 45),
yAxis = d3.svg.axis().scale(y).orient("left").ticks(numberTicksY),
// eg returns -> [20, 30, 40, 50]
tickArr = y.ticks(numberTicksY),
// use last 2 ticks (cld have used first 2 if wanted) with y scale fn to determine positions
tickDistance = y(tickArr[tickArr.length - 1]) - y(tickArr[tickArr.length - 2]);

(2019) We can expand techjacker solution to cover non-linear scales, instead of calculate only the distance between two ticks, you can have an array with all distances between ticks.
// ticksDistance is constant for a specific x_scale
const getTicksDistance = (scale) => {
const ticks = scale.ticks();
const spaces = []
for(let i=0; i < ticks.length - 1; i++){
spaces.push(scale(ticks[i+1]) - scale(ticks[i]))
}
return spaces;
};
//you have to recalculate when x_scale or ticks change
const ticksSpacingPow = getTicksDistance(x_scale_pow);
ticksSpacingPow is array with all distances
The example below, draws an ellipse on half of the distance between the ticks.
// normal
svg.append("g")
.attr("class", "x-axis")
.attr("transform", "translate(0,20)")
.call(x_axis_pow)
.selectAll('.tick')
.append('ellipse')
.attr('fill', 'red')
.attr('rx', '2px')
.attr('ry', '10px')
.attr('cx', (d,i)=> ticksSpacingPow[i]/2)
ps. Using the latest D3v5
const x_scale_pow = d3.scalePow().exponent(2)
.domain([0,20000])
.range([0, 960]);
const x_axis_pow = d3.axisBottom()
.scale(x_scale_pow)
.ticks(10)
// ticksDistance is constant for a specific x_scale
const getTicksDistance = (scale) => {
const ticks = scale.ticks();
const spaces = []
for(let i=0; i < ticks.length - 1; i++){
spaces.push(scale(ticks[i+1]) - scale(ticks[i]))
}
return spaces;
};
//you have to recalculate when x_scale or ticks change
const ticksSpacingPow = getTicksDistance(x_scale_pow);
const svg = d3.select("body").append("svg")
.attr("width", "500px")
.attr("height","350px")
.style("width", "100%")
.style("height", "auto");
// normal
svg.append("g")
.attr("class", "x-axis")
.attr("transform", "translate(0,20)")
.call(x_axis_pow)
.selectAll('.tick')
.append('ellipse')
.attr('fill', 'red')
.attr('rx', '2px')
.attr('ry', '10px')
.attr('cx', (d,i)=> ticksSpacingPow[i]/2)
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>

Related

Javascript / D3.js - draw large data set - improve the speed of zoom and pan in svg chart ploted by d3.js

Edit
Just found the post plotting 50 million points with d3.js.
Sluggish interaction with zoom and pan are due to too many elements in the svg. The key is to use hierarchical levels of detail, just like the image pyramid. , to limit the maximum elements in svg.
Original post
I am trying to read some data points from csv/excel file and plot them using d3.js.
The data set contains 100,000s of rows, each row contains a time stamp and a value at that time.
Time stamp, pressure
12/17/2019 12:00:00 AM, 600
I followed this example to plot the time-pressure chart with zoom and pan.
There is no issue and worked perfectly.
One issue is that when working with large data set, say 500,000 of data points, the interaction with the chart is sluggish.
The chart with 500,000 data points shows an overall shape, and the details would only come up when zoomed in at large scale.
When zoomed in, all the data points are re-plotted and clipped out by the clip path. Would there be some room to improve the speed?
Updated Code
function draw(res){
//clear the current content in the div
document.getElementById("spectrum-fig").innerHTML = '';
var fullwidth = d3.select('#spectrum-fig').node().getBoundingClientRect().width;
fullwidth = fullwidth < 500? 500:fullwidth;
var fullheight = 500;
var resLevelOne = getWindowed(res, 1);
var resLevelTwo = getWindowed(res, 2);
var designMax= getMaxPressureKPa();
var resMax = getPsiTopTen(res);
const SMYSKPa = getSMYSPressureKPa();
const avePsi = getAvePsi(res);
var psiRange = d3.max(res, d=>d.psi) - d3.min(res, d=>d.psi);
var resSmallChart = getWindowed(res, 2);//
//filtSpectrum(res, 0.05*psiRange); //0.05 magic numbers
//var resSmallChart = res;
//margin for focus chart, margin for small chart
var margin = {left:50, right: 50, top: 30, bottom:170},
margin2 = {left:50, right: 50, top: 360, bottom:30},
width = fullwidth - margin.left - margin.right,
height = fullheight - margin.top - margin.bottom,
height2 = fullheight - margin2.top-margin2.bottom;
//x, y, for big chart, x2, y2 for small chart
var x = d3.scaleTime().domain(d3.extent(res, d => d.Time)).range([0, width]),
x2 = d3.scaleTime().domain(d3.extent(res, d => d.Time)).range([0, width]),
y = d3.scaleLinear().domain([0, SMYSKPa]).range([height, 0]),
y2 = d3.scaleLinear().domain([0, SMYSKPa]).range([height2, 0]);
//clear the content in Spectrum-fig div before drawring
//avoid multiple drawings;
var xAxis =d3.axisBottom(x).tickFormat(d3.timeFormat("%m-%d")),
xAxis2 = d3.axisBottom(x2).tickFormat(d3.timeFormat("%b")),
yAxis = d3.axisLeft(y);
var brush = d3.brushX() // Add the brush feature using the d3.brush function
.extent( [ [0,0], [width,height2] ] ) // initialise the brush area: start at 0,0 and finishes at width,height: it means I select the whole graph area
.on("brush end", brushed); // trigger the brushed function
var zoom = d3.zoom()
.scaleExtent([1, 100]) //defined the scale extend
.translateExtent([[0, 0], [width, height]])
.extent([[0, 0], [width, height]])
.on("zoom", zoomed); //at the zoom end trigger zoomed function
//line for big chart line
var line = d3.line()
.x(function(d) { return x(d.Time) })
.y(function(d) { return y(d.psi) });
//line2 for small chart line
var line2 = d3.line()
.x(function(d) { return x2(d.Time) })
.y(function(d) { return y2(d.psi) });
var svg = d3.select("#spectrum-fig")
.append("svg")
.attr("width", fullwidth)
.attr("height", fullheight);
svg.append("defs").append("clipPath")
.attr("id", "clip")
.append("rect")
.attr("width", width)
.attr("height", height);
var focus = svg.append("g")
.attr("class", "focus")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var context = svg.append("g")
.attr("class", "context")
.attr("transform", "translate(" + margin2.left + "," + margin2.top + ")");
focus.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate (0," + height +")")
.call(xAxis);
focus.append("g")
.attr("class", "axis axis--y")
.call(yAxis);
focus.append("g")
.attr("transform", "translate (" + width + ", 0)")
.call(d3.axisRight(y).tickFormat('').tickSize(0));
focus.append("g")
.attr("transform", "translate (0, 0)")
.call(d3.axisTop(x).tickFormat('').tickSize(0));
// Add the line
focus.insert("path")
//.datum(res)
.attr("class", "line") // I add the class line to be able to modify this line later on.
.attr("fill", "none")
.attr('clip-path', 'url(#clip)')
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", line(resLevelTwo));
context.insert("path")
//.datum(resSmallChart)
.attr("class", "line")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("fill", "none")
.attr("d", line2(resSmallChart));
context.append("g")
.attr("class", "axis axis--x")
.attr("transform", "translate(0," + height2 + ")")
.call(xAxis2);
context.append("g")
.attr("class", "brush")
.call(brush)
.call(brush.move, x.range());
svg.append("rect")
.attr("class", "zoom")
.attr('fill', 'none')
.attr('cursor', 'move')
.attr('pointer-events', 'all')
.attr("width", width)
.attr("height", height)
.attr("transform", "translate(" + margin.left + "," + margin.top + ")")
.call(zoom);
function getWindowed(arr, level){
var windowed = new Array();
var arrLength = arr.length;
var windowSize =Math.pow(16, level); //set the window size
for(let i = 0; i * windowSize < arrLength; i++ ){ //each to be the window size
let startIndex = i * windowSize;
let endIndex = (i+1) * windowSize;
endIndex = endIndex >= arrLength ? arrLength-1 : endIndex;
let localExtreme = findLocalExtreme(arr.slice(startIndex, endIndex));
if (localExtreme.Max.Time.getTime() === localExtreme.Min.Time.getTime()){ //anything include = need getTime
windowed.push(localExtreme.Max)
}else if(localExtreme.Max.Time < localExtreme.Min.Time){
windowed.push(localExtreme.Max);
windowed.push(localExtreme.Min);
}else{
windowed.push(localExtreme.Min);
windowed.push(localExtreme.Max);
}
}
let firstElement = {...arr[0]};
let lastElement = {...arr[arr.length-1]};
if(firstElement.Time.getTime() != windowed[0].Time.getTime()){ //insert to the position zero
windowed.unshift(firstElement);
}
if(lastElement.Time.getTime() != windowed[windowed.length-1].Time.getTime()){
windowed.push(lastElement);
}//insert to the end last member;
return windowed;
}
function findLocalExtreme(slicedArr){
if(slicedArr === undefined || slicedArr.length == 0){
throw 'error: no array members';
}
let slicedArrLength = slicedArr.length;
let tempMax = {...slicedArr[0]};
let tempMin = {...slicedArr[0]};
if(slicedArrLength === 1){
return {
Max: tempMax,
Min: tempMin
}
}
for (let i = 1; i < slicedArrLength; i++){
if (slicedArr[i].psi > tempMax.psi){
tempMax = {...slicedArr[i]};
}
if (slicedArr[i].psi < tempMin.psi){
tempMin = {...slicedArr[i]};
}
}
return {
Max: tempMax,
Min: tempMin
}
}
function getDataToDraw(timeRange){ //timeRange [0,1] , [startTime, endTime]
const bisect = d3.bisector(d => d.Time).left;
const startIndex = bisect(res, timeRange[0]);
const endIndex = bisect(res, timeRange[1]);
const numberInOriginal = endIndex-startIndex+1;
const windowSize =16;
const maxNumber = 8000;
let level = Math.ceil(Math.log(numberInOriginal/maxNumber ) / Math.log(windowSize));
if(level <=0 ) level =0;
console.log(endIndex, startIndex, endIndex-startIndex+1, level);
if(level === 0){
return res.slice(startIndex, endIndex);
}if(level === 1){
let start_i = bisect(resLevelOne, timeRange[0]);
let end_i =bisect(resLevelOne, timeRange[1]);
return resLevelOne.slice(start_i, end_i);
}else { //if level 2 or higher, never happen
let start_i = bisect(resLevelTwo, timeRange[0]);
let end_i =bisect(resLevelTwo, timeRange[1]);
return resLevelTwo.slice(start_i, end_i);
}
}
function brushed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
var s = d3.event.selection || x2.range();
x.domain(s.map(x2.invert, x2));
focus.select(".line").attr("d", line(getDataToDraw(x.domain())));
focus.select(".axis--x").call(xAxis);
svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
.scale(width / (s[1] - s[0]))
.translate(-s[0], 0));
}
function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;
//console.log(t);
x.domain(t.rescaleX(x2).domain());
focus.select(".line").attr("d", line(getDataToDraw(t.rescaleX(x2).domain())));
focus.select(".axis--x").call(xAxis);
context.select(".brush").call(brush.move, x.range().map(t.invertX, t));
}
}
Here's my thoughts.
Re-plot seems a must-have, because how could you expect to have same position when you zoom in the points ?
However there's some frequency of the replot you can control. For example, people use debounce to decrease the number of firing below 50ms during any event (ex. pan especailly). Debounce is a general solution, you can check lodash library for some implementation.
.on("zoom", debounced(zoomed)) // lower the chance if you get 5 calls under 500ms
Also if there's any animation involved, you can defer the animation until the last stage of the zoom (or pan), which is similar to debounce concept. Or just simply disable animation.
Note: React does support another mode called concurrent, it's not enabled by default, not yet. However what it does is that, assuming each plot is captured by a small component, and it spends 1ms for render, then after it renders 16 components, it believes it spend too much time in this rendering, and give the response back to the browser to handle other things, ex. user input etc. This way you can start to scroll your page or move your mouse. And in the next cycle it can pick up the next 16 components. Assuming you have 1000 components, it'll take couple of cycles before it can finish all the rendering. And if you zooms again in the middle, it'll skip the first 16 components and move to the new render all over again. Hope you get the idea. It might help your problem with the latest React 18.
Refer to the post plotting 50 million points with d3.js.
Sluggish interaction with zoom and pan are due to too many elements in the svg. The key is to use hierarchical levels of detail, to limit the maximum elements in svg.

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: Multiple Values on Y-Axis

I have a graph I need to make but having a hard time figuring out the best approach. Essentially what I need is two different data sets on the y-axis that are separate values but still related. At zero on the y-axis the data set changes to a different value that goes in positive increments.This is an example of the type of graph I am talking about
What would be the best way to go about creating this? While I can certainly find examples of multiple y-axis graphs, they don't seem to account for this use case.
You can indeed create two different scales, which is probably the standard solution, or... you can create only one scale! So, just for the sake of curiosity, here is how to do it:
Create a scale going from -10 to 10...
var yScale = d3.scaleLinear()
.domain([-10, 10])
... changing the negative values to positive ones in the axis...
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
... and, of course, changing the y values to negative ones in the data for the lines below the x axis (here named dataInspiration):
dataInspiration.forEach(d => d.y = -d.y)
Here is a demo using random numbers:
var width = 600,
height = 200,
padding = 20;
svg = d3.select('body').append('svg')
.attr('width', width)
.attr('height', height);
var dataExpiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
var dataInspiration = d3.range(10).map(d => ({
x: d,
y: Math.random() * 10
}));
dataInspiration.forEach(d => d.y = -d.y)
var xScale = d3.scalePoint()
.domain(d3.range(10))
.range([padding, width - padding]);
var yScale = d3.scaleLinear()
.domain([-10, 10])
.range([height - padding, padding])
var line = d3.line()
.x(d => xScale(d.x))
.y(d => yScale(d.y))
.curve(d3.curveMonotoneX);
var lineExpiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "blue")
.attr("d", line(dataExpiration));
var lineInspiration = svg.append("path")
.attr("fill", "none")
.attr("stroke", "red")
.attr("d", line(dataInspiration));
var xAxis = d3.axisBottom(xScale)
.tickFormat(d => d != 0 ? d : null);
var yAxis = d3.axisLeft(yScale)
.tickFormat(d => d < 0 ? Math.abs(d) : d);
var gX = svg.append("g")
.attr("transform", "translate(0," + yScale(0) + ")")
.call(xAxis);
var gY = svg.append("g")
.attr("transform", "translate(" + padding + ",0)")
.call(yAxis);
<script src="https://d3js.org/d3.v4.min.js"></script>

Scale in different units

How to use D3 to convert and display the right information from different units
E.g.
All data is in mm..
[
{ label: 'sample1', x: 300 },
{ label: 'sample2', x: 1200 },
{ label: 'sample3', x: 4150 }
]
So, the question is, how can I create a scale that understand the sample3 should be point in same place after the 4 and before 5.
Consider
10000, its just a sample, can be 102301 or any value
I want to use D3 scale if possible to do this conversion
Attempt
let scaleX = d3.scale.linear().domain([-10, 10]).range([0, 500]) // Missing the mm information...
You have a conceptual problem here:
Mapping an input (domain) to an output (range): that's the task of the scale.
Formatting the number and the unit (if any) in the axis: that's the task of the axis generator
Thus, in your scale, you'll have to set the domain to accept the raw, actual data (that is, the data the way it is) you have:
var scale = d3.scaleLinear()
.domain([-10000, 10000])//the extent of your actual data
.range([min, max]);
Then, in the axis generator, you change the value in the display. Here, I'm simply dividing it by 1000 and adding "mm":
var axis = d3.axisBottom(scale)
.tickFormat(d => d / 1000 + "mm");
Note that I'm using D3 v4 in these snippets.
Here is a demo using these values: -7500, 500 and 4250. You can see that the circles are in the adequate position, but the axis shows the values in mm.
var data = [-7500, 500, 4250];
var svg = d3.select("body")
.append("svg")
.attr("width", 500)
.attr("height", 200);
var scale = d3.scaleLinear()
.domain([-10000, 10000])
.range([20, 480]);
var axis = d3.axisBottom(scale)
.tickFormat(d => d / 1000 + "mm");
var circles = svg.selectAll("foo")
.data(data)
.enter()
.append("circle")
.attr("r", 4)
.attr("fill", "teal")
.attr("cy", 40)
.attr("cx", d => scale(d));
var g = svg.append("g")
.attr("transform", "translate(0,60)")
.call(axis);
<script src="https://d3js.org/d3.v4.js"></script>
I found a way to do that..
const SIZE_MM = 10000
const SIZE_PX = 500
const scaleFormat = d3.scale.linear().domain([0, SIZE_MM]).range([-10, 10])
const ticksFormat = d => Math.round(scaleFormat(d))
const ticks = SIZE_MM / SIZE_PX
const lineScale = d3.scale.linear().domain([0, SIZE_MM ]).range([0, SIZE_PX])
lineScale(9500)
// 475

D3.js zoomable axis- how to zoom in only one direction?

I am using D3.js v4.
I have a minimum example working with zooming in and out on a single axis, with the following code:
// Create dummy data
var data = [];
for (var i = 0; i < 100; i++) {
data.push([Math.random(), Math.random()]);
}
// Set window parameters
var width = 330
var height = 200
// Append div, svg
d3.select('body').append('div')
.attr('id', 'div1')
d3.select('#div1')
.append("svg").attr("width", width).attr("height",height)
.attr('id','chart')
// Create scaling factors
var x = d3.scaleLinear()
.domain([0,1])
.range([0, (width - 30)])
var y = d3.scaleLinear()
.domain([0,1])
.range([0,height])
// Create group, then append circles
d3.select('#chart').append('g')
.attr('id','circlesplot')
d3.select('#circlesplot')
.selectAll('circles')
.data(data)
.enter().append('circle')
.attr('cx', function(d,i){ return x(d[0]); })
.attr('cy', function(d,i){ return y(d[1]); })
.attr('r', 4)
// Create y axis, append to chart
var yaxis = d3.axisRight(y)
.ticks(10)
var yaxis_g = d3.select('#chart').append('g')
.attr('id', 'yaxis_g')
.attr('transform','translate(' + (width - 30) +',0)')
.call(yaxis)
// Create zoom svg to the right
var svg = d3.select('#div1')
.append('svg')
.attr('width', 30)
.attr('height', height)
.attr('transform', 'translate('+ width + ',0)')
.call(d3.zoom()
.on('zoom', zoom))
function zoom() {
// Rescale axis during zoom
yaxis_g.transition()
.duration(50)
.call(yaxis.scale(d3.event.transform.rescaleY(y)))
// re-draw circles using new y-axis scale
var new_y = d3.event.transform.rescaleY(y);
d3.selectAll('circle').attr('cy', function(d) { return new_y(d[1])})
}
fiddle here: https://jsfiddle.net/v0aw9Ler/#&togetherjs=2wg7s8xfhC
Putting the mouse just to the right of the yaxis and scrolling gives the zooming function on the y axis.
What I'd like to happen is for the y axis maximum (in this case 1.0) to stay fixed, while zooming only in the other direction. You can kind of see what I mean by placing the mouse at the very bottom and just to the right of the y axis, and see the points cluster at the bottom of the graph.
I think it has to do with using zoom.extent(), but I'm just really not sure where to go from here; advice is greatly appreciated.
Source for this min working example:
http://bl.ocks.org/feyderm/03602b83146d69b1b6993e5f98123175

Categories

Resources