We develop a library for displaying large interactive hall schemes on the canvas without frameworks and make it work well in ie and mobile devices. Along the way, we deal with the features of the canvas.
First of all let's form the requirements:
We recall that the canvas is essentially a picture with api, so the handling of hovers and clicks on our conscience: you need to read the coordinates yourself, taking into account the scale and scroll, look for objects by their coordinates. But at the same time, we completely control the performance and draw only what we need.
Constantly iterate through all the objects in the scheme and check their coordinates is not optimal. Although this will happen quickly enough, we will still do better: we will build search trees, breaking the map into sectors.
')
In addition to search optimization, we will try to follow the following rules for working with canvas:
The browser has its own rendering timer, and using the requestAnimationFrame method, you can ask the browser to render our frame along with the rest of the animations - this will avoid double browser operation. To cancel the animation, there is a cancelAnimationFrame . Polyphyl .
It is not necessary to constantly redraw complex objects if they do not change. You can draw them in advance on a hidden canvas, and then take them from there.
Even if an element goes beyond the bounds of the canvas, time is still spent on its drawing.
This is especially noticeable in the ie, he honestly draws everything, while in chrome it is optimized, and much less time is spent on it.
It makes no sense to redraw the entire scene, if one element has changed.
Drawing text for canvas is a hard task, so you need to avoid a lot of
objects with text. Even if you want to put a number on each place - it is better to limit the display of this number by scale: for example, show the number only at a certain approximation, when this information will be useful.
Scheme is the main class.
View - the class knows the canvas on which to draw, and its parameters (we will have two).
SchemeObject - the class of the schema object knows its location, how to draw itself and how to handle events. May contain additional parameters, for example, price.
EventManager - class handling and create events. Upon receiving an event, passes it to the desired class.
ScrollManager is the class responsible for scrolling the schema.
ZoomManager - the class responsible for the zoom scheme.
StorageManager is a class that is responsible for storing schema objects, creating a search tree and searching for objects by coordinates.
Polyfill - class with a set of polylifilov for cross-browser compatibility.
Tools - a class with various functions, such as defining the intersection of squares.
ImageStorage - a class for creating images for storing images
I really want the scheme to have flexible settings. To do this, create such a simple method for configuring an object:
/** * Object configurator * @param obj * @param params */ public static configure(obj: any, params: any) { for (let paramName in params) { let value = params[paramName]; let setter = 'set' + Tools.capitalizeFirstLetter(paramName); if (typeof obj[setter] === 'function') { obj[setter].apply(obj, [value]); } } }
Now you can configure objects like this:
Tools.configure(this, params.options); Tools.configure(this.scrollManager, params.scroll); Tools.configure(this.zoomManager, params.zoom);
This is convenient: you only need to create setters for objects that can not only set a value in a property, but also pass on or change the value if necessary.
First of all, you need to learn how to simply place objects on the scheme. But for this you need to understand what objects are now in sight. We agreed not to constantly iterate over all objects, but to build a search tree.
To build a tree, it is necessary to divide the layout of the hall into parts, write one part into the left tree node, and the other into the right one. The node key will be a rectangle bounding the circuit area. Since an object represents a plane, not a point, it can be in several nodes of the tree at once - not a big deal. Question: how to break the circuit? To achieve maximum profit, the tree should be balanced, i.e. the number of elements in the nodes should be about the same. In our case, you can not particularly bother, because usually the objects in the scheme are almost uniform. Just halve alternately by width and height. Here is a split for a tree of depth 8:
/** * Tree node */ export class TreeNode { /** * Parent node */ protected parent: TreeNode; /** * Children nodes */ protected children: TreeNode[] = []; /** * Bounding rect of node */ protected boundingRect: BoundingRect; /** * Objects in node */ protected objects: SchemeObject[] = []; /** * Depth */ protected depth: number; /** * Constructor * @param parent * @param boundingRect * @param objects * @param depth */ constructor(parent: null | TreeNode, boundingRect: BoundingRect, objects: SchemeObject[], depth: number) { this.parent = parent; this.boundingRect = boundingRect; this.objects = objects; this.depth = depth; } /** * Add child * @param child */ public addChild(child: TreeNode): void { this.children.push(child); } /** * Get objects * @returns {SchemeObject[]} */ public getObjects(): SchemeObject[] { return this.objects; } /** * Get children * @returns {TreeNode[]} */ public getChildren(): TreeNode[] { return this.children; } /** * Is last node * @returns {boolean} */ public isLastNode(): boolean { return this.objects.length > 0; } /** * Get last children * @returns {TreeNode[]} */ public getLastChildren(): TreeNode[] { let result: TreeNode[] = []; for (let childNode of this.children) { if (childNode.isLastNode()) { result.push(childNode); } else { let lastChildNodeChildren = childNode.getLastChildren(); for (let lastChildNodeChild of lastChildNodeChildren) { result.push(lastChildNodeChild); } } } return result; } /** * Get child by coordinates * @param coordinates * @returns {TreeNode|null} */ public getChildByCoordinates(coordinates: Coordinates): TreeNode | null { for (let childNode of this.children) { if (Tools.pointInRect(coordinates, childNode.getBoundingRect())) { return childNode; } } return null; } /** * Get child by bounding rect * @param boundingRect * @returns {TreeNode[]} */ public getChildrenByBoundingRect(boundingRect: BoundingRect): TreeNode[] { let result: TreeNode[] = []; for (let childNode of this.children) { if (Tools.rectIntersectRect(childNode.getBoundingRect(), boundingRect)) { result.push(childNode); } } return result; } /** * Remove objects */ public removeObjects(): void { this.objects = []; } /** * Get bounding rect * @returns {BoundingRect} */ public getBoundingRect(): BoundingRect { return this.boundingRect; } /** * Get depth * @returns {number} */ public getDepth(): number { return this.depth; }
/** * Recursive explode node * @param node * @param depth */ protected explodeTreeNodes(node: TreeNode, depth: number): void { this.explodeTreeNode(node); depth--; if (depth > 0) { for (let childNode of node.getChildren()) { this.explodeTreeNodes(childNode, depth); } } } /** * Explode node to children * @param node */ protected explodeTreeNode(node: TreeNode): void { let nodeBoundingRect = node.getBoundingRect(); let newDepth = node.getDepth() + 1; let leftBoundingRect = Tools.clone(nodeBoundingRect) as BoundingRect; let rightBoundingRect = Tools.clone(nodeBoundingRect) as BoundingRect; /** * Width or height explode */ if (newDepth % 2 == 1) { let width = nodeBoundingRect.right - nodeBoundingRect.left; let delta = width / 2; leftBoundingRect.right = leftBoundingRect.right - delta; rightBoundingRect.left = rightBoundingRect.left + delta; } else { let height = nodeBoundingRect.bottom - nodeBoundingRect.top; let delta = height / 2; leftBoundingRect.bottom = leftBoundingRect.bottom - delta; rightBoundingRect.top = rightBoundingRect.top + delta; } let leftNodeObjects = Tools.filterObjectsByBoundingRect(leftBoundingRect, node.getObjects()); let rightNodeObjects = Tools.filterObjectsByBoundingRect(rightBoundingRect, node.getObjects()); let leftNode = new TreeNode(node, leftBoundingRect, leftNodeObjects, newDepth); let rightNode = new TreeNode(node, rightBoundingRect, rightNodeObjects, newDepth); node.addChild(leftNode); node.addChild(rightNode); node.removeObjects(); }
Now it is very easy for us to find the desired objects both by the square and by coordinates. There are already amendments to the scroll and zoom, let's talk about them a little lower.
/** * Find node by coordinates * @param node * @param coordinates * @returns {TreeNode|null} */ public findNodeByCoordinates(node: TreeNode, coordinates: Coordinates): TreeNode | null { let childNode = node.getChildByCoordinates(coordinates); if (!childNode) { return null; } if (childNode.isLastNode()) { return childNode; } else { return this.findNodeByCoordinates(childNode, coordinates); } } /** * find objects by coordinates in tree * @param coordinates Coordinates * @returns {SchemeObject[]} */ public findObjectsByCoordinates(coordinates: Coordinates): SchemeObject[] { let result: SchemeObject[] = []; // scale let x = coordinates.x; let y = coordinates.y; x = x / this.scheme.getZoomManager().getScale(); y = y / this.scheme.getZoomManager().getScale(); // scroll x = x - this.scheme.getScrollManager().getScrollLeft(); y = y - this.scheme.getScrollManager().getScrollTop(); // search node let rootNode = this.getTree(); let node = this.findNodeByCoordinates(rootNode, {x: x, y: y}); let nodeObjects: SchemeObject[] = []; if (node) { nodeObjects = node.getObjects(); } // search object in node for (let schemeObject of nodeObjects) { let boundingRect = schemeObject.getBoundingRect(); if (Tools.pointInRect({x: x, y: y}, boundingRect)) { result.push(schemeObject) } } return result; }
We can also easily determine which objects are in view and require rendering without going through all the objects:
/** * Render visible objects */ protected renderAll(): void { if (this.renderingRequestId) { this.cancelAnimationFrameApply(this.renderingRequestId); this.renderingRequestId = 0; } this.eventManager.sendEvent('beforeRenderAll'); this.clearContext(); let scrollLeft = this.scrollManager.getScrollLeft(); let scrollTop = this.scrollManager.getScrollTop(); this.view.setScrollLeft(scrollLeft); this.view.setScrollTop(scrollTop); let width = this.getWidth() / this.zoomManager.getScale(); let height = this.getHeight() / this.zoomManager.getScale(); let leftOffset = -scrollLeft; let topOffset = -scrollTop; let nodes = this.storageManager.findNodesByBoundingRect(null, { left: leftOffset, top: topOffset, right: leftOffset + width, bottom: topOffset + height }); for (let node of nodes) { for (let schemeObject of node.getObjects()) { schemeObject.render(this, this.view); } } this.eventManager.sendEvent('afterRenderAll'); }
Object storage and retrieval class: src / managers / StorageManager.ts
Zoom is simple. Canvas has a scale method that transforms a grid of coordinates. But we need not just to zoom, we need to zoom to the point where the cursor or center is located.
For zooming to a point, you only need to know two points: the old zoom center (at the old scale) and the new one, and add their difference to the scheme offset:
/** * Zoom to point * @param point * @param delta */ public zoomToPoint(point: Coordinates, delta: number): void { let prevScale = this.scheme.getZoomManager().getScale(); let zoomed = this.scheme.getZoomManager().zoom(delta); if (zoomed) { let newScale = this.scheme.getZoomManager().getScale(); let prevCenter: Coordinates = { x: point.x / prevScale, y: point.y / prevScale, }; let newCenter: Coordinates = { x: point.x / newScale, y: point.y / newScale, }; let leftOffsetDelta = newCenter.x - prevCenter.x; let topOffsetDelta = newCenter.y - prevCenter.y; this.scheme.getScrollManager().scroll( this.scheme.getScrollManager().getScrollLeft() + leftOffsetDelta, this.scheme.getScrollManager().getScrollTop() + topOffsetDelta ); } }
But we want to support the touch device, so you need to process the movement of two fingers and disable the native zoom:
this.scheme.getCanvas().addEventListener('touchstart', (e: TouchEvent) => { this.touchDistance = 0; this.onMouseDown(e); }); this.scheme.getCanvas().addEventListener('touchmove', (e: TouchEvent) => { if (e.targetTouches.length == 1) { // one finger - dragging this.onMouseMove(e); } else if (e.targetTouches.length == 2) { // two finger - zoom const p1 = e.targetTouches[0]; const p2 = e.targetTouches[1]; let distance = Math.sqrt(Math.pow(p2.pageX - p1.pageX, 2) + Math.pow(p2.pageY - p1.pageY, 2)); let delta = 0; if(this.touchDistance) { delta = distance - this.touchDistance; } this.touchDistance = distance; if (delta) { this.scheme.getZoomManager().zoomToPointer(e, delta / 5); } } e.preventDefault(); });
An unpleasant feature was found in iPhones 6 and older: with a quick double-tap, a native zoom appeared with a focus on the canvas, and in this mode the canvas began to slow down terribly. There is no reaction to the viewport. It is treated as follows:
this.scheme.getCanvas().addEventListener('touchend', (e: TouchEvent) => { // prevent double tap zoom let now = (new Date()).getTime(); if (this.lastTouchEndTime && now - this.lastTouchEndTime <= 300) { e.preventDefault(); } else { this.onMouseUp(e); } this.lastTouchEndTime = now; });
Scaling class: src / managers / ZoomManager.ts
I decided to simply add to the coordinates of the offset to the left and above.
True, there is a translate method that shifts the grid of coordinates. At the time of development, it seemed to me not very convenient, but perhaps I will use it still. But all this stuff, we are most interested in event processing.
Some people when clicking can shift the cursor a little, we have to take this into account:
/** * Mouse down * @param e */ protected onMouseDown(e: MouseEvent | TouchEvent): void { this.leftButtonDown = true; this.setLastClientPositionFromEvent(e); } /** * Mouse up * @param e */ protected onMouseUp(e: MouseEvent | TouchEvent): void { this.leftButtonDown = false; this.setLastClientPositionFromEvent(e); if (this.isDragging) { this.scheme.setCursorStyle(this.scheme.getDefaultCursorStyle()); this.scheme.requestRenderAll(); } // defer for prevent trigger click on mouseUp setTimeout(() => {this.isDragging = false; }, 10); } /** * On mouse move * @param e */ protected onMouseMove(e: MouseEvent | TouchEvent): void { if (this.leftButtonDown) { let newCoordinates = this.getCoordinatesFromEvent(e); let deltaX = Math.abs(newCoordinates.x - this.getLastClientX()); let deltaY = Math.abs(newCoordinates.y - this.getLastClientY()); // 1 - is click with offset if (deltaX > 1 || deltaY > 1) { this.isDragging = true; this.scheme.setCursorStyle('move'); } } if (!this.isDragging) { this.handleHover(e); } else { this.scheme.getScrollManager().handleDragging(e); } } /** * Handle dragging * @param e */ public handleDragging(e: MouseEvent | TouchEvent): void { let lastClientX = this.scheme.getEventManager().getLastClientX(); let lastClientY = this.scheme.getEventManager().getLastClientY(); this.scheme.getEventManager().setLastClientPositionFromEvent(e); let leftCenterOffset = this.scheme.getEventManager().getLastClientX() - lastClientX; let topCenterOffset = this.scheme.getEventManager().getLastClientY() - lastClientY; // scale leftCenterOffset = leftCenterOffset / this.scheme.getZoomManager().getScale(); topCenterOffset = topCenterOffset / this.scheme.getZoomManager().getScale(); let scrollLeft = leftCenterOffset + this.getScrollLeft(); let scrollTop = topCenterOffset + this.getScrollTop(); this.scroll(scrollLeft, scrollTop); }
The class responsible for the scroll: src / managers / ScrollManager.ts
It seems that there is already a working version of the scheme, but an unpleasant surprise awaits us:
Our scheme now works quickly only in chrome. The problem is that when you move the scheme in full size and zoom out of this full size, all objects are redrawn. And when only a part of the objects fit on the scale, it works normally.
At first I wanted to merge the nearest places into clusters so that the place of hundreds of objects draw one at a small scale. But I could not find / come up with an algorithm that would do it in a reasonable time and would be stable, since Objects on the map can be located as you like.
Then I remembered the rule that is written on each fence (and at the beginning of this article) when working with canvas: do not redraw unchanging parts. Indeed, when moving and zooming, the scheme itself does not change, so we just need to have a “snapshot” of the scheme n times the initial scale and, when moving / zooming, do not render objects, but simply substitute our image until the map resolution exceeds the resolution of the image. And then the remaining real objects will be quickly drawn in view of their number.
But this picture also has to change sometimes. For example, when choosing a place, it changes the view and we do not want the selected places to disappear while the scheme is being moved. To redraw the entire image (n times the initial size of the map) when clicked is expensive,
but at the same time, we can allow ourselves not to care much about the intersection of objects in the snapshot and update only the square in which the modified object is located.
/** * Update scheme cache * @param onlyChanged */ public updateCache(onlyChanged: boolean): void { if (!this.cacheView) { let storage = this.storageManager.getImageStorage('scheme-cache'); this.cacheView = new View(storage.getCanvas()); } if (onlyChanged) { for (let schemeObject of this.changedObjects) { schemeObject.clear(this, this.cacheView); schemeObject.render(this, this.cacheView); } } else { let boundingRect = this.storageManager.getObjectsBoundingRect(); let scale = (1 / this.zoomManager.getScaleWithAllObjects()) * this.cacheSchemeRatio; let rectWidth = boundingRect.right * scale; let rectHeight = boundingRect.bottom * scale; this.cacheView.setDimensions({ width: rectWidth, height: rectHeight }); this.cacheView.getContext().scale(scale, scale); for (let schemeObject of this.getObjects()) { schemeObject.render(this, this.cacheView); } } this.changedObjects = []; } /** * Draw from cache */ public drawFromCache() { if (!this.cacheView) { return false; } if (this.renderingRequestId) { this.cancelAnimationFrameApply(this.renderingRequestId); this.renderingRequestId = 0; } this.clearContext(); let boundingRect = this.storageManager.getObjectsBoundingRect(); let rectWidth = boundingRect.right; let rectHeight = boundingRect.bottom; this.view.getContext().drawImage( this.cacheView.getCanvas(), this.getScrollManager().getScrollLeft(), this.getScrollManager().getScrollTop(), rectWidth, rectHeight ); } /** * Request draw from cache * @returns {Scheme} */ public requestDrawFromCache(): this { if (!this.renderingRequestId) { this.renderingRequestId = this.requestFrameAnimationApply(() => { this.drawFromCache(); }); } return this; }
In this seemingly uncomplicated way, we greatly increased the speed of the circuit.
Thank you for reading to the end. In the process of working on the scheme, I spied on the sources of fabricjs and chartjs in order to cycle less.
Source: https://habr.com/ru/post/349886/
All Articles