Как писать на HTML Canvas удобно, или как изобрести очередной renderer на Angular

Kate

Administrator
Команда форума

Проблемы​

Разработка на canvas с контекстом 2D обычно не предполагает никаких сложностей. Для начала необходимо изучить пару десятков встроенных методов WEB API CanvasRenderingContext2D, прочитать рекомендации по оптимизации, вспомнить школьный курс геометрии. И на этих базовых вещах можно уже строить неплохие приложения на canvas.
Как один из вариантов начала разработки на canvas: из примитивов фигур строят элементы, затем их объединяют в функцию, эти функции складывают в готовый элемент, объединяют их в слой, ну и в конце уже отдают в функцию рендера. Все еще звучит довольно неплохо и с этим можно даже жить, если использовать чистые функции, и придерживаться везде этого подхода. Но не всегда этого удается, всегда есть соблазн выхватить что-либо из контекста. Для примера приведу код из source-map-vizualization замечательный инструмент, сделанный на canvas. Только чтобы понять весь код и привнести какие либо исправления, я думаю придется посидеть не один час.
Типичный проект на canvas+Angular может выглядеть так, как описано в статье: How to get started with Canvas animations in Angular. Достаточно хорошо, если вам необходимо рисовать мало элементов:
import { Component, ViewChild, ElementRef, OnInit } from '@angular/core';

@Component({
selector: 'app-root',
template: `
<canvas #canvas width="600" height="300"></canvas>
<button (click)="animate()">Play</button>
`,
})
export class AppComponent implements OnInit {
@ViewChild('canvas', { static: true })
canvas: ElementRef<HTMLCanvasElement>;

private ctx: CanvasRenderingContext2D;

ngOnInit(): void {
this.ctx = this.canvas.nativeElement.getContext('2d');
}

animate(): void {
// draw function
}
}
Постепенно такой код всегда стремится к большей степени не поддерживаемости. Так случилось и у меня, как я пришел на проект, где повсюду использовался canvas + Angular, к тому уже с большим легаси на тот момент. Само собой скорость разработки уменьшилась, все нужно было глобально рефакторить. После недолгого погружения в проект сформировались требования для рефакторинга.

Требования для удобного использования HTML Canvas​

  • Чистые функции
  • Поддержка использования обобщенного кода функции
  • Возможность использовать старый код в новом подходе
  • Поддержка перерисовки в одном canvas context
  • Поддержка анимации
  • Поддержка listeners для элементов рисования
  • Поддержка инфраструктуры Angular, (Input, Output, OnInit, OnDestroy )
Так как уже был опыт создания custom renderer для Angular: Статья Делаем кроссплатформенное нативное десктоп приложение на Angular, было решено что делаю очередную реализацию Angular renderer.
Из существующих решений Angular Canvas renderer в open source, большинство выглядело простым экспериментом, и не было упоминаний что где-то на проде его юзают. К тому же эти решения подменяют полностью DefaultDomRenderer, а хотелось использовать Canvas Renderer только для определенных компонентов, дабы не сломать своим рендерером остальной функционал приложения.
В этой статье не буду рассказывать как создавать свой renderer под Angular, выше в статье уже описывал процесс создания.
AngularCanvasRenderer
Для того чтобы использовать этот рендерер только в нужных для нас местах, необходимо отметить компонент через декоратор @CanvasComponent. В свою, очередь этот декоратор унесет метаинфу по компоненту в мапу, и рендерер будет понимать какой все-таки использовать renderer при выполнения инструкции из шаблона.
Component:
@CanvasComponent
@Component({
selector: 'app-graph-canvas-example',
templateUrl: './graph-canvas-example.component.html',
styleUrls: ['./graph-canvas-example.component.scss'],
})
Template:
<p>Graph example</p>

<canvas class="first">
<rect [x]="mouseX" [y]="20" w="20" h="20"></rect>
<line *ngFor="..." [x1]="10" [x2]="100" [y1]="10" [y2]="200"></line>
</canvas>
<canvas>
<graph-line
[data]="data"
[step]="step"
[deltaX]="deltaX"
strokeStyle="orange"
></graph-line>
<graph-line [data]="data3" [deltaX]="deltaX" strokeStyle="blue"></graph-line>
</canvas>
Для поддержки "компонентного" подхода внутри canvas, и внедрение чистых функции в шаблонах, как в примере выше, были придуманы так называемые CanvasElement, которые поддерживают selector внутри canvas элемента.
import { CanvasElement, NgCanvasElement, NgCanvas } from 'angular-canvas';

