D3 semantic zooming with Reusable Pattern - javascript

I'm trying to implement semantic zooming while using Mike Bostock's Towards Reusable Charts pattern (where a chart is represented as a function). In my zoom handler, I'd like to use transform.rescaleX to update my scale and then simply call the function again.
It almost works but the rescaling seems to accumulate zoom transforms getting faster and faster. Here's my fiddle:
function chart() {
let aspectRatio = 10.33;
let margin = { top: 0, right: 0, bottom: 5, left: 0 };
let current = new Date();
let scaleBand = d3.scaleBand().padding(.2);
let scaleTime = d3.scaleTime().domain([d3.timeDay(current), d3.timeDay.ceil(current)]);
let axis = d3.axisBottom(scaleTime);
let daysThisMonth = d3.timeDay.count(d3.timeMonth(current), d3.timeMonth.ceil(current));
let clipTypes = [ClipType.Scheduled, ClipType.Alarm, ClipType.Motion];
let zoom = d3.zoom().scaleExtent([1 / daysThisMonth, 1440]);
let result = function(selection) {
selection.each(function(data) {
let selection = d3.select(this);
let outerWidth = this.getBoundingClientRect().width;
let outerHeight = outerWidth / aspectRatio;
let width = outerWidth - margin.left - margin.right;
let height = outerHeight - margin.top - margin.bottom;
scaleBand.domain(d3.range(data.length)).range([0, height * .8]);
scaleTime.range([0, width]);
zoom.on('zoom', _ => {
scaleTime = d3.event.transform.rescaleX(scaleTime);
selection.call(result);
});
let svg = selection.selectAll('svg').data([data]);
let svgEnter = svg.enter().append('svg').attr('viewBox', '0 0 ' + outerWidth + ' ' + outerHeight);//.attr('preserveAspectRatio', 'xMidYMin slice');
svg = svg.merge(svgEnter);
let defsEnter = svgEnter.append('defs');
let defs = svg.select('defs');
let gMainEnter = svgEnter.append('g').attr('id', 'main');
let gMain = svg.select('g#main').attr('transform', 'translate(' + margin.left + ' ' + margin.top + ')');
let gAxisEnter = gMainEnter.append('g').attr('id', 'axis');
let gAxis = gMain.select('g#axis').call(axis.scale(scaleTime));
let gCameraContainerEnter = gMainEnter.append('g').attr('id', 'camera-container');
let gCameraContainer = gMain.select('g#camera-container').attr('transform', 'translate(' + 0 + ' ' + height * .2 + ')').call(zoom);
let gCameraRowsEnter = gCameraContainerEnter.append('g').attr('id', 'camera-rows');
let gCameraRows = gCameraContainer.select('g#camera-rows');
let gCameras = gCameraRows.selectAll('g.camera').data(d => {
return d;
});
let gCamerasEnter = gCameras.enter().append('g').attr('class', 'camera');
gCameras = gCameras.merge(gCamerasEnter);
gCameras.exit().remove();
let rectClips = gCameras.selectAll('rect.clip').data(d => {
return d.clips.filter(clip => {
return clipTypes.indexOf(clip.type) !== -1;
});
});
let rectClipsEnter = rectClips.enter().append('rect').attr('class', 'clip').attr('height', _ => {
return scaleBand.bandwidth();
}).attr('y', (d, i, g) => {
return scaleBand(Array.prototype.indexOf.call(g[i].parentNode.parentNode.childNodes, g[i].parentNode)); //TODO: sloppy
}).style('fill', d => {
switch(d.type) {
case ClipType.Scheduled:
return '#0F0';
case ClipType.Alarm:
return '#FF0';
case ClipType.Motion:
return '#F00';
};
});
rectClips = rectClips.merge(rectClipsEnter).attr('width', d => {
return scaleTime(d.endTime) - scaleTime(d.startTime);
}).attr('x', d => {
return scaleTime(d.startTime);
});
rectClips.exit().remove();
let rectBehaviorEnter = gCameraContainerEnter.append('rect').attr('id', 'behavior').style('fill', '#000').style('opacity', 0);
let rectBehavior = gCameraContainer.select('rect#behavior').attr('width', width).attr('height', height * .8);//.call(zoom);
});
};
return result;
}
// data model
let ClipType = {
Scheduled: 0,
Alarm: 1,
Motion: 2
};
let data = [{
id: 1,
src: "assets/1.jpg",
name: "Camera 1",
server: 1
}, {
id: 2,
src: "assets/2.jpg",
name: "Camera 2",
server: 1
}, {
id: 3,
src: "assets/1.jpg",
name: "Camera 3",
server: 2
}, {
id: 4,
src: "assets/1.jpg",
name: "Camera 4",
server: 2
}].map((_ => {
let current = new Date();
let randomClips = d3.randomUniform(24);
let randomTimeSkew = d3.randomUniform(-30, 30);
let randomType = d3.randomUniform(3);
return camera => {
camera.clips = d3.timeHour.every(Math.ceil(24 / randomClips())).range(d3.timeDay.offset(current, -30), d3.timeDay(d3.timeDay.offset(current, 1))).map((d, indexEndTime, g) => {
return {
startTime: indexEndTime === 0 ? d : d3.timeMinute.offset(d, randomTimeSkew()),
endTime: indexEndTime === g.length - 1 ? d3.timeDay(d3.timeDay.offset(current, 1)) : null,
type: Math.floor(randomType())
};
}).map((d, indexStartTime, g) => {
if(d.endTime === null)
d.endTime = g[indexStartTime + 1].startTime;
return d;
});
return camera;
};
})());
let myChart = chart();
let selection = d3.select('div#container');
selection.datum(data).call(myChart);
<div id="container"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.13.0/d3.min.js"></script>
Edit: The zoom handler below works fine, but I'd like a more general solution:
let newScaleTime = d3.event.transform.rescaleX(scaleTime);
d3.select('g#axis').call(axis.scale(newScaleTime));
d3.selectAll('rect.clip').attr('width', d => {
return newScaleTime(d.endTime) - newScaleTime(d.startTime);
}).attr('x', d => {
return newScaleTime(d.startTime);
});

