Related
This is a follow up from my previous question.
I have a progressbar.js circle that animates on scroll. If there is just one circle it works as expected.
Now I want to create many of these animated circles by looping through an object with different key-values pairs.
For example:
var divsValues = {
'total-score-circle': 0.75,
'general-score-circle': 0.80,
'speed-score-circle': 0.85,
'privacy-score-circle': 0.90,
};
For each key-value pair, the key is a div ID and the value is number that tells the animation how far to go.
Below is the code where I try to implement my loop, but the problem is that only the last circle is animated on scroll. All the circles appear in their "pre-animation" state, but only the last circle actually becomes animated when you scroll to the bottom.
I need each circle to animate once it is in the viewport.
//Loop through my divs and create animated circle for each one
function makeCircles() {
var divsValues = {
'total-score-circle': 0.75,
'general-score-circle': 0.80,
'speed-score-circle': 0.85,
'privacy-score-circle': 0.90,
};
for (var i in divsValues) {
if (divsValues.hasOwnProperty(i)) {
bgCircles(i, divsValues[i]);
}
}
}
makeCircles();
// Check if element is scrolled into view
function isScrolledIntoView(elem) {
var docViewTop = jQuery(window).scrollTop();
var docViewBottom = docViewTop + jQuery(window).height();
var elemTop = jQuery(elem).offset().top;
var elemBottom = elemTop + jQuery(elem).height();
return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
}
//Circle design and animation
function bgCircles(divid, countvalue) {
// Design the circle using progressbar.js
bar = new ProgressBar.Circle(document.getElementById(divid), {
color: '#ddd',
// This has to be the same size as the maximum width to
// prevent clipping
strokeWidth: 4,
trailWidth: 4,
easing: 'easeInOut',
duration: 1400,
text: {
autoStyleContainer: false
},
from: {
color: '#ddd',
width: 4
},
to: {
color: '#888',
width: 4
},
// Set default step function for all animate calls
step: function(state, circle) {
circle.path.setAttribute('stroke', state.color);
circle.path.setAttribute('stroke-width', state.width);
var value = Math.round(circle.value() * 100);
if (value === 0) {
circle.setText('');
} else {
circle.setText(value + '%');
}
}
});
bar.text.style.fontFamily = '"Montserrat", sans-serif';
bar.text.style.fontSize = '1.7rem';
bar.trail.setAttribute('stroke-linecap', 'round');
bar.path.setAttribute('stroke-linecap', 'round');
//Animate the circle when scrolled into view
window.onscroll = function() {
if (isScrolledIntoView(jQuery('#' + divid))) bar.animate(countvalue);
else bar.animate(0); // or bar.set(0)
}
}
#total-score-circle,
#general-score-circle,
#speed-score-circle,
#privacy-score-circle {
margin: 0.8em auto;
width: 100px;
height: 100px;
position: relative;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/progressbar.js/1.0.1/progressbar.min.js"></script>
<div id="total-score-circle"></div>
<div id="general-score-circle"></div>
<div id="speed-score-circle"></div>
<div id="privacy-score-circle"></div>
While researching this problem I learned that JavaScript will only output the last value of a loop, which I thought could be the cause of my problem.
So I tried to replace the for loop with these solutions...
Solution 1: Same problem as before, only the last loop animates on scroll.
for (var i in divsValues) {
(function(){
var ii = i;
if (divsValues.hasOwnProperty(ii)) {
bgCircles(ii, divsValues[ii]);
}
})();
}
Solution 2: Again, same problem as before, only the last loop animates on scroll.
for (var i in divsValues) {
let ii = i;
if (divsValues.hasOwnProperty(ii)) {
bgCircles(ii, divsValues[ii]);
}
}
Solution 3: Again, same problem as before, only the last loop animates on scroll.
for (var i in divsValues) {
try{throw i}
catch(ii) {
if (divsValues.hasOwnProperty(ii)) {
bgCircles(ii, divsValues[ii]);
}
}
}
So now I'm thinking maybe the problem is not the loop, but something I can't see or figure out.
You were quite close.
Here are the fixes:
In the function bgCircles(...), use var to declare the bar in that function's scope:
var bar = new ProgressBar.Circle(document.getElementById(divid), {
When you set the animate scrolled into view checker event, you assign a new function over-and-over to window.onscroll. Since you are using jQuery, consider jQuery's .scroll event handler and use it like this:
$(window).scroll(function () {
if (isScrolledIntoView(jQuery('#' + divid))) bar.animate(countvalue);
else bar.animate(0); // or bar.set(0)
});
The solution in whole:
//Loop through my divs and create animated circle for each one
function makeCircles() {
var divsValues = {
'total-score-circle': 0.75,
'general-score-circle': 0.80,
'speed-score-circle': 0.85,
'privacy-score-circle': 0.90,
};
for (var i in divsValues) {
if (divsValues.hasOwnProperty(i)) {
bgCircles(i, divsValues[i]);
}
}
}
makeCircles();
// Check if element is scrolled into view
function isScrolledIntoView(elem) {
var docViewTop = jQuery(window).scrollTop();
var docViewBottom = docViewTop + jQuery(window).height();
var elemTop = jQuery(elem).offset().top;
var elemBottom = elemTop + jQuery(elem).height();
return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
}
//Circle design and animation
function bgCircles(divid, countvalue) {
// Design the circle using progressbar.js
var bar = new ProgressBar.Circle(document.getElementById(divid), {
color: '#ddd',
// This has to be the same size as the maximum width to
// prevent clipping
strokeWidth: 4,
trailWidth: 4,
easing: 'easeInOut',
duration: 1400,
text: {
autoStyleContainer: false
},
from: {
color: '#ddd',
width: 4
},
to: {
color: '#888',
width: 4
},
// Set default step function for all animate calls
step: function(state, circle) {
circle.path.setAttribute('stroke', state.color);
circle.path.setAttribute('stroke-width', state.width);
var value = Math.round(circle.value() * 100);
if (value === 0) {
circle.setText('');
} else {
circle.setText(value + '%');
}
}
});
bar.text.style.fontFamily = '"Montserrat", sans-serif';
bar.text.style.fontSize = '1.7rem';
bar.trail.setAttribute('stroke-linecap', 'round');
bar.path.setAttribute('stroke-linecap', 'round');
//Animate the circle when scrolled into view
$(window).scroll(function () {
if (isScrolledIntoView(jQuery('#' + divid))) bar.animate(countvalue);
else bar.animate(0); // or bar.set(0)
});
}
#total-score-circle,
#general-score-circle,
#speed-score-circle,
#privacy-score-circle {
margin: 0.8em auto;
width: 100px;
height: 100px;
position: relative;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/progressbar.js/1.0.1/progressbar.min.js"></script>
<div id="total-score-circle"></div>
<div id="general-score-circle"></div>
<div id="speed-score-circle"></div>
<div id="privacy-score-circle"></div>
Note:
Since I didn't edit any of your circle animation/circle visibility checking functions, I assume you intended the current state of your animate-when-scrolled-and-in-view functionality the way, that it is right now. In this current state, your script does/has the following side-effects:
If you don't scroll the page, not at all, your circles won't start to animate, even, when they are visible. Solution: encapsulate the visibility checker lines to a separate function and run, when creating the circles.
If you scroll through a circle, its animation with the percent will going to go to its default state, which is 0%. Solution: change the visibility checker function, when the particular element is not visible because of overscroll, return that state as visible too. This way your circles will stay at 100%, even when you scroll over them.
Regarding performance and best practices:
When using jQuery, make sure to call jQuery(...) or its shorthand $(...) as few times as possible. Use variables to store elements, properties, and data.
It's better to separate longer/larger, monolithic functions into smaller functions with a more narrow, but also more clear scope of functionality.
When using event listeners, make sure to run as few of them as possible. Structure your HTML and your JavaScript code to have clear and performant ways to access and modify your crucial elements, properties, and data.
The loop you have will run so fast that the browser engine wont be able to render the changes, I would suggest either you use setInterval() method or continuous setTimeout() method which will add some delay to your code so that the browser can render the changes you are making.
For your special case I would suggest:
var i = 0;
var tobecleared = setInterval(timer,1000);
function timer(){
var p = get_ith_key_from_divsvalues(i);//implement this method
console.log(p);
bgCircles(p, divsValues[p]);
i++;
if(i == Object.keys(divsValues).length)
clearInterval(tobecleared);
}
function get_ith_key_from_divsvalues(i){
var j = -1;
for(var property in divsValues){
j++;
if(j==i)
return property;
}
}
Note : window.onscroll is being overwritten in each call that is why only the last circle responds.
There are two fixes you need to apply.
Currently bar is the global variable, so it's always the same, to fix it declare it with var.
Use window.addEventListener to attach scroll event to window, by setting handler with window.onscroll you constantly override event handler and usage of addEventListener allow you attach more than one event handler.
//Loop through my divs and create animated circle for each one
$( document ).ready(function(){
function makeCircles() {
var divsValues = {
'total-score-circle': 0.75,
'general-score-circle': 0.80,
'speed-score-circle': 0.85,
'privacy-score-circle': 0.90,
};
for (var i in divsValues) {
if (divsValues.hasOwnProperty(i)) {
bgCircles(i, divsValues[i]);
}
}
}
makeCircles();
// Check if element is scrolled into view
function isScrolledIntoView(elem) {
var docViewTop = jQuery(window).scrollTop();
var docViewBottom = docViewTop + jQuery(window).height();
var elemTop = jQuery(elem).offset().top;
var elemBottom = elemTop + jQuery(elem).height();
return ((elemBottom <= docViewBottom) && (elemTop >= docViewTop));
}
//Circle design and animation
function bgCircles(divid, countvalue) {
// Design the circle using progressbar.js
var bar = new ProgressBar.Circle(document.getElementById(divid), {
color: '#ddd',
// This has to be the same size as the maximum width to
// prevent clipping
strokeWidth: 4,
trailWidth: 4,
easing: 'easeInOut',
duration: 1400,
text: {
autoStyleContainer: false
},
from: {
color: '#ddd',
width: 4
},
to: {
color: '#888',
width: 4
},
// Set default step function for all animate calls
step: function(state, circle) {
circle.path.setAttribute('stroke', state.color);
circle.path.setAttribute('stroke-width', state.width);
var value = Math.round(circle.value() * 100);
if (value === 0) {
circle.setText('');
} else {
circle.setText(value + '%');
}
}
});
bar.text.style.fontFamily = '"Montserrat", sans-serif';
bar.text.style.fontSize = '1.7rem';
bar.trail.setAttribute('stroke-linecap', 'round');
bar.path.setAttribute('stroke-linecap', 'round');
//Animate the circle when scrolled into view
window.addEventListener('scroll', function() {
if (isScrolledIntoView(jQuery('#' + divid))) bar.animate(countvalue);
else bar.animate(0); // or bar.set(0)
})
}
})
#total-score-circle,
#general-score-circle,
#speed-score-circle,
#privacy-score-circle {
margin: 0.8em auto;
width: 100px;
height: 100px;
position: relative;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/progressbar.js/1.0.1/progressbar.min.js"></script>
<div id="total-score-circle"></div>
<div id="general-score-circle"></div>
<div id="speed-score-circle"></div>
<div id="privacy-score-circle"></div>
I am trying to increase the size of my SVG container dynamically so that it fits all the data. There is a fiddle which explains the dynamic increase of the SVG: http://jsfiddle.net/CKW5q/
However, the same concept doesn't work for a bi-directional sankey chart(d3). Following is the function called to expand the parentnode:
function expand(node) {
node.state = "expanded";
node.children.forEach(function (child) {
child.state = "collapsed";
child._parent = this;
child.parent = null;
containChildren(child);
}, node);
HEIGHT = HEIGHT + node.children.length * 10; //update height by 10px per child
WIDTH = WIDTH + node.children.length * 10;//update width
}
svg.attr("height", HEIGHT); // update svg height
This gives very odd results. It definitely increases the container, but unfortunately keeps the original SVG dimensions intact:
I suppose that the SVG HEIGHT and WIDTH being declared at the start of the script needs to be updated, which for some reasons am not able to. Any help will be appreciated.
You will need to do something like this:
var Chart = (function(window, d3) {
(init code that doesn't change on resize here)
render();
function render() {
updateDimensions();
(code that needs to be re-rendered on resize here)
}
function updateDimensions() {
margin = {top: 100, right: 40, bottom: 80, left: 80}; // example
width = window.innerWidth - margin.left - margin.right;
height = window.innerHeight - margin.top - margin.bottom;
}
return {
render: render
}
})(window, d3);
window.addEventListener('resize', Chart.render);
I am trying to follow this tutorial here https://www.smashingmagazine.com/2012/10/design-your-own-mobile-game/ and I am stuck on the second part. (2. A Blank Canvas)
I am not sure where to put the POP.Draw object. Does it go inside of the var POP{} brackets where the other objects are created? I've tried keeping it inside, outside, and in the init function which I don't think makes sense. The purpose is to create methods within the new Draw object so they can be called later to create pictures in the canvas.
Here is my current code. It is the same as the one in the link:
var POP = {
//setting up initial values
WIDTH: 320,
HEIGHT: 480,
// we'll set the rest of these
//in the init function
RATIO: null,
currentWidth: null,
currentHeight: null,
canvas: null,
ctx: null,
init: function() {
//the proportion of width to height
POP.RATIO = POP.WIDTH / POP.HEIGHT;
//these will change when the screen is resized
POP.currentWidth = POP.WIDTH;
POP.currentHeight = POP.HEIGHT;
//this is our canvas element
POP.canvas = document.getElementsByTagName('canvas')[0];
//setting this is important
//otherwise the browser will
//default to 320x200
POP.canvas.width = POP.WIDTH;
POP.canvas.width = POP.HEIGHT;
//the canvas context enables us to
//interact with the canvas api
POP.ctx = POP.canvas.getContext('2d');
//we need to sniff out Android and iOS
// so that we can hide the address bar in
// our resize function
POP.ua = navigator.userAgent.toLowerCase();
POP.android = POP.ua.indexOf('android') > -1 ? true : false;
POP.ios = (POP.ua.indexOf('iphone') > -1 || POP.ua.indexOf('ipad') > -1) ? true : false;
//we're ready to resize
POP.resize();
POP.Draw.clear();
POP.Draw.rect(120, 120, 150, 150, 'green');
POP.Draw.circle(100, 100, 50, 'rgba(225,0,0,0.5)');
POP.Draw.text('Hello WOrld', 100, 100, 10, "#000");
},
resize: function() {
POP.currentHeight = window.innerHeight;
//resize the width in proportion to the new height
POP.currentWidth = POP.currentHeight * POP.RATIO;
//this will create some extra space on the page
//allowing us to scroll past the address bar thus hiding it
if (POP.android || POP.ios) {
document.body.style.height = (window.innerHeight + 50) + 'px';
}
//set the new canvas style width and height note:
//our canvas is still 320 x 400 but we're essentially scaling it with css
POP.canvas.style.width = POP.currentWidth + 'px';
POP.canvas.style.height = POP.currentHeight + 'px';
//we use a timeout here because some mobile browsers
//don't fire if there is not a short delay
window.selfTimeout(function() {
window.scrollTo(0, 1);
})
//this will create some extra space on the page
//enabling us to scroll past the address bar
//thus hiding it
if (POP.android || POP.ios) {
document.body.style.height = (window.innerHeight + 50) + 'px';
}
}
};
window.addEventListener('load', POP.init, false);
window.addEventListener('resize', POP.resize, false);
//abstracts various canvas operations into standalone functions
POP.Draw = {
clear: function() {
POP.ctx.clearRect(0, 0, POP.WIDTH, POP.HEIGHT);
},
rect: function(x, y, w, h, col) {
POP.ctx.fillStyle = col;
POP.ctx.fillRect(x, y, w, h);
},
circle: function(x, y, r, col) {
POP.ctx.fillStyle = col;
POP.ctx.beginPath();
POP.ctx.arc(x + 5, y + 5, r, 0, Math.PI * 2, true);
POP.ctx.closePath();
POP.ctx.fill();
},
text: function(string, x, y, size, col) {
POP.ctx.font = 'bold' + size + 'px Monospace';
POP.ctx.fillStyle = col;
POP.ctx.fillText(string, x, y);
}
};
SOLVED
I didn't realize but the completed code is on the webpage. I downloaded it and looked at the example for answers.
I solved the issue by placing the POP.Draw.clear, POP.Draw.rect methods before calling the POP.resize() method. I'm not really sure why the order matters, but it does.
I'm trying to make a pan-and-zoom Canvas for use as a minimap in a game. I've set the stage to be draggable so the player can move around with the mouse, as well as move individual objects on the layers of the stage. However, I don't want to be able to drag the stage into the surrounding white space. In other words, I only want to allow panning while zoomed in so you never encounter that white space. To try and constrain the stage, I've set up a dragBoundFunc:
dragBoundFunc: function(pos) {
return {
x: (pos.x < 0 ? 0 : pos.x > width ? width : pos.x),
y: (pos.y < 0 ? 0 : pos.y > height ? height : pos.y)
};
}
(Full JSFiddle example: http://jsfiddle.net/4Brry/)
I'm encountering two problems:
Firstly, the canvas is still able to move upwards and to the left.
Secondly, and more annoyingly, the constraints begin to misbehave when we begin to zoom.
When you zoom, the constraints don't take this fact into account. So, what if we add the stage offsets?
dragBoundFunc: function(pos) {
return {
x: ((ui.stage.getOffset().x+pos.x) < 0 ? 0 : pos.x > width ? width : pos.x),
y: ((ui.stage.getOffset().y+pos.y) < 0 ? 0 : pos.y > height ? height : pos.y)
};
}
(Full JSFiddle example: http://jsfiddle.net/2fLCd/)
This is a lot better, but now the view "snaps back" when you go too far. It would be nicer if it just stopped moving in the disallowed direction.
Anyone know how I could fix these issues?
Ok, I integrated the Zynga Scroller functionality with the KineticJS framework to get what I wanted.
Code in action
Let's step look at the code, which is an amalgamation of things I found online and wrote myself.
First, we generate the canvas using KineticJS:
var width = 700;
var height = 700;
var stage = new Kinetic.Stage({
container: 'container',
width: width,
height: height
});
var layer = new Kinetic.Layer({});
stage.add(layer);
/* I skipped some circle generation code. */
Then, we define some events that fire when dragging and dropping something on the layer. We'll use these to populate a global variable called somethingIsBeingDraggedInKinetic. We'll use this variable in the panning code of Zynga Scroller so the entire stage isn't moved around when you're dragging a KineticJS shape.
var somethingIsBeingDraggedInKinetic = false;
layer.on('dragstart', function(evt) {
// get the thing that is being dragged
var thing = evt.targetNode;
if( thing )
somethingIsBeingDraggedInKinetic = true;
});
layer.on('dragend', function(evt) {
// get the thing that is being dragged
var thing = evt.targetNode;
if( thing )
somethingIsBeingDraggedInKinetic = false;
});
Next up is the Zynga Scroller initialization code. The Zynga Scroller code handles input and transformations, and then passes on three values to a rendering function: top, left and zoom. These values are perfect for passing on to the KineticJS framework:
// Canvas renderer
var render = function(left, top, zoom) {
// Constrain the stage from going too far to the right
if( (left + (width / zoom)) > width )
left = width - (width / zoom );
// Constrain the stage from going too far to the left
if( (top + (height / zoom)) > height )
top = height - (height / zoom );
stage.setOffset(left, top);
stage.setScale(zoom);
stage.draw();
};
// Initialize Scroller
this.scroller = new Scroller(render, {
zooming: true,
animating: false,
bouncing: false,
locking: false,
minZoom: 1
});
After that, we need to position the Zynga Scroller correctly. I'll admit that this part is a bit of black magic for me. I copied the rest of the code over from the "asset/ui.js" file.
var container = document.getElementById("container");
var rect = container.getBoundingClientRect();
scroller.setPosition(rect.left + container.clientLeft, rect.top + container.clientTop);
scroller.setDimensions(700, 700, width, height);
Finally, I copied over the panning code as well, and added some code that checks if the KineticJS framework is moving something:
var mousedown = false;
container.addEventListener("mousedown", function(e) {
if (e.target.tagName.match(/input|textarea|select/i)) {
return;
}
scroller.doTouchStart([{
pageX: e.pageX,
pageY: e.pageY
}], e.timeStamp);
mousedown = true;
}, false);
document.addEventListener("mousemove", function(e) {
if (somethingIsBeingDraggedInKinetic)
return;
if (!mousedown) {
return;
}
scroller.doTouchMove([{
pageX: e.pageX,
pageY: e.pageY
}], e.timeStamp);
mousedown = true;
}, false);
document.addEventListener("mouseup", function(e) {
if (!mousedown) {
return;
}
scroller.doTouchEnd(e.timeStamp);
mousedown = false;
}, false);
Oh, and the zoom handler.
container.addEventListener(navigator.userAgent.indexOf("Firefox") > -1 ? "DOMMouseScroll" : "mousewheel", function(e) {
scroller.doMouseZoom(e.detail ? (e.detail * -120) : e.wheelDelta, e.timeStamp, e.pageX, e.pageY);
}, false);
This is perfect as a basis for a zoomable map!
I want to get an element's position relative to the window (fixed position).
Here's what I've got so far:
(function ($) {
$.fn.fixedPosition = function () {
var offset = this.offset();
var $doc = $(document);
return {
'x': offset.left - $doc.scrollLeft(),
'y': offset.top - $doc.scrollTop()
};
};
})(jQuery);
$('#thumbnails img').click(function () {
var pos = $(this).fixedPosition();
console.log(pos);
});
But when I click a thumbnail, it appears to be off by about 10 pixels or so. i.e., it will give me negative values for y even when the top edge of the photo is about 5 pixels away from the top of my browser window.
Use:
element.getBoundingClientRect();
In a JQuery Plugin:
$.fn.fixedPosition = function () {
var rect = this.getBoundingClientRect();
return {
x: rect.left,
y: rect.top
};
};
See:
https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIDOMClientRect
Update:
Solution now depends on JSizes and a couple helper methods:
function Point(x, y) {
return {
'x': x,
'y': y,
'left': x,
'top': y
};
}
$.fn.outerOffset = function () {
/// <summary>Returns an element's offset relative to its outer size; i.e., the sum of its left and top margin, padding, and border.</summary>
/// <returns type="Object">Outer offset</returns>
var margin = this.margin();
var padding = this.padding();
var border = this.border();
return Point(
margin.left + padding.left + border.left,
margin.top + padding.top + border.top
);
};
$.fn.fixedPosition = function () {
/// <summary>Returns the "fixed" position of the element; i.e., the position relative to the browser window.</summary>
/// <returns type="Object">Object with 'x' and 'y' properties.</returns>
var offset = this.offset();
var $doc = $(document);
var bodyOffset = $(document.body).outerOffset();
return Point(offset.left - $doc.scrollLeft() + bodyOffset.left, offset.top - $doc.scrollTop() + bodyOffset.top);
};
Your code looks fine and it should work as you're expecting it to.
That said, .offset() has a "gotcha" involved in which it won't account for any padding, margin, or border applied to the DOM body. It finds the offset of the element in relation to the document, not the window.
http://api.jquery.com/offset/
From the documentation:
Note: jQuery does not support getting the offset coordinates of hidden elements or accounting for borders, margins, or padding set on the body element.
Some css should hopefully fix the weird results:
body { margin: 0; padding: 0; border: none; }