I've made elements draggable on a straight path and fixed to an axis before using jQuery UI, like so:
$('#element').draggable({
axis:'y'
});
However, I'm wondering how to make an element draggable while following a certain path that is essentially a draggable line.
I basically have one style / position of the element in the beginning and another style / position of its end location and I want it to be draggable along the path between these two positions.
How can I do this with javascript or jQuery?
I don't think jQuery provides that kind of functionality. However, you can do it in pure JavaScript, or use this plugin I made:
$.fn.dragBetween = function(eles) {
var dragEle = this,
md = false, offset, a, b, ab;
eles[0] = $(eles[0])[0];
eles[1] = $(eles[1])[0];
dragEle.on("mousedown", function(e) {
if (e.which == 1) {
mD = true;
offset = new Vector(
e.clientX - this.offsetLeft,
e.clientY - this.offsetTop
);
a = new Vector(eles[0].offsetLeft, eles[0].offsetTop);
b = new Vector(eles[1].offsetLeft, eles[1].offsetTop);
ab = b.sub(a);
}
});
$(window).on("mousemove", function(e) {
if (!mD) return false;
var cursor = new Vector(e.clientX, e.clientY).sub(offset).sub(a),
mag = cursor.dot(ab) / ab.dot(ab),
proj = ab.times(Math.max(0, Math.min(1, mag)));
var final = proj.add(a);
dragEle.css({
top: final._arr[1],
left: final._arr[0]
});
e.preventDefault();
}).mouseup(function() {
mD = false;
});
};
And this is the Vector class I wrote in a hurry:
function Vector(x, y) {
this._arr = [x, y];
}
Vector.prototype.dot = function(v2) {
var v1 = this;
return v1._arr[0] * v2._arr[0] + v1._arr[1] * v2._arr[1];
}
Vector.prototype.add = function(v2) {
var v1 = this;
return new Vector(v1._arr[0] + v2._arr[0], v1._arr[1] + v2._arr[1]);
}
Vector.prototype.sub = function(v2) {
var v1 = this;
return new Vector(v1._arr[0] - v2._arr[0], v1._arr[1] - v2._arr[1]);
}
Vector.prototype.times = function(n) {
var v = this;
return new Vector(v._arr[0] * n, v._arr[1] * n);
}
To call it, simply do:
$("#drag").dragBetween(["#a", "#b"]);
Demo: http://jsfiddle.net/DerekL/0j6e60d8/
For formula explanation, see: https://www.desmos.com/calculator/tpegbbk8gt
I wasn't able to use Derek's answer because you lose the other features jquery ui draggable provides like containment.
I found to make it drag diagonally, you can modify the css using the drag event like this:
$(".leftTopCircle").draggable({ axis:"x", containment: ".topLeft", scroll: false });
$(".leftTopCircle").on( "drag", function(event, ui){
$(this).css("top", ui.position.left+"px");
});
Related
In the OpenLayers overlay example:
http://openlayers.org/en/v3.11.2/examples/overlay.html
If you click near the top of map most of the overlay is hidden. Is there a CSS trick, or an OpenLayers setting (I do not want to use the autoPan, which doesn't seem to work for popovers anyway), that will enable the entire popover to be shown even if it extends beyond the map view?
Here's a screenshot that illustrates the problem.
autoPan does work for popups, see here: http://openlayers.org/en/v3.11.2/examples/popup.html
However, I also had some trouble with autoPan so I didi it like this (Fiddle demo):
// move map if popop sticks out of map area:
var extent = map.getView().calculateExtent(map.getSize());
var center = map.getView().getCenter();
var pixelPosition = map.getPixelFromCoordinate([ coordinate[0], coordinate[1] ]);
var mapWidth = $("#map").width();
var mapHeight = $("#map").height();
var popoverHeight = $("#popup").height();
var popoverWidth = $("#popup").width();
var thresholdTop = popoverHeight+50;
var thresholdBottom = mapHeight;
var thresholdLeft = popoverWidth/2-80;
var thresholdRight = mapWidth-popoverWidth/2-130;
if(pixelPosition[0] < thresholdLeft || pixelPosition[0] > thresholdRight || pixelPosition[1]<thresholdTop || pixelPosition[1]>thresholdBottom) {
if(pixelPosition[0] < thresholdLeft) {
var newX = pixelPosition[0]+(thresholdLeft-pixelPosition[0]);
} else if(pixelPosition[0] > thresholdRight) {
var newX = pixelPosition[0]-(pixelPosition[0]-thresholdRight);
} else {
var newX = pixelPosition[0];
}
if(pixelPosition[1]<thresholdTop) {
var newY = pixelPosition[1]+(thresholdTop-pixelPosition[1]);
} else if(pixelPosition[1]>thresholdBottom) {
var newY = pixelPosition[1]-(pixelPosition[1]-thresholdBottom);
} else {
var newY = pixelPosition[1];
}
newCoordinate = map.getCoordinateFromPixel([newX, newY]);
newCenter = [(center[0]-(newCoordinate[0]-coordinate[0])), (center[1]-(newCoordinate[1]-coordinate[1])) ]
map.getView().setCenter(newCenter);
}
I added this code to the Popover Official Example in this fiddle demo:
// get DOM element generated by Bootstrap
var bs_element = document.getElementById(element.getAttribute('aria-describedby'));
var offset_height = 10;
// get computed popup height and add some offset
var popup_height = bs_element.offsetHeight + offset_height;
var clicked_pixel = evt.pixel;
// how much space (height) left between clicked pixel and top
var height_left = clicked_pixel[1] - popup_height;
var view = map.getView();
// get the actual center
var center = view.getCenter();
if (height_left < 0) {
var center_px = map.getPixelFromCoordinate(center);
var new_center_px = [
center_px[0],
center_px[1] + height_left
];
map.beforeRender(ol.animation.pan({
source: center,
start: Date.now(),
duration: 300
}));
view.setCenter(map.getCoordinateFromPixel(new_center_px));
}
To make the popup always appear inside the map view, I reversed the ol3 autopan function
So that it the popup is offset from the feature towards the view, instead of panning the view.
I am not sure why so many ol3 fiddles are not loading the map anymore.
http://jsfiddle.net/bunjil/L6rztwj8/48/
var getOverlayOffsets = function(mapInstance, overlay) {
const overlayRect = overlay.getElement().getBoundingClientRect();
const mapRect = mapInstance.getTargetElement().getBoundingClientRect();
const margin = 15;
// if (!ol.extent.containsExtent(mapRect, overlayRect)) //could use, but need to convert rect to extent
const offsetLeft = overlayRect.left - mapRect.left;
const offsetRight = mapRect.right - overlayRect.right;
const offsetTop = overlayRect.top - mapRect.top;
const offsetBottom = mapRect.bottom - overlayRect.bottom;
console.log('offsets', offsetLeft, offsetRight, offsetTop, offsetBottom);
const delta = [0, 0];
if (offsetLeft < 0) {
// move overlay to the right
delta[0] = margin - offsetLeft;
} else if (offsetRight < 0) {
// move overlay to the left
delta[0] = -(Math.abs(offsetRight) + margin);
}
if (offsetTop < 0) {
// will change the positioning instead of the offset to move overlay down.
delta[1] = margin - offsetTop;
} else if (offsetBottom < 0) {
// move overlay up - never happens if bottome-center is default.
delta[1] = -(Math.abs(offsetBottom) + margin);
}
return (delta);
};
/**
* Add a click handler to the map to render the popup.
*/
map.on('click', function(evt) {
var coordinate = evt.coordinate;
var hdms = ol.coordinate.toStringHDMS(ol.proj.transform(
coordinate, 'EPSG:3857', 'EPSG:4326'));
content.innerHTML = '<p>You clicked here:</p><code>' + hdms +
'</code>';
//overlay.setPosition(coordinate);
overlay.setOffset([0, 0]); // restore default
overlay.setPositioning('bottom-right'); // restore default
//overlay.set('autopan', true, false); //only need to do once.
overlay.setPosition(coordinate);
const delta = getOverlayOffsets(map, overlay);
if (delta[1] > 0) {
overlay.setPositioning('bottom-center');
}
overlay.setOffset(delta);
})
In this fiddle, the setPositioning() isn't working, so when you click near the top, the popup is under your mouse - it would be better to setPositioning('bottom-center');
automove would be a good feature to complement autopan.
In case of popover where "autoPan" option is not available you have to check extent's limits (top/bottom/right - left is skipped since popover is spawned on the center right of feature). So extending previous answer of Jonatas Walker a bit:
var bs_element = $('.popover');
var popup_height = bs_element.height();
var popup_width = bs_element.width();
var clicked_pixel = evt.pixel;
var view = map.getView();
var center = view.getCenter();
var height_left = clicked_pixel[1] - popup_height / 2; // from top
var height_left2 = clicked_pixel[1] + popup_height / 2; // from bottom
var width_left = clicked_pixel[0] + popup_width; // from right
var center_px = map.getPixelFromCoordinate(center);
var new_center_px = center_px;
var needs_recenter = false;
if (height_left2 > $("#map").height()) {
new_center_px[1] = height_left2 - center_px[1] + 30;
needs_recenter = true;
}
else if (height_left < 0) {
new_center_px[1] = center_px[1] + height_left;
needs_recenter = true;
}
if (width_left > $("#map").width()) {
new_center_px[0] = width_left - center_px[0] + 30;
needs_recenter = true;
}
if (needs_recenter)
view.setCenter(map.getCoordinateFromPixel(new_center_px));
When I use drag() on a scaled (zoomed) object, the object moves according to the scale, so that if for example, the scale is set to 3 -- then each 1px move of the mouse is multiplied by 3.
After 20 or so pixel moves by the mouse the behavior is completely unacceptable.
Is that a bug or am I doing something wrong?
var g = s.g();
g.transform("scale(3)");
var rect = g.rect(20,20,40,40);
var circle = g.circle(60,150,50);
var move = function(dx,dy) {
this.attr({
transform: this.data('origTransform') + (this.data('origTransform') ? "T" : "t") + [dx, dy]
});
}
var start = function() {
this.data('origTransform', this.transform().local );
}
var stop = function() {
console.log('finished dragging');
}
rect.drag(move, start, stop );
circle.drag(move, start, stop );
See fiddle at http://jsfiddle.net/mje8knLf/1/ (just drag one of the shapes)
TIA!
To do this, we need to account for existing transformations that appear on all the outer elements.
Here is a plugin I did a while back to help with this.
The main bit is the dragMove function. We find the existing other transforms that are applied (there are 3 types of transform matrix in Snap on an element, localMatrix, diffMatrix, globalMatix), and invert it to get the matrix that we will want to apply to allow for the existing effect. (If there are some cases where diffMatrix doesn't work, take a look at globalMatrix).
Then using that, we can use y() and x() on the new drag amount, to find what it would be in the new coordinate space.
Snap.plugin( function( Snap, Element, Paper, global ) {
Element.prototype.altDrag = function() {
this.drag( dragMove, dragStart, dragEnd );
return this;
}
var dragStart = function ( x,y,ev ) {
this.data('ot', this.transform().local );
}
var dragMove = function(dx, dy, ev, x, y) {
var tdx, tdy;
var snapInvMatrix = this.transform().diffMatrix.invert();
snapInvMatrix.e = snapInvMatrix.f = 0;
tdx = snapInvMatrix.x( dx,dy ); tdy = snapInvMatrix.y( dx,dy );
this.transform( "t" + [ tdx, tdy ] + this.data('ot') );
}
var dragEnd = function() {
}
});
Try: transform: this.data('origTransform') + (this.data('origTransform') ? "T" : "t") + [dx/3, dy/3].
Basic implementation:-
Scale is a global variable (can be a FLOAT also)
All dx and dy motion is divided by Scale
sidenote: all elements can be dragged out of the box. You might want to implement some movement limit around the edges.
Here I am using svg-pan-zoom.js for the pan and zoom for SVG, very easy to use.
And I am using snap.svg
var svgElement;
var panZoomPlan;
function setPanZoom(){
svgElement = document.querySelector('svg');
panZoomPlan = svgPanZoom(svgElement);
panZoomPlan.setMinZoom(0.1);
panZoomPlan.setMaxZoom(50);
panZoomPlan.setZoomScaleSensitivity(0.2);
panZoomPlan.disableDblClickZoom();
}
function set_draggable(svg_element) {
var shape = plan.select("#" + svg_element);
shape.drag(dragNode, dragStart, dragStop);
}
var dragStart = function() {
panZoomPlan.disablePan();
panZoomPlan.disableZoom();
this.isDragged = false;
var node_id = this.node.id;
this.data('origTransform', this.transform().local );
}
var dragNode = function(dx,dy) {
this.isDragged = true;
realZoom = panZoomPlan.getSizes().realZoom;
var rdx = dx/realZoom;
var rdy = dy/realZoom;
this.attr({
transform: this.data('origTransform') + (this.data('origTransform') ? "T" : "t") + [rdx, rdy]
});
event.preventDefault();
}
var dragStop = function() {
panZoomPlan.enablePan();
panZoomPlan.enableZoom();
if (!this.isDragged) {
return;
}
//update scene elements data
var node_id = this.node.id;
Nodes_dict[node_id].x += Nodes_dict[node_id].rdx;
Nodes_dict[node_id].y += Nodes_dict[node_id].rdy;
}
I hope this helps later users who are seeing this question.
I'm working on a floorplan editor program which is implemented in part using HTML5 Canvas and KineticJS. The user can select from a couple of tools to place nodes on the canvas (rooms, hallways, staircases, etc), and can then create edges between the nodes to link them.
I'm running into a problem when placing a specific type of node on my canvas: I can add any number of most types of nodes I've got defined without any performance decrease, but as soon as I try and add more than a handful of 'room' nodes, they take longer and longer to render and the stage tap event handler which creates nodes becomes unresponsive. This issue only occurs on the iPad and not when I run the application from a desktop.
All nodes are comprised of a Kinetic Circle with a particular image to represent the type of node and some data such as x/y position, type, id, and a list of attached edges. The only difference between room nodes and other special nodes is an extra Kinetic Rect and a couple of variables to represent the room itself. When creating a new node, all the attributes are filled by the constructor function, then the node is passed to a setup function to register event handlers and a render function to render it based on its type.
I'm still fairly new with these technologies, so any advice, no matter how specific or general, will probably be helpful. I've attached what I think will be helpful snippets of code; if you need to see more or have questions about what's here please let me know.
Thank you so much for any time and effort.
=========================================================================
EDIT:
Removing the opacity attribute of the room nodes made a huge difference; the performance decrease is almost entirely negligible now. I'll post further updates if and when I work out why that's the case.
function Node(x, y, id, type, attr) {
this.x = x;
this.y = y;
this.nX = 0;
this.nY = 0;
this.type = type;
this.bbox;
this.id = id;
this.edges = {};
this.attr = {"zPos": 0,
"name": '',
"width": default_room_width,
"height": default_room_height};
this.render(x, y, node_radius);
}
Node.prototype.render = function(x, y, r){
//Create node on canvas
this.visual = null;
var _self = this;
if(this.type == "node" || this.type == "Hallway"){
this.visual = new Kinetic.Circle({
x: x,
y: y,
radius: r,
fill: '#2B64FF',
stroke: '#000000',
strokeWidth: 2,
draggable: true
});
nodeLayer.add(this.visual);
this.x = this.visual.x();
this.y = this.visual.y();
nodeLayer.draw();
this.visual.id = this.id;
this.setupNode(x, y, node_radius);
} else {
var image = new Image();
_self.visual = new Kinetic.Image({
x: x - (node_img_size / 2),
y: y - (node_img_size / 2),
image: image,
width: node_img_size,
height: node_img_size,
draggable: true
});
image.onload = function(){
_self.setupNode(x, y, node_radius);
nodeLayer.add(_self.visual);
_self.x = _self.visual.x();
_self.y = _self.visual.y();
nodeLayer.draw();
_self.visual.id = _self.id;
}
image.src = '../../img/' + this.type + 'tool.png';
var width = this.attr["width"];
var height = this.attr["height"];
if(this.type== "room") {
this.bbox = new Kinetic.Rect({
x: x,
y: y,
strokeWidth: 2,
stroke: "black",
fill: "black",
opacity: 0.5,
listening: true,
width: width,
height: height,
offset: {x:width/2, y:height/2}
});
setupRoom(this.bbox);
this.bbox.offsetX(default_room_width/2);
this.bbox.offsetY(default_room_height/2);
roomLayer.add(this.bbox);
roomLayer.draw();
}
}
}
//Bind event handlers for rooms
function setupRoom(room) {
room.on(tap, function(e) {
if(selectedRoom == this) {
selectedRoom = null;
this.setOpacity(0.5);
roomLayer.draw();
} else {
if(selectedRoom != null)
selectedRoom.setOpacity(0.5);
selectedRoom = this;
this.setOpacity(1);
roomLayer.draw();
}
});
room.on('mouseenter', function(e) {
is_hovering = true;
}).on('mouseleave', function(e) {
is_hovering = false;
});
}
Node.prototype.setupNode = function (x, y) {
var _self = this;
var c = this.visual;
c.on('mouseenter', function(e){
is_hovering = true;
}).on('mouseleave', function(e){
is_hovering = false;
});
c.on(tap, function(e){
if(is_adding_node == true && _self.type == "node" && linkerNode != _self) {
is_adding_node = false;
nodeDrag = false;
$.each(nodes, function(key, value) {
if(value.type == "node") {
value.visual.fill("#2B64FF");
}
});
nodeLayer.batchDraw();
joinNodes(linkerNode, _self);
} else {
handleNodeTap(e, _self);
}
});
c.on(dbltap, function(e){
console.log(_self.id);
if(selectedTool != "link"){
showMenuForNode(c);
}
});
//Drag event
c.on(touchstart, function(e){
nodeDrag = true;
}).on(touchmove, function(e){
var touchPos = stage.getPointerPosition();
var newX = c.x() + offsetX * -1;
var newY = c.y() + offsetY * -1;
_self.x = newX;
_self.y = newY;
if(_self.text != null) {
_self.text.x(_self.visual.x() - node_text_offset);
_self.text.y(_self.visual.y() - node_text_offset);
}
//Move room BB if set
if (_self.bbox != undefined) {
_self.bbox.x(_self.visual.x() + _self.visual.width()/2);
_self.bbox.y(_self.visual.y() + _self.visual.height()/2);
roomLayer.batchDraw();
}
//Update positions of connected edges
for(var x in _self.edges){
edges[_self.edges[x]].setPosition();
}
nodeLayer.batchDraw();
}).on(touchend, function(e){
currentNode = _self;
nodeDrag = false;
});
};
From what I'm reading of the documentation on Nokia maps I can add custom marker using a vector based drawing API:
http://developer.nokia.com/Community/Wiki/HERE_Maps_API_-_How_to_create_custom_graphics_marker
You can create custom graphic markers but only based on a sprite:
http://heremaps.github.io/examples/examples.html#sprite-markers
Or you can add their own markers:
http://developer.nokia.com/Community/Wiki/HERE_Maps_API_-_How_to_add_map_markers
But is there any way to provide an HTML snippet to position on the map like a map marker? That is how other map libraries work so I can completely control the map marker in HTML/CSS. I already have map markers I would like to use that are styled in HTML/CSS and would not like to duplicate that styling in custom JS.
If you are intent on using styled, injected HTML, it would be possible to create a series of custom components (one for each marker) and attach them to the map. This would inject a block level element for each component which you could style as you see fit.
This is not entirely dissimilar to the simple GroundOverlay component I used to use before the ImgTileProvider class was exposed in the API - it injects a <IMG> element and resizes on zoomLevel (which you will probably need to remove) , but still effectively attaches a piece of HTML to a specific anchor point on the map.
For most simple applications I would usually use Markers (with or without my own iconography) or Infobubbles though. These lead to a more responsive and standard UI and don't clutter the map.
function extend(B, A) {
function I() {}
I.prototype = A.prototype;
B.prototype = new I();
B.prototype.constructor = B;
}
function GroundOverlay(url, boundingBox) {
nokia.maps.map.component.Component.call(this);
this.init(url, boundingBox);
}
extend(GroundOverlay,
nokia.maps.map.component.Component);
GroundOverlay.prototype.init = function (url, boundingBox) {
var that = this;
that.overlayDiv = document.createElement('div');
that.overlayDiv.style.position = 'absolute';
that.overlayDiv.style.cursor = 'default';
that.overlayImage = document.createElement('img');
that.overlayImage.id = 'groundoverlay';
that.overlayDiv.appendChild(that.overlayImage);
that.set('url', url);
that.set('boundingBox', boundingBox);
that.set('visible', true);
that.set('opacity', 1);
that.addOverlay = function () {
var isVisible = that.get('visible'),
bbox,
topLeft,
bottomRight;
if (isVisible === false) {
that.overlayDiv.style.display = 'none';
} else {
bbox = that.get('boundingBox');
topLeft = that.map.geoToPixel(bbox.topLeft);
bottomRight = that.map.geoToPixel(bbox.bottomRight);
that.overlayDiv.style.display = 'block';
that.overlayDiv.style.left = topLeft.x + 'px';
that.overlayDiv.style.top = topLeft.y + 'px';
that.overlayDiv.style.width = (bottomRight.x - topLeft.x) + 'px';
that.overlayDiv.style.height = (bottomRight.y - topLeft.y) + 'px';
that.overlayImage.src = that.get('url');
that.overlayImage.style.width = (bottomRight.x - topLeft.x) + 'px';
that.overlayImage.style.height = (bottomRight.y - topLeft.y) + 'px';
that.overlayImage.style.opacity = that.get('opacity');
}
};
that.addObserver('opacity', that.addOverlay);
that.addObserver('visible', that.addOverlay);
that.addObserver('url', that.addOverlay);
that.addObserver('boundingBox', that.addOverlay);
that.eventHandlers = {
dragListener : function (evt) {
var newGeo = that.map.pixelToGeo(
that.map.width / 2 - evt.deltaX,
that.map.height / 2 - evt.deltaY
);
that.map.set('center', newGeo);
evt.stopPropagation();
},
dblClickListener : function (evt) {
evt.target = this.parentNode.parentNode;
that.map.dispatch(evt);
},
mouseWheelListener : function (evt) {
evt.target = this.parentNode.parentNode;
that.map.dispatch(evt);
}
};
};
GroundOverlay.prototype.attach = function (map) {
this.map = map;
var controls = map.getUIContainer().firstChild,
child = controls.firstChild;
controls.insertBefore(this.overlayDiv, child);
map.addObserver('center', this.addOverlay);
map.addObserver('zoomLevel', this.addOverlay);
if (!this.evtTarget) {
this.evtTarget = nokia.maps.dom.EventTarget(
document.getElementById('groundoverlay')
).enableDrag();
this.evtTarget.addListener('drag', this.eventHandlers.dragListener);
this.evtTarget.addListener('dblclick', this.eventHandlers.dblClickListener);
this.evtTarget.addListener('mousewheel', this.eventHandlers.mouseWheelListener);
this.addOverlay();
}
};
GroundOverlay.prototype.detach = function (map) {
this.map = null;
map.removeObserver('center', this.addOverlay);
map.removeObserver('zoomLevel', this.addOverlay);
this.overlayDiv.parentNode.removeChild(this.overlayDiv);
};
GroundOverlay.prototype.getId = function () {
return 'GroundOverlay';
};
GroundOverlay.prototype.getVersion = function () {
return '1.0.0';
};
We're working with the HTML5 canvas, displaying lots of images at one time.
This is working pretty well but recently we've had a problem with chrome.
When drawing images on to a canvas you seem to reach a certain point where the performance degrades very quickly.
It's not a slow effect, it seems that you go right from 60fps to 2-4fps.
Here's some reproduction code:
// Helpers
// https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Math/random
function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; }
// http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/
window.requestAnimFrame = (function () { return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback) { window.setTimeout(callback, 1000 / 60); }; })();
// https://github.com/mrdoob/stats.js
var Stats = function () { var e = Date.now(), t = e; var n = 0, r = Infinity, i = 0; var s = 0, o = Infinity, u = 0; var a = 0, f = 0; var l = document.createElement("div"); l.id = "stats"; l.addEventListener("mousedown", function (e) { e.preventDefault(); y(++f % 2) }, false); l.style.cssText = "width:80px;opacity:0.9;cursor:pointer"; var c = document.createElement("div"); c.id = "fps"; c.style.cssText = "padding:0 0 3px 3px;text-align:left;background-color:#002"; l.appendChild(c); var h = document.createElement("div"); h.id = "fpsText"; h.style.cssText = "color:#0ff;font-family:Helvetica,Arial,sans-serif;font-size:9px;font-weight:bold;line-height:15px"; h.innerHTML = "FPS"; c.appendChild(h); var p = document.createElement("div"); p.id = "fpsGraph"; p.style.cssText = "position:relative;width:74px;height:30px;background-color:#0ff"; c.appendChild(p); while (p.children.length < 74) { var d = document.createElement("span"); d.style.cssText = "width:1px;height:30px;float:left;background-color:#113"; p.appendChild(d) } var v = document.createElement("div"); v.id = "ms"; v.style.cssText = "padding:0 0 3px 3px;text-align:left;background-color:#020;display:none"; l.appendChild(v); var m = document.createElement("div"); m.id = "msText"; m.style.cssText = "color:#0f0;font-family:Helvetica,Arial,sans-serif;font-size:9px;font-weight:bold;line-height:15px"; m.innerHTML = "MS"; v.appendChild(m); var g = document.createElement("div"); g.id = "msGraph"; g.style.cssText = "position:relative;width:74px;height:30px;background-color:#0f0"; v.appendChild(g); while (g.children.length < 74) { var d = document.createElement("span"); d.style.cssText = "width:1px;height:30px;float:left;background-color:#131"; g.appendChild(d) } var y = function (e) { f = e; switch (f) { case 0: c.style.display = "block"; v.style.display = "none"; break; case 1: c.style.display = "none"; v.style.display = "block"; break } }; var b = function (e, t) { var n = e.appendChild(e.firstChild); n.style.height = t + "px" }; return { REVISION: 11, domElement: l, setMode: y, begin: function () { e = Date.now() }, end: function () { var f = Date.now(); n = f - e; r = Math.min(r, n); i = Math.max(i, n); m.textContent = n + " MS (" + r + "-" + i + ")"; b(g, Math.min(30, 30 - n / 200 * 30)); a++; if (f > t + 1e3) { s = Math.round(a * 1e3 / (f - t)); o = Math.min(o, s); u = Math.max(u, s); h.textContent = s + " FPS (" + o + "-" + u + ")"; b(p, Math.min(30, 30 - s / 100 * 30)); t = f; a = 0 } return f }, update: function () { e = this.end() } } }
// Firefox events suck
function getOffsetXY(eventArgs) { return { X: eventArgs.offsetX == undefined ? eventArgs.layerX : eventArgs.offsetX, Y: eventArgs.offsetY == undefined ? eventArgs.layerY : eventArgs.offsetY }; }
function getWheelDelta(eventArgs) { if (!eventArgs) eventArgs = event; var w = eventArgs.wheelDelta; var d = eventArgs.detail; if (d) { if (w) { return w / d / 40 * d > 0 ? 1 : -1; } else { return -d / 3; } } else { return w / 120; } }
// Reproduction Code
var stats = new Stats();
document.body.appendChild(stats.domElement);
var masterCanvas = document.getElementById('canvas');
var masterContext = masterCanvas.getContext('2d');
var viewOffsetX = 0;
var viewOffsetY = 0;
var viewScaleFactor = 1;
var viewMinScaleFactor = 0.1;
var viewMaxScaleFactor = 10;
var mouseWheelSensitivity = 10; //Fudge Factor
var isMouseDown = false;
var lastMouseCoords = null;
var imageDimensionPixelCount = 25;
var paddingPixelCount = 2;
var canvasDimensionImageCount = 50;
var totalImageCount = Math.pow(canvasDimensionImageCount, 2);
var images = null;
function init() {
images = createLocalImages(totalImageCount, imageDimensionPixelCount);
initInteraction();
renderLoop();
}
function initInteraction() {
var handleMouseDown = function (eventArgs) {
isMouseDown = true;
var offsetXY = getOffsetXY(eventArgs);
lastMouseCoords = [
offsetXY.X,
offsetXY.Y
];
};
var handleMouseUp = function (eventArgs) {
isMouseDown = false;
lastMouseCoords = null;
}
var handleMouseMove = function (eventArgs) {
if (isMouseDown) {
var offsetXY = getOffsetXY(eventArgs);
var panX = offsetXY.X - lastMouseCoords[0];
var panY = offsetXY.Y - lastMouseCoords[1];
pan(panX, panY);
lastMouseCoords = [
offsetXY.X,
offsetXY.Y
];
}
};
var handleMouseWheel = function (eventArgs) {
var mouseX = eventArgs.pageX - masterCanvas.offsetLeft;
var mouseY = eventArgs.pageY - masterCanvas.offsetTop;
var zoom = 1 + (getWheelDelta(eventArgs) / mouseWheelSensitivity);
zoomAboutPoint(mouseX, mouseY, zoom);
if (eventArgs.preventDefault !== undefined) {
eventArgs.preventDefault();
} else {
return false;
}
}
masterCanvas.addEventListener("mousedown", handleMouseDown, false);
masterCanvas.addEventListener("mouseup", handleMouseUp, false);
masterCanvas.addEventListener("mousemove", handleMouseMove, false);
masterCanvas.addEventListener("mousewheel", handleMouseWheel, false);
masterCanvas.addEventListener("DOMMouseScroll", handleMouseWheel, false);
}
function pan(panX, panY) {
masterContext.translate(panX / viewScaleFactor, panY / viewScaleFactor);
viewOffsetX -= panX / viewScaleFactor;
viewOffsetY -= panY / viewScaleFactor;
}
function zoomAboutPoint(zoomX, zoomY, zoomFactor) {
var newCanvasScale = viewScaleFactor * zoomFactor;
if (newCanvasScale < viewMinScaleFactor) {
zoomFactor = viewMinScaleFactor / viewScaleFactor;
} else if (newCanvasScale > viewMaxScaleFactor) {
zoomFactor = viewMaxScaleFactor / viewScaleFactor;
}
masterContext.translate(viewOffsetX, viewOffsetY);
masterContext.scale(zoomFactor, zoomFactor);
viewOffsetX = ((zoomX / viewScaleFactor) + viewOffsetX) - (zoomX / (viewScaleFactor * zoomFactor));
viewOffsetY = ((zoomY / viewScaleFactor) + viewOffsetY) - (zoomY / (viewScaleFactor * zoomFactor));
viewScaleFactor *= zoomFactor;
masterContext.translate(-viewOffsetX, -viewOffsetY);
}
function renderLoop() {
clearCanvas();
renderCanvas();
stats.update();
requestAnimFrame(renderLoop);
}
function clearCanvas() {
masterContext.clearRect(viewOffsetX, viewOffsetY, masterCanvas.width / viewScaleFactor, masterCanvas.height / viewScaleFactor);
}
function renderCanvas() {
for (var imageY = 0; imageY < canvasDimensionImageCount; imageY++) {
for (var imageX = 0; imageX < canvasDimensionImageCount; imageX++) {
var x = imageX * (imageDimensionPixelCount + paddingPixelCount);
var y = imageY * (imageDimensionPixelCount + paddingPixelCount);
var imageIndex = (imageY * canvasDimensionImageCount) + imageX;
var image = images[imageIndex];
masterContext.drawImage(image, x, y, imageDimensionPixelCount, imageDimensionPixelCount);
}
}
}
function createLocalImages(imageCount, imageDimension) {
var tempCanvas = document.createElement('canvas');
tempCanvas.width = imageDimension;
tempCanvas.height = imageDimension;
var tempContext = tempCanvas.getContext('2d');
var images = new Array();
for (var imageIndex = 0; imageIndex < imageCount; imageIndex++) {
tempContext.clearRect(0, 0, imageDimension, imageDimension);
tempContext.fillStyle = "rgb(" + getRandomInt(0, 255) + ", " + getRandomInt(0, 255) + ", " + getRandomInt(0, 255) + ")";
tempContext.fillRect(0, 0, imageDimension, imageDimension);
var image = new Image();
image.src = tempCanvas.toDataURL('image/png');
images.push(image);
}
return images;
}
// Get this party started
init();
And a jsfiddle link for your interactive pleasure:
http://jsfiddle.net/BtyL6/14/
This is drawing 50px x 50px images in a 50 x 50 (2500) grid on the canvas. I've also quickly tried with 25px x 25px and 50 x 50 (2500) images.
We have other local examples that deal with bigger images and larger numbers of images and the other browser start to struggle with these at higher values.
As a quick test I jacked up the code in the js fiddle to 100px x 100px and 100 x 100 (10000) images and that was still running at 16fps when fully zoomed out. (Note: I had to lower the viewMinScaleFactor to 0.01 to fit it all in when zoomed out.)
Chrome on the other hand seems to hit some kind of limit and the FPS drops from 60 to 2-4.
Here's some info about what we've tried and the results:
We've tried using setinterval rather than requestAnimationFrame.
If you load 10 images and draw them 250 times each rather than 2500 images drawn once each then the problem goes away. This seems to indicate that chrome is hitting some kind of limit/trigger as to how much data it's storing about the rendering.
We have culling (not rendering images outside of the visual range) in our more complex examples and while this helps it's not a solution as we need to be able to show all the images at once.
We have the images only being rendered if there have been changes in our local code, against this helps (when nothing changes, obviously) but it isn't a full solution because the canvas should be interactive.
In the example code we're creating the images using a canvas, but the code can also be run hitting a web service to provide the images and the same behaviour (slowness) will be seen.
We've found it very hard to even search for this issue, most results are from a couple of years ago and woefully out of date.
If any more information would be useful then please ask!
EDIT: Changed js fiddle URL to reflect the same code as in the question. The code itself didn't actually change, just the formatting. But I want to be consistent.
EDIT: Updated jsfiddle and and code with css to prevent selection and call requestAnim after the render loop is done.
In Canary this code freezes it on my computer. As to why this happens in Chrome the simple answer is that it uses a different implementation than f.ex. FF. In-depth detail I don't know, but there is obviously room for optimizing the implementation in this area.
I can give some tip however on how you can optimize the given code to make it run in Chrome as well :-)
There are several things here:
You are storing each block of colors as images. This seem to have a huge performance impact on Canary / Chrome.
You are calling requestAnimationFrame at the beginning of the loop
You are clearing and rendering even if there are no changes
Try to (addressing the points):
If you only need solid blocks of colors, draw them directly using fillRect() instead and keep the color indexes in an array (instead of images). Even if you draw them to an off-screen canvas you will only have to do one draw to main canvas instead of multiple image draw operations.
Move requestAnimationFrame to the end of the code block to avoid stacking.
Use dirty flag to prevent unnecessary rendering:
I modified the code a bit - I modified it to use solid colors to demonstrate where the performance impact is in Chrome / Canary.
I set a dirty flag in global scope as true (to render the initial scene) which is set to true each time the mouse move occur:
//global
var isDirty = true;
//mouse move handler
var handleMouseMove = function (eventArgs) {
// other code
isDirty = true;
// other code
};
//render loop
function renderLoop() {
if (isDirty) {
clearCanvas();
renderCanvas();
}
stats.update();
requestAnimFrame(renderLoop);
}
//in renderCanvas at the end:
function renderCanvas() {
// other code
isDirty = false;
}
You will of course need to check for caveats for the isDirty flag elsewhere and also introduce more criteria if it's cleared at the wrong moment. I would store the old position of the mouse and only (in the mouse move) if it changed set the dirty flag - I didn't modify this part though.
As you can see you will be able to run this in Chrome and in FF at a higher FPS.
I also assume (I didn't test) that you can optimize the clearCanvas() function by only drawing the padding/gaps instead of clearing the whole canvas. But that need to be tested.
Added a CSS-rule to prevent the canvas to be selected when using the mouse:
For further optimizing in cases such as this, which is event driven, you don't actually need an animation loop at all. You can just call the redraw when the coords or mouse-wheel changes.
Modification:
http://jsfiddle.net/BtyL6/10/
This was a legitimate bug in chrome.
https://code.google.com/p/chromium/issues/detail?id=247912
It has now been fixed and should be in a chrome mainline release soon.