The short answer is you need to implement a reference scale to indicate what the scale's base state is when unmanipulated by the zoom. Otherwise you will run into the problem you describe: "It almost works but the rescaling seems to accumulate zoom transforms getting faster and faster. "
To see why a reference scale is needed, zoom in on the graph and out (once each) without moving the mouse. When you zoom in, the axis changes. When you zoom out the axis does not. Note the scale factor on the intial zoom in and the first time you zoom out: 1.6471820345351462 on the zoom in, 1 on the zoom out. The number represents how much the to magnify/minify whatever it is we are zooming in on. On the initial zoom in we magnify by a factor of ~1.65. On the preceding zoom out we minify by a factor of 1, ie: not at all. If on the other hand you zoom out first, you minify by a factor of about 0.6 and then if you were to zoom in you magnify by a factor of 1. I've built a stripped down of your example to show this:
function chart() {
let zoom = d3.zoom().scaleExtent([0.25,20]);
let scale = d3.scaleLinear().domain([0,1000]).range([0,550]);
let axis = d3.axisBottom;
let result = function(selection) {
selection.each(function() {
let selection = d3.select(this);
selection.call(axis(scale));
selection.call(zoom);
zoom.on('zoom', function() {
scale = d3.event.transform.rescaleX(scale);
console.log(d3.event.transform.k);
selection.call(result);
});
})
}
return result;
}
d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>
The scale should be relative to the initial zoom factor, usually 1. In otherwords, the zoom is cumulative, it records magnification/minification as a factor of the initial scale, not the last step (otherwise transform k values would only be one of three values: one value for zooming out, another for zooming in and one for remaining the same and all relative to the current scale). This is why rescaling the initial scale doesn't work - you lose the reference point to the initial scale that the zoom is referencing.
From the docs, if you redefine a scale with d3.event.transform.rescaleX, we get a scale that reflects the zoom's (cumulative) transformation:
[the rescaleX] method does not modify the input scale x; x thus
represents the untransformed scale, while the returned scale
represents its transformed view. (docs)
Building on this, if we zoom in twice in a row, the first time we zoom in we see the transform.k value is ~1.6x on the first time, the second time it is ~2.7x. But, since we rescale the scale, we apply a zoom of 2.7x on a scale that has already been zoomed in 1.6x, giving us a scale factor of ~4.5x rather than 2.7x. To make matters worse, if we zoom in twice and then out once, the zoom (out) event gives us a scale value that is still greater than 1 (~1.6 on first zoom in, ~2.7 on second, ~1.6 on zoom out), hence we are still zooming in despite scrolling out:
function chart() {
let zoom = d3.zoom().scaleExtent([0.25,20]);
let scale = d3.scaleLinear().domain([0,1000]).range([0,550]);
let axis = d3.axisBottom;
let result = function(selection) {
selection.each(function() {
let selection = d3.select(this);
selection.call(axis(scale));
selection.call(zoom);
zoom.on('zoom', function() {
scale = d3.event.transform.rescaleX(scale);
var magnification = 1000/(scale.domain()[1] - scale.domain()[0]);
console.log("Actual magnification: "+magnification+"x");
console.log("Intended magnification: "+d3.event.transform.k+"x")
console.log("---");
selection.call(result);
});
})
}
return result;
}
d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>
I haven't discussed the x offset portion of the zoom, but you can imagine that a similar problem occurs - the zoom is cumulative but you lose the initial reference point that those cumulative changes are in reference to.
The idiomatic solution is to use a reference scale and the zoom to create a working scale used for plotting rectangles/axes/etc. The working scale is initially the same as the reference scale (generally) and is set as so: workingScale = d3.event.transform.rescaleX(referenceScale) on each zoom.
function chart() {
let zoom = d3.zoom().scaleExtent([0.25,20]);
let workingScale = d3.scaleLinear().domain([0,1000]).range([0,550]);
let referenceScale = d3.scaleLinear().domain([0,1000]).range([0,550]);
let axis = d3.axisBottom;
let result = function(selection) {
selection.each(function() {
let selection = d3.select(this);
selection.call(axis(workingScale));
selection.call(zoom);
zoom.on('zoom', function() {
workingScale = d3.event.transform.rescaleX(referenceScale);
var magnification = 1000/(workingScale.domain()[1] - workingScale.domain()[0]);
console.log("Actual magnification: "+magnification+"x");
console.log("Intended magnification: "+d3.event.transform.k+"x")
console.log("---");
selection.call(result);
});
})
}
return result;
}
d3.select("svg").call(chart());
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="550" height="200"></svg>

