Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { debounce } = require("resource://devtools/shared/debounce.js");
const { lerp } = require("resource://devtools/client/memory/utils.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const LERP_SPEED = 0.5;
const ZOOM_SPEED = 0.01;
const TRANSLATE_EPSILON = 1;
const ZOOM_EPSILON = 0.001;
const LINE_SCROLL_MODE = 1;
const SCROLL_LINE_SIZE = 15;
/**
* DragZoom is a constructor that contains the state of the current dragging and
* zooming behavior. It sets the scrolling and zooming behaviors.
*
* @param {HTMLElement} container description
* The container for the canvases
*/
function DragZoom(container, debounceRate, requestAnimationFrame) {
EventEmitter.decorate(this);
this.isDragging = false;
// The current mouse position
this.mouseX = container.offsetWidth / 2;
this.mouseY = container.offsetHeight / 2;
// The total size of the visualization after being zoomed, in pixels
this.zoomedWidth = container.offsetWidth;
this.zoomedHeight = container.offsetHeight;
// How much the visualization has been zoomed in
this.zoom = 0;
// The offset of visualization from the container. This is applied after
// the zoom, and the visualization by default is centered
this.translateX = 0;
this.translateY = 0;
// The size of the offset between the top/left of the container, and the
// top/left of the containing element. This value takes into account
// the device pixel ratio for canvas draws.
this.offsetX = 0;
this.offsetY = 0;
// The smoothed values that are animated and eventually match the target
// values. The values are updated by the update loop
this.smoothZoom = 0;
this.smoothTranslateX = 0;
this.smoothTranslateY = 0;
// Add the constant values for testing purposes
this.ZOOM_SPEED = ZOOM_SPEED;
this.ZOOM_EPSILON = ZOOM_EPSILON;
const update = createUpdateLoop(container, this, requestAnimationFrame);
this.destroy = setHandlers(this, container, update, debounceRate);
}
module.exports = DragZoom;
/**
* Returns an update loop. This loop smoothly updates the visualization when
* actions are performed. Once the animations have reached their target values
* the animation loop is stopped.
*
* Any value in the `dragZoom` object that starts with "smooth" is the
* smoothed version of a value that is interpolating toward the target value.
* For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each
* iteration of the update loop until it's sufficiently close as defined by
* the epsilon values.
*
* Only these smoothed values and the container CSS are updated by the loop.
*
* @param {HTMLDivElement} container
* @param {Object} dragZoom
* The values that represent the current dragZoom state
* @param {Function} requestAnimationFrame
*/
function createUpdateLoop(container, dragZoom, requestAnimationFrame) {
let isLooping = false;
function update() {
const isScrollChanging =
Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON;
const isTranslateChanging =
Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) >
TRANSLATE_EPSILON ||
Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) >
TRANSLATE_EPSILON;
isLooping = isScrollChanging || isTranslateChanging;
if (isScrollChanging) {
dragZoom.smoothZoom = lerp(
dragZoom.smoothZoom,
dragZoom.zoom,
LERP_SPEED
);
} else {
dragZoom.smoothZoom = dragZoom.zoom;
}
if (isTranslateChanging) {
dragZoom.smoothTranslateX = lerp(
dragZoom.smoothTranslateX,
dragZoom.translateX,
LERP_SPEED
);
dragZoom.smoothTranslateY = lerp(
dragZoom.smoothTranslateY,
dragZoom.translateY,
LERP_SPEED
);
} else {
dragZoom.smoothTranslateX = dragZoom.translateX;
dragZoom.smoothTranslateY = dragZoom.translateY;
}
const zoom = 1 + dragZoom.smoothZoom;
const x = dragZoom.smoothTranslateX;
const y = dragZoom.smoothTranslateY;
container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`;
if (isLooping) {
requestAnimationFrame(update);
}
}
// Go ahead and start the update loop
update();
return function restartLoopingIfStopped() {
if (!isLooping) {
update();
}
};
}
/**
* Set the various event listeners and return a function to remove them
*
* @param {Object} dragZoom
* @param {HTMLElement} container
* @param {Function} update
* @return {Function} The function to remove the handlers
*/
function setHandlers(dragZoom, container, update, debounceRate) {
const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate);
const removeDragHandlers = setDragHandlers(
container,
dragZoom,
emitChanged,
update
);
const removeScrollHandlers = setScrollHandlers(
container,
dragZoom,
emitChanged,
update
);
return function removeHandlers() {
removeDragHandlers();
removeScrollHandlers();
};
}
/**
* Sets handlers for when the user drags on the canvas. It will update dragZoom
* object with new translate and offset values.
*
* @param {HTMLElement} container
* @param {Object} dragZoom
* @param {Function} changed
* @param {Function} update
*/
function setDragHandlers(container, dragZoom, emitChanged, update) {
const parentEl = container.parentElement;
function startDrag() {
dragZoom.isDragging = true;
container.style.cursor = "grabbing";
}
function stopDrag() {
dragZoom.isDragging = false;
container.style.cursor = "grab";
}
function drag(event) {
const prevMouseX = dragZoom.mouseX;
const prevMouseY = dragZoom.mouseY;
dragZoom.mouseX = event.clientX - parentEl.offsetLeft;
dragZoom.mouseY = event.clientY - parentEl.offsetTop;
if (!dragZoom.isDragging) {
return;
}
dragZoom.translateX += dragZoom.mouseX - prevMouseX;
dragZoom.translateY += dragZoom.mouseY - prevMouseY;
keepInView(container, dragZoom);
emitChanged();
update();
}
parentEl.addEventListener("mousedown", startDrag);
parentEl.addEventListener("mouseup", stopDrag);
parentEl.addEventListener("mouseout", stopDrag);
parentEl.addEventListener("mousemove", drag);
return function removeListeners() {
parentEl.removeEventListener("mousedown", startDrag);
parentEl.removeEventListener("mouseup", stopDrag);
parentEl.removeEventListener("mouseout", stopDrag);
parentEl.removeEventListener("mousemove", drag);
};
}
/**
* Sets the handlers for when the user scrolls. It updates the dragZoom object
* and keeps the canvases all within the view. After changing values update
* loop is called, and the changed event is emitted.
*
* @param {HTMLDivElement} container
* @param {Object} dragZoom
* @param {Function} changed
* @param {Function} update
*/
function setScrollHandlers(container, dragZoom, emitChanged, update) {
const window = container.ownerDocument.defaultView;
function handleWheel(event) {
event.preventDefault();
if (dragZoom.isDragging) {
return;
}
// Update the zoom level
const scrollDelta = getScrollDelta(event, window);
const prevZoom = dragZoom.zoom;
dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED);
// Calculate the updated width and height
const prevZoomedWidth = container.offsetWidth * (1 + prevZoom);
const prevZoomedHeight = container.offsetHeight * (1 + prevZoom);
dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom);
dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom);
const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth;
const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight;
const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2;
const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2;
// The ratio of where the center of the mouse is in regards to the total
// zoomed width/height
const ratioZoomX =
(prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) /
prevZoomedWidth;
const ratioZoomY =
(prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) /
prevZoomedHeight;
// Distribute the change in width and height based on the above ratio
dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX);
dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY);
// Keep the canvas in range of the container
keepInView(container, dragZoom);
emitChanged();
update();
}
container.addEventListener("wheel", handleWheel);
return function removeListener() {
container.removeEventListener("wheel", handleWheel);
};
}
/**
* Account for the various mouse wheel event types, per pixel or per line
*
* @param {WheelEvent} event
* @return {Number} The scroll size in pixels
*/
function getScrollDelta(event) {
if (event.deltaMode === LINE_SCROLL_MODE) {
// Update by a fixed arbitrary value to normalize scroll types
return event.deltaY * SCROLL_LINE_SIZE;
}
return event.deltaY;
}
/**
* Keep the dragging and zooming within the view by updating the values in the
* `dragZoom` object.
*
* @param {HTMLDivElement} container
* @param {Object} dragZoom
*/
function keepInView(container, dragZoom) {
const { devicePixelRatio } = container.ownerDocument.defaultView;
const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2;
const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2;
dragZoom.translateX = Math.max(
-overdrawX,
Math.min(overdrawX, dragZoom.translateX)
);
dragZoom.translateY = Math.max(
-overdrawY,
Math.min(overdrawY, dragZoom.translateY)
);
dragZoom.offsetX =
devicePixelRatio *
((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX);
dragZoom.offsetY =
devicePixelRatio *
((dragZoom.zoomedHeight - container.offsetHeight) / 2 -
dragZoom.translateY);
}