- PVSM.RU - https://www.pvsm.ru -
Разрабатываем библиотеку для отображения больших интерактивных схем залов на canvas без фреймворков и заставляем хорошо работать в ie и мобильных устройствах. Попутно разбираемся с особенностями работы canvas.
Первым делом сформируем требования:
Не будем тянуть и сразу посмотрим демо [1], так будет понятнее о чем речь.
В статье я буду вставлять только небольшие участки кода, остальное можно посмотреть на
GitHub [2]
Вспоминаем, что canvas по сути картинка с api, поэтому обработка ховеров и кликов на нашей совести: нужно самим считать координаты с учетом масштаба и скролла, искать объекты по их координатам. Но в тоже время мы полностью контролируем производительность и рисуем только то, что нужно.
Постоянно перебирать все объекты на схеме и сверять их координаты не оптимально. Хотя это и будет происходить достаточно быстро, мы все равно сделаем лучше: построим деревья поиска, разбив карту на сектора.
Кроме оптимизации поиска, постараемся следовать следующим правилам работы с canvas:
У браузера есть свой таймер отрисовки, и с помощью метода requestAnimationFrame [3] можно попросить браузер отрисовать наш кадр вместе с остальными анимациями, — это позволит избежать двойной работы браузера. Для отмены анимации есть cancelAnimationFrame [4]. Полифил [5].
Не обязательно постоянно перерисовывать сложные объекты, если они не изменяются. Можно отрисовать их заранее на скрытом canvas, а потом брать оттуда.
Даже если элемент выходит за границы холста, на его отрисовку все равно тратится время.
Особенно это заметно в ie, он честно отрисовывает все, в то время в хроме это оптимизировано, и на это времени тратится намного меньше.
Нет смысла перерисовывать всю сцену, если изменился один элемент.
Отрисовка текста для canvas тяжелая задача, поэтому нужно избегать большого количества
объектов с текстом. Даже если хочется на каждое место поставить цифру — лучше ограничить отображение этой цифры масштабом: например, показывать цифру только при определенном приближении, когда эта информация будет полезна.
Scheme — основной класс.
View — класс знает canvas, на котором нужно рисовать, и его параметры (у нас их будет два).
SchemeObject — класс объекта схемы знает свое местоположение, как себя отрисовать и как обрабатывать события. Может содержать дополнительные параметры, например, цену.
EventManager — класс обработки и создания событий. При получении события передает его нужному классу.
ScrollManager — класс, отвечающий за скролл схемы.
ZoomManager — класс, отвечающий за зум схемы.
StorageManager — класс, отвечающий за хранение объектов схемы, создание дерева поиска и поиск объектов по координатам.
Polyfill — класс с набором поллифилов для кроссбраузерности.
Tools — класс с различными функциями, типа определения пересечения квадратов.
ImageStorage — класс создания канвасов для хранения изображений
Очень хочется, чтобы у схемы были гибкие настройки. Для этого создадим такой нехитрый метод конфигурации объекта:
/**
* 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]);
}
}
}
Теперь можно конфигурировать объекты так:
Tools.configure(this, params.options);
Tools.configure(this.scrollManager, params.scroll);
Tools.configure(this.zoomManager, params.zoom);
Это удобно: нужно только создать сеттеры у объектов, которые могут не просто установить значение в свойство, но и свалидировать или изменить значение при необходимости.
Первым делом нужно научиться просто размещать объекты на схеме. Но для этого нужно понять, какие объекты сейчас находятся в зоне видимости. Мы договорились, не перебирать постоянно все объекты, а построить дерево поиска.
Для построения дерева нужно разделять схему зала на части, записывать одну часть в левый узел дерева, а другую — в правый. Ключом узла будет являться прямоугольник, ограничивающий область схемы. Т.к. объект представляет плоскость, а не точку, он может оказаться сразу в нескольких узлах дерева — не страшно. Вопрос: как разбивать схему? Для достижения максимального профита, дерево должно быть сбалансировано, т.е. количество элементов в узлах должно быть примерно одинаковое. В нашем случае можно особо не заморачиваться, т.к. обычно объекты на схеме расположены практически равномерно. Просто делим пополам поочередно по ширине и высоте. Вот такое разбиение будет для дерева глубиной 8:
TreeNode — класс узла дерева знает своего родителя, своих детей и координаты квадрата содержащихся в нем объектов:
/**
* 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();
}
Теперь нам очень просто найти желаемые объекты как по квадрату, так и по координатам. Здесь уже есть поправки на скролл и зум, про них чуть ниже поговорим.
/**
* 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;
}
Еще мы можем легко определить, какие объекты лежат в зоне видимости и требуют отрисовки без перебора всех объектов:
/**
* 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');
}
Класс хранения и поиска объектов: src/managers/StorageManager.ts [7]
Зум — это просто. У canvas есть метод scale [8], который трансформирует сетку координат. Но нам нужно не просто зумить, нам нужно зумить в точку, в которой находится курсор или центр.
Для зума в точку нужно всего лишь знать две точки: старый центр зума (при старом масштабе) и новый, и добавить их разницу к смещению схемы:
/**
* 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
);
}
}
Но мы же хотим поддерживать тач устройства, поэтому нужно обработать движение двух пальцев и запретить нативный зум:
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();
});
В айфонах 6 и старше была найдена неприятная особенность: при быстром двойном касании возникал нативный зум с фокусом на канвасе, причем в таком режиме канвас начинал жутко тормозить. На viewport никакой реакции. Лечится так:
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;
});
Класс, отвечающий за масштабирование: src/managers/ZoomManager.ts [9]
Я решил просто прибавлять к координатам смещение слева и сверху.
Правда есть метод translate [10], который смещает сетку координат. На момент разработки он показался мне не очень удобным, но, возможно, я им еще воспользуюсь. Но это все мелочи, больше всего нас интересуют вопросы обработки событий.
Некоторые люди при клике могут немного смещать курсор, это мы должны учесть:
/**
* 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);
}
Класс, отвечающий за скролл: src/managers/ScrollManager.ts [11]
Вот вроде бы уже есть рабочий вариант схемы, но нас ждет неприятный сюрприз:
наша схема сейчас быстро работает только в хроме. Проблема в том, что при перемещении схемы в полном размере и зуме из этого полного размера, перерисовываются все объекты. А когда уже в масштабе помещается только часть объектов — работает нормально.
Сначала я хотел объединить ближайшие места в кластеры, чтобы место сотни объектов рисовать один при мелком масштабе. Но не смог найти/придумать алгоритм, который бы делал это за разумное время и был бы устойчивым, т.к. объекты на карте могут быть расположены как угодно.
Потом я вспомнил правило, которое написано на каждом заборе (и в начале этой статьи) при работе с canvas: не перерисовывать неизменяющиеся части. Действительно, при перемещении и зуме сама схема не изменяется, поэтому нам нужно просто иметь «снимок» схемы в n раз больше начального масштаба и, при перемещении/зуме не рендерить объекты, а просто подставлять нашу картинку, пока разрешение карты не превысило разрешение снимка. А потом уже и оставшиеся реальные объекты будут быстро рисоваться в виду своего количества.
Но эта картинка тоже должна иногда меняться. Например, при выборе места оно меняет вид и мы не хотим, чтобы выбранные места исчезали на время перемещения схемы. Перерисовывать весь снимок (в n раз больше начального размера карты) при клике дорого,
но в тоже время мы можем позволить себе на снимке не сильно заботиться о пересечении объектов и обновлять только квадрат, в котором находиться измененный объект.
/**
* 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;
}
Таким вроде бы нехитрым способом мы очень сильно повысили скорость работы схемы.
Спасибо, что дочитали до конца. В процессе работы над схемой я подглядывал в исходники fabricjs [12] и chartjs [13] чтобы меньше велосипедить.
Автор: NikitchenkoSergey
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/274434
Ссылки в тексте:
[1] демо: http://nikitchenko.ru/scheme-designer/examples/
[2] GitHub: https://github.com/NikitchenkoSergey/scheme-designer
[3] requestAnimationFrame: https://developer.mozilla.org/ru/docs/DOM/window.requestAnimationFrame
[4] cancelAnimationFrame: https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame
[5] Полифил: https://github.com/NikitchenkoSergey/scheme-designer/blob/master/src/helpers/Polyfill.ts#L11
[6] Подробное описание проблемы: https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
[7] src/managers/StorageManager.ts: https://github.com/NikitchenkoSergey/scheme-designer/blob/master/src/managers/StorageManager.ts
[8] scale: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/scale
[9] src/managers/ZoomManager.ts: https://github.com/NikitchenkoSergey/scheme-designer/blob/master/src/managers/ZoomManager.ts
[10] translate: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/translate
[11] src/managers/ScrollManager.ts: https://github.com/NikitchenkoSergey/scheme-designer/blob/master/src/managers/ScrollManager.ts
[12] fabricjs: http://fabricjs.com/
[13] chartjs: http://www.chartjs.org/
[14] Источник: https://habrahabr.ru/post/349886/?utm_campaign=349886
Нажмите здесь для печати.