Related

Why isn't my path working with gsap's motion path?

Background:
I am animating a circle down my web page to follow a certain path using GSAP's motion path plugin. I have scaled the path to ensure it follows the same proportions on all devices using this code:
let w = window.innerWidth;
let h = document.querySelector("main").offsetHeight;
const pathSpec = "M305.91,0c8.54,101.44,17.07,203.69,5.27,304.8s-45.5,202.26-112.37,279c-36,41.37-80.92,74.88-113.34,119.16-66.32,90.59-70.8,210.86-72.71,323.13C9.69,1206.79,9.48,1387.9,1.71,1568c-8,186.11,25.77,370.42,48.07,554.43q4.36,36,8.07,72.07c14.18,140.22,9.95,281.38,8.06,422-1.87,139.1,26.43,260.75,66.38,393.67l82.47,274.39,8.25,27.44,29.87,129c40.8,176.3,87,363.23,64.51,545.49-11,89.33-3.5,182.44-2.28,272.84l3.83,286.1c3.63,188.78,5.07,377.63,7.6,566.44l3.83,286.08c1.27,94.55-4,191.79,3.84,286,14.37,172.9-10,353.08-14.33,527.78q-7,283.21,1.14,566.55,8.14,281.61,31.34,562.53c7.43,90,21.31,180.76,26.7,270.47,5.6,93.24-4,190-6,283.47l-3.45,164.33";
const yScale = h / 8059.08;
const xScale = w / 380.28;
let newPath = "";
const pathBits = Array.from(pathSpec.matchAll(/([MmLlHhVvCcSsQqTtAa])([\d\.,\se-]*)/g));
console.log(pathBits);
pathBits.forEach((bit) => {
const command = bit[1];
const coordinates = bit[2];
newPath += command;
const coordArray = coordinates.split(/[,\s]/g);
// console.log("coordArray", coordArray);
newPath += coordArray.map((coord, index) => {
if (index % 2 == 0) {
return parseFloat(coord) * yScale;
}
else {
return parseFloat(coord) * xScale;
}
}).join(',');
});
So, essentially, I've created coordArray to split the array into the x and y coordinates and added it to newPath which is the scaled version. Then, I've added newPath to motionPath using:
const tlCircle = gsap.timeline();
tlCircle.to(".circle-animation", {
scrollTrigger: {
scroller: "main",
trigger: ".circle-animation",
start: "top 10%",
end: "max",
scrub: 1,
},
ease: "true",
motionPath: {
path: newPath,
autoRotate: true
}
});
When I put the pathSpec var in the path parameter, my circle animates but not when I put the newPath in, even though the web console shows it is in the same format as pathSpec but just scaled.
I've tried to debug this console logs to see what the input and outputs are which are what I want -- newPath is a. svg path in a string but scaled to fit my window but it doesn't work when I use it in motionPath.

