diff options
Diffstat (limited to 'devtools/client/memory/components/tree-map/drag-zoom.js')
-rw-r--r-- | devtools/client/memory/components/tree-map/drag-zoom.js | 316 |
1 files changed, 316 insertions, 0 deletions
diff --git a/devtools/client/memory/components/tree-map/drag-zoom.js b/devtools/client/memory/components/tree-map/drag-zoom.js new file mode 100644 index 000000000..3de970725 --- /dev/null +++ b/devtools/client/memory/components/tree-map/drag-zoom.js @@ -0,0 +1,316 @@ +/* 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("sdk/lang/functional"); +const { lerp } = require("devtools/client/memory/utils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +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 devicePixelRatio 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; + + let 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() { + let isScrollChanging = ( + Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON + ); + let 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; + } + + let zoom = 1 + dragZoom.smoothZoom; + let x = dragZoom.smoothTranslateX; + let 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) { + let emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); + + let removeDragHandlers = + setDragHandlers(container, dragZoom, emitChanged, update); + let 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) { + let parentEl = container.parentElement; + + function startDrag() { + dragZoom.isDragging = true; + container.style.cursor = "grabbing"; + } + + function stopDrag() { + dragZoom.isDragging = false; + container.style.cursor = "grab"; + } + + function drag(event) { + let prevMouseX = dragZoom.mouseX; + let 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, false); + parentEl.addEventListener("mouseup", stopDrag, false); + parentEl.addEventListener("mouseout", stopDrag, false); + parentEl.addEventListener("mousemove", drag, false); + + return function removeListeners() { + parentEl.removeEventListener("mousedown", startDrag, false); + parentEl.removeEventListener("mouseup", stopDrag, false); + parentEl.removeEventListener("mouseout", stopDrag, false); + parentEl.removeEventListener("mousemove", drag, false); + }; +} + +/** + * 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) { + let window = container.ownerDocument.defaultView; + + function handleWheel(event) { + event.preventDefault(); + + if (dragZoom.isDragging) { + return; + } + + // Update the zoom level + let scrollDelta = getScrollDelta(event, window); + let prevZoom = dragZoom.zoom; + dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); + let deltaZoom = dragZoom.zoom - prevZoom; + + // Calculate the updated width and height + let prevZoomedWidth = container.offsetWidth * (1 + prevZoom); + let prevZoomedHeight = container.offsetHeight * (1 + prevZoom); + dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); + dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); + let deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; + let deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; + + let mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; + let mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; + + // The ratio of where the center of the mouse is in regards to the total + // zoomed width/height + let ratioZoomX = (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) + / prevZoomedWidth; + let 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, false); + + return function removeListener() { + container.removeEventListener("wheel", handleWheel, false); + }; +} + +/** + * Account for the various mouse wheel event types, per pixel or per line + * + * @param {WheelEvent} event + * @param {Window} window + * @return {Number} The scroll size in pixels + */ +function getScrollDelta(event, window) { + 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) { + let { devicePixelRatio } = container.ownerDocument.defaultView; + let overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; + let 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 + ); +} |