@CanvasElement({
selector: 'graph-line'
})
export class GraphLineElement implements NgCanvasElement {
// parent element
public parent: NgCanvas;

// canvas element redraw until all NgCanvasElement needDraw as true
public needDraw: boolean;

setNgProperty(name: string, value: any): void {
this[name] = value;

// redraw all element in one canvas context after set ng property
this.parent.drawAll();
}

draw(context: CanvasRenderingContext2D, time: number): void {
context.strokeStyle = 'red';
// ...
}
}
CanvasElement должен реализовать интерфейс NgCanvasElement. По большей части интерфейс состоит из методов, которые напрямую выполняются из renderer. Например, можно придумать систему стилей для canvas элементов и изменять их через метод setStyle?(style: string, value: any, flags?: RendererStyleFlags2): void;
и <graph-line style="color: green">
но на практике не используется.
parent: NgCanvas;
Родительский элемент в котором регистрируются дочерние элементы
needDraw: boolean;
Атрибут который отвечает за продолжения цикла рисования, используется при анимациях.
setNgProperty(name: string, value: any): void
Метод при котором значения попадают через Input шаблона при changeDetection.
parent.drawAll() в этом случае вызывает перерисовку всех элементов в текущем canvas слое.
draw(context: CanvasRenderingContext2D, time: number)
Метод в котором ведется отрисовка canvas примитив.
Output event
Для организации Output необходимо реализовать интерфейс рендерера addEventListener.
Пример реализации hover на элемент:
<canvas>
<triangle
[x]="mouseX"
[y]="mouseY"
(hover)="hover($event)"
></triangle>
</canvas>
private callbackFunc: {
hover?: () => {};
} = {};

addEventListener(eventName: string, callbackFunc) {
this.callbackFunc[eventName] = callbackFunc;
Проверку принадлежности координат мыши в элемент необходимо реализовывать самому, вспомнив курс геометрии. Благо легко входишь во вкус.
Полный пример
Еще больше методов в readme проекта.

NgCanvas
В схеме ниже описан механизм работы Angular Canvas Renderer:
1396ddebfc7bcf07b2dd70e2cea08298.png

Где в CanvasComponent:
<canvas> NgCanvas
<canvas-element></canvas-element> CanvasElement

Последствия​

AngularMaterial
В Angular Material сильно используется Angular Animation, и так как BrowserAnimationModule заменяет дефолтный рендерер, начинаются конфликты.
Первым простым решением было подрубить NoopAnimationModule, и отказаться от плавной анимации AngularMaterial, что выглядело вполне интересно. Но конечно же в Angular предусмотрели и этот случай (но не публичный API, не думаю что кому то еще в голову это пришло): Можно подменить родительский DomRenderer своим же (благо интерфейсы совпадают) на моменте создания через AnimationRendererFactory
WebGL, WebGPU

Сейчас модель использования, зашита для использования контекста только под 2d, ничего не ограничивает сделать в NgCanvas canvas.getContext("webgpu"); и это тоже будет работать.
NodeGUI (Qt), NativeScript
Так как эти решения таким же образом подменяют рендерер, нет ничего простого занести в них еще один. В принципе любой рендерер поддерживается, главное чтобы исполняемый хост имел возможность работать с Canvas.
Год в проде
Изначально эксперимент являлся лишь моим pet project, который я делал в свободное от работы время. Для демонстрации возможностей была создана простейшая игра и обычные графики. Эта попытка доказать что подобные рендереры облегчают жизнь вскоре была взята на вооружение и уже год как работают в проде.
Теперь большинство сложных интерфейсов разрабатываются только на нем. Элементы ввода рисуются в обычном DOM, так как CanvasComponent это не только про CanvasRenderer, но и про поддержку DefaultDomRenderer в нем же.
Bundlephobia [3,4 кб]
Ссылки:
  • Github: angular-canvas
  • Демка игры из превью: Game
  • Демка графики: Graph
  • Telegram канал Angular Fanatic, с актуальной информацией про Angular

 
Сверху