Highcharts: xxx.animate doesn't work correctly when objects are replaced each other more than once

The below code doesn't work correctly when I click the button more than once. I found the temporary solution is to call chart.destroy() and chart = Highcharts.chart(...) to reset the internal states. But I want to know the right solution because that's just a temporary measure and it won't solve the actual cause of the problem. Please anyone can help me?
var chart = Highcharts.chart('container', options);
var update = function () {
var points = chart.series[0].points;
chart.series[0].setData([points[0].y, points[1].y + 200]);
};
var rotate = function () {
var points = chart.series[0].points,
ticks = chart.xAxis[0].ticks;
var sortedPoints = points.slice();
sortedPoints.sort(function(a,b){
return b.y - a.y;
});
points.forEach(function(point, i){
sortedPoints.forEach(function(sPoint, j){
if (point === sPoint){
points[i].graphic.animate({
x: points[j].shapeArgs.x
});
points[i].dataLabel.animate({
y: points[j].dataLabel.y
});
ticks[i].label.animate({
y: ticks[j].label.xy.y
});
}
});
});
};
document.getElementById("button").addEventListener("click", function() {
update();
rotate();
}, false);
Live demo: https://jsfiddle.net/Shinohara/dscvwrxu/22/
You do not need to reset the internal states. You can relay on current and initial values:
var rotate = function() {
var points = chart.series[0].points,
index,
ticks = chart.xAxis[0].ticks;
var sortedPoints = points.slice();
sortedPoints.sort(function(a, b) {
return b.y - a.y;
});
points.forEach(function(point, i) {
sortedPoints.forEach(function(sPoint, j) {
if (point === sPoint) {
index = j;
if (points[i].animated) {
points[i].animated = false;
j = i;
} else {
points[i].animated = true;
}
points[i].graphic.animate({
x: points[j].shapeArgs.x
});
points[i].dataLabel.animate({
y: points[index].dataLabel.y
});
ticks[i].label.animate({
y: ticks[j].label.xy.y
});
}
});
});
};
Live demo: https://jsfiddle.net/BlackLabel/jfd0vuy8/
#ppotaczek Your live demo rotates bars infinitely when I click the button more than once. In this case, what I want is the code which rotates bars for the first time only. The below is my intention.
State1: Cat1=1000, Cat2=900 # Current rank is 1st:Cat1 and 2nd:Cat2
Interval1: Cat2+=200 and rotate bars between state1 and state2
State2: Cat1=1000, Cat2=1100 # Current rank is 1st:Cat2 and 2nd:Cat1
Interval2: Cat2+=200 and don't rotate bars between state2 and state3
State3: Cat1=1000, Cat2=1300 # Current rank is 1st:Cat2 and 2nd:Cat1
I think the reason why your live demo rotates bars between state2 and state3 is update() also rotates bars.
// This function unintentionally rotates bars for the second time.
var update = function() {
var points = chart.series[0].points;
chart.series[0].setData([points[0].y, points[1].y + 200]);
};
I want to put bars from largest to smallest. And I don't want to rotate bars when the rank is not changed. Do you have any good solution?

