336 lines
10 KiB
JavaScript
336 lines
10 KiB
JavaScript
/* 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);
|
|
}
|