drag-zoom.js (10298B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { debounce } = require("resource://devtools/shared/debounce.js"); 8 const { lerp } = require("resource://devtools/client/memory/utils.js"); 9 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 10 11 const LERP_SPEED = 0.5; 12 const ZOOM_SPEED = 0.01; 13 const TRANSLATE_EPSILON = 1; 14 const ZOOM_EPSILON = 0.001; 15 const LINE_SCROLL_MODE = 1; 16 const SCROLL_LINE_SIZE = 15; 17 18 /** 19 * DragZoom is a constructor that contains the state of the current dragging and 20 * zooming behavior. It sets the scrolling and zooming behaviors. 21 * 22 * @param {HTMLElement} container description 23 * The container for the canvases 24 */ 25 function DragZoom(container, debounceRate, requestAnimationFrame) { 26 EventEmitter.decorate(this); 27 28 this.isDragging = false; 29 30 // The current mouse position 31 this.mouseX = container.offsetWidth / 2; 32 this.mouseY = container.offsetHeight / 2; 33 34 // The total size of the visualization after being zoomed, in pixels 35 this.zoomedWidth = container.offsetWidth; 36 this.zoomedHeight = container.offsetHeight; 37 38 // How much the visualization has been zoomed in 39 this.zoom = 0; 40 41 // The offset of visualization from the container. This is applied after 42 // the zoom, and the visualization by default is centered 43 this.translateX = 0; 44 this.translateY = 0; 45 46 // The size of the offset between the top/left of the container, and the 47 // top/left of the containing element. This value takes into account 48 // the device pixel ratio for canvas draws. 49 this.offsetX = 0; 50 this.offsetY = 0; 51 52 // The smoothed values that are animated and eventually match the target 53 // values. The values are updated by the update loop 54 this.smoothZoom = 0; 55 this.smoothTranslateX = 0; 56 this.smoothTranslateY = 0; 57 58 // Add the constant values for testing purposes 59 this.ZOOM_SPEED = ZOOM_SPEED; 60 this.ZOOM_EPSILON = ZOOM_EPSILON; 61 62 const update = createUpdateLoop(container, this, requestAnimationFrame); 63 64 this.destroy = setHandlers(this, container, update, debounceRate); 65 } 66 67 module.exports = DragZoom; 68 69 /** 70 * Returns an update loop. This loop smoothly updates the visualization when 71 * actions are performed. Once the animations have reached their target values 72 * the animation loop is stopped. 73 * 74 * Any value in the `dragZoom` object that starts with "smooth" is the 75 * smoothed version of a value that is interpolating toward the target value. 76 * For instance `dragZoom.smoothZoom` approaches `dragZoom.zoom` on each 77 * iteration of the update loop until it's sufficiently close as defined by 78 * the epsilon values. 79 * 80 * Only these smoothed values and the container CSS are updated by the loop. 81 * 82 * @param {HTMLDivElement} container 83 * @param {object} dragZoom 84 * The values that represent the current dragZoom state 85 * @param {Function} requestAnimationFrame 86 */ 87 function createUpdateLoop(container, dragZoom, requestAnimationFrame) { 88 let isLooping = false; 89 90 function update() { 91 const isScrollChanging = 92 Math.abs(dragZoom.smoothZoom - dragZoom.zoom) > ZOOM_EPSILON; 93 const isTranslateChanging = 94 Math.abs(dragZoom.smoothTranslateX - dragZoom.translateX) > 95 TRANSLATE_EPSILON || 96 Math.abs(dragZoom.smoothTranslateY - dragZoom.translateY) > 97 TRANSLATE_EPSILON; 98 99 isLooping = isScrollChanging || isTranslateChanging; 100 101 if (isScrollChanging) { 102 dragZoom.smoothZoom = lerp( 103 dragZoom.smoothZoom, 104 dragZoom.zoom, 105 LERP_SPEED 106 ); 107 } else { 108 dragZoom.smoothZoom = dragZoom.zoom; 109 } 110 111 if (isTranslateChanging) { 112 dragZoom.smoothTranslateX = lerp( 113 dragZoom.smoothTranslateX, 114 dragZoom.translateX, 115 LERP_SPEED 116 ); 117 dragZoom.smoothTranslateY = lerp( 118 dragZoom.smoothTranslateY, 119 dragZoom.translateY, 120 LERP_SPEED 121 ); 122 } else { 123 dragZoom.smoothTranslateX = dragZoom.translateX; 124 dragZoom.smoothTranslateY = dragZoom.translateY; 125 } 126 127 const zoom = 1 + dragZoom.smoothZoom; 128 const x = dragZoom.smoothTranslateX; 129 const y = dragZoom.smoothTranslateY; 130 container.style.transform = `translate(${x}px, ${y}px) scale(${zoom})`; 131 132 if (isLooping) { 133 requestAnimationFrame(update); 134 } 135 } 136 137 // Go ahead and start the update loop 138 update(); 139 140 return function restartLoopingIfStopped() { 141 if (!isLooping) { 142 update(); 143 } 144 }; 145 } 146 147 /** 148 * Set the various event listeners and return a function to remove them 149 * 150 * @param {object} dragZoom 151 * @param {HTMLElement} container 152 * @param {Function} update 153 * @return {Function} The function to remove the handlers 154 */ 155 function setHandlers(dragZoom, container, update, debounceRate) { 156 const emitChanged = debounce(() => dragZoom.emit("change"), debounceRate); 157 158 const removeDragHandlers = setDragHandlers( 159 container, 160 dragZoom, 161 emitChanged, 162 update 163 ); 164 const removeScrollHandlers = setScrollHandlers( 165 container, 166 dragZoom, 167 emitChanged, 168 update 169 ); 170 171 return function removeHandlers() { 172 removeDragHandlers(); 173 removeScrollHandlers(); 174 }; 175 } 176 177 /** 178 * Sets handlers for when the user drags on the canvas. It will update dragZoom 179 * object with new translate and offset values. 180 * 181 * @param {HTMLElement} container 182 * @param {object} dragZoom 183 * @param {Function} changed 184 * @param {Function} update 185 */ 186 function setDragHandlers(container, dragZoom, emitChanged, update) { 187 const parentEl = container.parentElement; 188 189 function startDrag() { 190 dragZoom.isDragging = true; 191 container.style.cursor = "grabbing"; 192 } 193 194 function stopDrag() { 195 dragZoom.isDragging = false; 196 container.style.cursor = "grab"; 197 } 198 199 function drag(event) { 200 const prevMouseX = dragZoom.mouseX; 201 const prevMouseY = dragZoom.mouseY; 202 203 dragZoom.mouseX = event.clientX - parentEl.offsetLeft; 204 dragZoom.mouseY = event.clientY - parentEl.offsetTop; 205 206 if (!dragZoom.isDragging) { 207 return; 208 } 209 210 dragZoom.translateX += dragZoom.mouseX - prevMouseX; 211 dragZoom.translateY += dragZoom.mouseY - prevMouseY; 212 213 keepInView(container, dragZoom); 214 215 emitChanged(); 216 update(); 217 } 218 219 parentEl.addEventListener("mousedown", startDrag); 220 parentEl.addEventListener("mouseup", stopDrag); 221 parentEl.addEventListener("mouseout", stopDrag); 222 parentEl.addEventListener("mousemove", drag); 223 224 return function removeListeners() { 225 parentEl.removeEventListener("mousedown", startDrag); 226 parentEl.removeEventListener("mouseup", stopDrag); 227 parentEl.removeEventListener("mouseout", stopDrag); 228 parentEl.removeEventListener("mousemove", drag); 229 }; 230 } 231 232 /** 233 * Sets the handlers for when the user scrolls. It updates the dragZoom object 234 * and keeps the canvases all within the view. After changing values update 235 * loop is called, and the changed event is emitted. 236 * 237 * @param {HTMLDivElement} container 238 * @param {object} dragZoom 239 * @param {Function} changed 240 * @param {Function} update 241 */ 242 function setScrollHandlers(container, dragZoom, emitChanged, update) { 243 const window = container.ownerDocument.defaultView; 244 245 function handleWheel(event) { 246 event.preventDefault(); 247 248 if (dragZoom.isDragging) { 249 return; 250 } 251 252 // Update the zoom level 253 const scrollDelta = getScrollDelta(event, window); 254 const prevZoom = dragZoom.zoom; 255 dragZoom.zoom = Math.max(0, dragZoom.zoom - scrollDelta * ZOOM_SPEED); 256 257 // Calculate the updated width and height 258 const prevZoomedWidth = container.offsetWidth * (1 + prevZoom); 259 const prevZoomedHeight = container.offsetHeight * (1 + prevZoom); 260 dragZoom.zoomedWidth = container.offsetWidth * (1 + dragZoom.zoom); 261 dragZoom.zoomedHeight = container.offsetHeight * (1 + dragZoom.zoom); 262 const deltaWidth = dragZoom.zoomedWidth - prevZoomedWidth; 263 const deltaHeight = dragZoom.zoomedHeight - prevZoomedHeight; 264 265 const mouseOffsetX = dragZoom.mouseX - container.offsetWidth / 2; 266 const mouseOffsetY = dragZoom.mouseY - container.offsetHeight / 2; 267 268 // The ratio of where the center of the mouse is in regards to the total 269 // zoomed width/height 270 const ratioZoomX = 271 (prevZoomedWidth / 2 + mouseOffsetX - dragZoom.translateX) / 272 prevZoomedWidth; 273 const ratioZoomY = 274 (prevZoomedHeight / 2 + mouseOffsetY - dragZoom.translateY) / 275 prevZoomedHeight; 276 277 // Distribute the change in width and height based on the above ratio 278 dragZoom.translateX -= lerp(-deltaWidth / 2, deltaWidth / 2, ratioZoomX); 279 dragZoom.translateY -= lerp(-deltaHeight / 2, deltaHeight / 2, ratioZoomY); 280 281 // Keep the canvas in range of the container 282 keepInView(container, dragZoom); 283 emitChanged(); 284 update(); 285 } 286 287 container.addEventListener("wheel", handleWheel); 288 289 return function removeListener() { 290 container.removeEventListener("wheel", handleWheel); 291 }; 292 } 293 294 /** 295 * Account for the various mouse wheel event types, per pixel or per line 296 * 297 * @param {WheelEvent} event 298 * @return {number} The scroll size in pixels 299 */ 300 function getScrollDelta(event) { 301 if (event.deltaMode === LINE_SCROLL_MODE) { 302 // Update by a fixed arbitrary value to normalize scroll types 303 return event.deltaY * SCROLL_LINE_SIZE; 304 } 305 return event.deltaY; 306 } 307 308 /** 309 * Keep the dragging and zooming within the view by updating the values in the 310 * `dragZoom` object. 311 * 312 * @param {HTMLDivElement} container 313 * @param {object} dragZoom 314 */ 315 function keepInView(container, dragZoom) { 316 const { devicePixelRatio } = container.ownerDocument.defaultView; 317 const overdrawX = (dragZoom.zoomedWidth - container.offsetWidth) / 2; 318 const overdrawY = (dragZoom.zoomedHeight - container.offsetHeight) / 2; 319 320 dragZoom.translateX = Math.max( 321 -overdrawX, 322 Math.min(overdrawX, dragZoom.translateX) 323 ); 324 dragZoom.translateY = Math.max( 325 -overdrawY, 326 Math.min(overdrawY, dragZoom.translateY) 327 ); 328 329 dragZoom.offsetX = 330 devicePixelRatio * 331 ((dragZoom.zoomedWidth - container.offsetWidth) / 2 - dragZoom.translateX); 332 dragZoom.offsetY = 333 devicePixelRatio * 334 ((dragZoom.zoomedHeight - container.offsetHeight) / 2 - 335 dragZoom.translateY); 336 }