Negative and Zero Log Values in d3

I am trying to plot multiple overlaying charts with X Axis Time and Y axis should toggle between Linear and Log Scale. I am not able to represent my charts appropriately for log scale even after using d3.scale.log().clamp(true).domain([]).range([]).nice()
Below is the code which I am using to map data points to Y axis
_initScaleYMap = function() {
const chart = this.$root().datum();
const data = chart.getDataSets() || [];
const dataSets = chart.getDataSets().filter((ds) => ds.getValueAxisId());
this.scaleYMap(new Map());
const dataSetsGroupedByValueAxisId = _.groupBy(dataSets, (dataSet) => dataSet.getValueAxisId());
Object.entries(dataSetsGroupedByValueAxisId).forEach(([valueAxisId, matchedDataSets]) => {
data.forEach((d) => {
const chartValueAxis = chart._getValueAxisById(valueAxisId);
if (!chartValueAxis) {
throw `Value axis '${valueAxisId} '`;
}
const min = chartValueAxis.getMin();
const max = chartValueAxis.getMax();
const domain = (Number.isFinite(min) && Number.isFinite(max) && !this.forceArea()) ? [min, max] : getYDomain(chart, matchedDataSets);
if (!d.getShowLog()) {
this.scaleYMap().set(valueAxisId,
d3.scale.linear()
.domain(domain)
.range([this.height() - this.margin().top - this.margin().bottom, 0])
);
} else {
this.scaleYMap().set(valueAxisId,
d3.scale.log().domain(domain).range([this.height() - this.margin().top - this.margin().bottom, 1]).nice()
);
}
});
});
return this;
};
I am attaching the before and after photos, before being the linear scale for y and after being the Log scale
On Linear Scale
On Log Scale

Highcharts manually added svg elements not following stock graph on pan

I have a click event in my highstock / highchart graph, I have successfully added custom drawing tools such as adding lines and text. Here is the code for that
$('#stockchart-canvas-container').on('click','svg',function(e){
var svg = $('#stockchart-canvas-container svg')[0];
var point= svg.createSVGPoint(), svgP
point.x = e.clientX
point.y = e.clientY
svgP = point.matrixTransform(svg.getScreenCTM().inverse());
if(user.selected_tool=='line'){
if(user.previous_x == undefined && user.previous_y == undefined) {
user.current_x = svgP.x
user.current_y = svgP.y
user.previous_x = 0
user.previous_y = 0
$('#stockchart-canvas-container').on('mousemove','svg',function(ev){
var svg2 = $('#stockchart-canvas-container svg')[0];
var point2= svg.createSVGPoint(), svgP2
point2.x = ev.clientX
point2.y = ev.clientY
svgP2 = point2.matrixTransform(svg2.getScreenCTM().inverse());
$('#temp-line').remove()
stockchart.renderer.path(['M',
user.current_x,
user.current_y,
'L',
svgP2.x,
svgP2.y,
'Z',
]).attr({'stroke-width':2,stroke:'#ccc',id:'temp-line'}).add(stockchart.seriesGroup)
})
} else {
$('#stockchart-canvas-container').off('mousemove')
stockchart.renderer.path(['M',
user.current_x,
user.current_y,
'L',
svgP.x,
svgP.y,
'Z'
]).attr({'stroke-width':2,stroke:'#ccc'}).add(stockchart.seriesGroup)
user.current_x=0
user.current_y=0
user.previous_x=undefined
user.previous_y=undefined
}
} else if (user.selected_tool=='text') {
$('#insert-text-modal').modal('show')
$('#accept-insert-text').on('click',function(){
if($('#text-input').val()){
stockchart.renderer.text($('#text-input').val(),svgP.x,svgP.y).add(stockchart.seriesGroup)
}
$(this).off('click')
$('#insert-text-modal').modal('hide')
})
}
})
My problem is that I want the line and the text to follow the stock graph as I pan or zoom the graph. Any ideas how I can do this?
You have to preserve coordinate values at the moment the text/line is drawn - the coordinates in terms of axes. On each chart redraw, you need to reposition the line/text - so you have to calculate new pixel position (which can be calculated via axis.toPixels) and set the new values to the line/text. For a text you need to calculate one point, for a path element you need to recalculate each segment.
See the code below:
Function for calculating pixels from values and values from pixels - it includes some basic logic for hiding a text if it overflows a chart's plot area - but it should be adjusted depending on your needs.
function translate (x, y, chart, toPixels) {
const xAxis = chart.xAxis[0]
const yAxis = chart.yAxis[0]
let tx, ty, hide
if (toPixels) {
tx = xAxis.toPixels(x)
ty = yAxis.toPixels(y)
if (tx < xAxis.left || tx > xAxis.left + xAxis.width) {
hide = true
} else if (!hide && (ty < yAxis.top || ty > yAxis.top + yAxis.height)) {
hide = true
}
if (hide) {
tx = -9e7
ty = -9e7
}
} else {
tx = xAxis.toValue(x)
ty = yAxis.toValue(y)
}
return { x: tx, y: ty }
}
On chart click - it adds the text and keep in the array, on chart redraw r - it repositions items.
chart: {
events: {
load: function () {
this.drawnItems = []
},
click: function (e) {
const { x, y } = e
const text = this.renderer.text('custom text', x, y).add()
text.point = translate(x, y, this)
this.drawnItems.push(text)
},
redraw: function () {
this.drawnItems.forEach(item => {
const { x, y } = item.point
item.attr(translate(x, y, this, true))
})
}
}
},
Live example: http://jsfiddle.net/nsf67ro6/

Vis.js won't zoom-in further than scale 1.0 with .fit()

I am using the library Vis.js to display a Network.
For my application, I need the network to be displayed fullscreen, with the nodes almost touching the borders of its container.
The problem comes from network.fit(); it won't Zoom-In further than scale '1.0'
I wrote a Fiddle to showcase the issue:
http://jsfiddle.net/v1467x1d/12/
var nodeSet = [
{id:1,label:'big'},
{id:2,label:'big too'} ];
var edgeSet = [
{from:1, to:2} ];
var nodes = new vis.DataSet(nodeSet);
var edges = new vis.DataSet(edgeSet);
var container = document.getElementById('mynetwork');
var data = {
nodes: nodes,
edges: edges
};
var options = {};
var network = new vis.Network(container, data, options);
network.fit();
console.log( 'scale: '+ network.getScale() ); // Always 1
How can I force Vis to zoom until the network is fullscreen?
As Richard said - now, this method does not work as expected. You can use a custom method, as a concept:
function bestFit() {
network.moveTo({scale:1});
network.stopSimulation();
var bigBB = { top: Infinity, left: Infinity, right: -Infinity, bottom: -Infinity }
nodes.getIds().forEach( function(i) {
var bb = network.getBoundingBox(i);
if (bb.top < bigBB.top) bigBB.top = bb.top;
if (bb.left < bigBB.left) bigBB.left = bb.left;
if (bb.right > bigBB.right) bigBB.right = bb.right;
if (bb.bottom > bigBB.bottom) bigBB.bottom = bb.bottom;
})
var canvasWidth = network.canvas.body.container.clientWidth;
var canvasHeight = network.canvas.body.container.clientHeight;
var scaleX = canvasWidth/(bigBB.right - bigBB.left);
var scaleY = canvasHeight/(bigBB.bottom - bigBB.top);
var scale = scaleX;
if (scale * (bigBB.bottom - bigBB.top) > canvasHeight ) scale = scaleY;
if (scale>1) scale = 0.9*scale;
network.moveTo({
scale: scale,
position: {
x: (bigBB.right + bigBB.left)/2,
y: (bigBB.bottom + bigBB.top)/2
}
})
}
[ http://jsfiddle.net/dv4qyeoL/ ]
I am sorry it is impossible to do this using network.fit. Here is the relevant code.
However, you can patch it yourself and include the modified version into your application which should then work as expected.
Here is a fiddle (line 38337 for the modification). I can't promise it won't break something else though.
Relevant code :
/*if (zoomLevel > 1.0) {
zoomLevel = 1.0;
} else if (zoomLevel === 0) {
zoomLevel = 1.0;
}*/
if (zoomLevel === 0) {
zoomLevel = 1.0;
}

Categories