Рисуем деревья, часть 2. Геометрия, графика и работа с DOM
Продолжаем писать рисовалку деревьев по канонам ООП.
В предыдущей части мы спроектировали архитектуру приложения, настроили окружение и внедрение зависимостей. В конце написали код модуля L-систем, который генерировал выражение по заданным правилам.
В этом посте мы создадим модуль геометрии для расчёта положения точек на плоскости. После него создадим модули для работы с DOM и canvas
. А в конце выведем на экран первый простенький рисунок.
Пишем модуль геометрии
Второй модуль в доменном слое — модуль геометрических расчётов. Согласно ISP мы разделим его на две части: первая будет создавать геометрические фигуры, вторая — определять стартовую точку на плоскости, откуда мы будем фигуры строить.
Разберёмся с первой частью. Объявим публичное API:
// src/geometry/shape/types.ts
export interface ShapeBuilder {
createLine(start: Point, length: Length, angle?: Angle): Line;
}
Добавим недостающие доменные типы:
// typings/geometry.d.ts
type PixelsAmount = number;
type DegreesAmount = number;
type Coordinate = number;
type Length = PixelsAmount;
type Angle = DegreesAmount;
type Point = {
x: Coordinate;
y: Coordinate;
};
type Size = {
width: Length;
height: Length;
};
type Line = {
start: Point;
end: Point;
};
Теперь приступим к реализации:
// src/geometry/shape/implementation.ts
import { ShapeBuilder } from './types';
export class CoreShapeBuilder implements ShapeBuilder {
public createLine(start: Point, length: Length, angle: Angle = 0): Line {
const radians = (angle * Math.PI) / 180;
const end: Point = {
x: start.x + length * Math.sin(radians),
y: start.y - length * Math.cos(radians)
};
return { start, end };
}
}
Дерево будет расти вверх, поэтому мы уменьшаем координату Y на длину отрезка. (Начало координат у canvas
— в левом верхнем углу.) Если задан угол наклона, меняем положение точки по соответствующей координате.
Зарегистрируем модуль:
// src/geometry/shape/composition.ts
import { container } from '../../composition';
import { CoreShapeBuilder } from './implementation';
import { ShapeBuilder } from './types';
container.registerSingleton<ShapeBuilder, CoreShapeBuilder>();
// И импортируем файл `src/geometry/shape/composition.ts`
// внутри файла `src/composition/index.ts`.
// Далее я буду опускать напоминание об импорте композиции.
Лирическое отступление о нейминге и стандартных реализациях
В целом, имя класса CoreShapeBuilder
мне не очень нравится. Здесь вполне бы подошло и просто ShapeBuilder
, но это имя занято интерфейсом.
В случаях, когда интерфейс реализуется только 1 способом, можно обойтись и без интерфейса. Мы бы могли представить публичные методы класса как API:
class ShapeBuilder {
/* ... */
}
container.registerSingleton<ShapeBuilder>();
Но для последовательности мы будем представлять и использовать интерфейс и реализацию отдельно друг от друга.
Кстати, в C# проблема с неймингом решена префиксами для интерфейса, но в TS не принято добавлять префиксы в названия интерфейсов, так что имеем что имеем.
Выбираем стартовую точку
Для определения точки, откуда мы будем рисовать фигуры, создадим ещё один модуль. Объявим публичное API:
// src/geometry/location/types.ts
export interface StartSelector {
selectStart(): Point;
}
Чтобы реализовать метод selectStart
, нам надо знать размеры «полотна». Мы можем поступить двумя способами:
- передать размеры как аргумент метода;
- создать объект с настройками для приложения, где хранить вообще все настраиваемые величины.
Я решил использовать пример с настройками, чтобы показать, как можно внедрять обычные объекты. Напишем метод:
// src/geometry/location/implementation.ts
import { AppSettings } from '../../settings';
import { StartSelector } from './types';
export class StartPointSelector implements StartSelector {
public selectStart(): Point {
const { width, height } = this.settings.canvasSize;
return {
x: Math.round(width / 2),
y: height
};
}
}
Внутри метода мы ссылаемся на this.settings.canvasSize
. Сейчас поля this.settings
у нас нет, его надо создать. Мы можем это сделать напрямую или в конструкторе:
// 1. Напрямую:
export class StartPointSelector {
settings = {
/*…*/
};
}
// 2. Через конструктор:
export class StartPointSelector {
constructor(settings) {
this.settings = settings;
}
}
Нам удобнее всего будет это делать через конструктор — так мы сможем спихнуть всю работу по добавлению настроек на DI-контейнер. Обновим код:
// src/geometry/location/implementation.ts
export class StartPointSelector implements StartSelector {
// Такая запись сразу же присваивает значение приватному полю,
// нам уже не нужно будет его дублировать в классе руками:
constructor(private settings: AppSettings) {}
// …
}
Почему мы подмешиваем настройки именно так? Можно же просто сослаться на объект. Да, но тогда нам придётся следить настройками самостоятельно. Мы же хотим отдать заботу о подстановке нужных значений контейнеру.
В примере выше мы говорим ему, «когда будешь создавать экземпляр класса StartPointSelector
, передай ему в конструктор что-то, что реализует интерфейс AppSettings
».
Ссылаясь на абстракцию (интерфейс) мы перестаём зависеть от любых деталей и конкретных модулей. Нам не важно, что именно будет добавлено, как зависимость. Главное, чтобы это что-то содержало все методы и свойства, которые объявлены в AppSettings
.
В дальнейшем все зависимости мы будем добавлять именно так.
Создаём настройки
Я не увидел смысла разделять настройки на несколько файлов, поэтому сложил и тип, и объект в один:
// src/settings/index.ts
import { container } from '../composition';
export type AppSettings = {
canvasSize: Size;
};
export const settings: AppSettings = {
canvasSize: {
width: 800,
height: 600
}
};
container.registerSingleton<AppSettings>(() => settings);
На последней строке мы регистрируем объект settings
, как что-то реализующее AppSettings
. С этого момента любые модули, которые запрашивают AppSettings
в своём конструкторе, получат объект settings
.
В прошлом посте мы обсуждали, как работает контейнер — он заменяет интерфейсы на конкретные объекты. Обычно это экземпляры классов, но в случае с настройками — просто объект. Так тоже можно 🙂
Регистрируем модуль
Добавим геометрию в контейнер:
// src/geometry/location/composition.ts
import { container } from '../../composition';
import { StartPointSelector } from './implementation';
import { StartSelector } from './types';
container.registerSingleton<StartSelector, StartPointSelector>();
Готово, доменный слой полностью написан!
Работаем с графикой
Когда мы работаем с грамотной архитектурой, мы не обязаны писать код в каком-либо порядке.
Разработчики могут одновременно работать над разными слоями: часть команды над доменом, другая над прикладным уровнем, третья — над портами и адаптерами. Главное — чтобы команды договорились об API, которое будет связывать модули.
Чтобы доказать, что это возможно, перепрыгнем через прикладной слой и напишем адаптер для работы с canvas
. Спроектируем входы и выходы:

Модуль будет предоставлять наружу интерфейс Drawer
, который будет содержать методы для рисования. А зависеть модуль будет от интерфейса DrawingContextProvider
. Помним, что интерфейс адаптера должен удовлетворять нуждам приложения: мы подстраиваем внешний мир под наши хотелки, а не наоборот.
Заметим, что мы специально пишем в интерфейсе не CanvasDrawer
, а Drawer
. Название интерфейса должно быть абстрактным, чтобы его могли реализовывать разные модули: CanvasDrawer
для рисования на canvas
, SvgDrawer
для работы с svg
и т.д.
Так мы прячем детали реализации от внешнего мира. Это полезно в случае, если мы захотим заменить реализацию, потому что для внешнего мира ничего не поменяется — интерфейс останется тем же.
DrawingContextProvider
будет предоставлять доступ к элементу canvas
на странице. Почему не достучаться до элемента прямо в этом модуле? Мы хотим соблюсти принцип единственной ответственности, а значит каждый модуль должен заниматься одной задачей. «Доступ к элементу» и «обеспечение команд для рисования» — это разные задачи, поэтому нам нужны разные сущности.
Интерфейс Drawer
В интерфейсе опишем метод drawLine
, он будет принимать отрезок и настройки «кисти»: ширину и цвет.
// src/graphics/drawer/types.ts
export type BrushSettings = {
color?: Color;
width?: PixelsAmount;
};
export interface Drawer {
drawLine(line: Line, settings?: BrushSettings): void;
}
Добавим также недостающие аннотации типов:
// typings/graphics.d.ts
type HexadecimalColor = string;
type Color = HexadecimalColor;
Реализация рисовалки
Определим зависимости в конструкторе и публичный метод drawLine
:
// src/graphics/drawer/implementation.ts
import { DrawingContext, DrawingContextProvider } from '../context/types';
import { Drawer, BrushSettings } from './types';
export class CanvasDrawer implements Drawer {
private context: DrawingContext = null;
constructor(private contextProvider: DrawingContextProvider) {
this.context = this.contextProvider.getInstance();
if (!this.context) throw new Error('Failed to access the drawing context.');
}
public drawLine({ start, end }: Line, { color, width }: BrushSettings = {}): void {
// Код с командами для рисования...
}
}
В конструкторе мы получаем объект типа DrawingContextProvider
. Он будет предоставлять нам доступ к элементу, на котором можно рисовать. Если элемента не нашлось, выбрасываем исключение.
Задача этого класса — «перевести» полученный отрезок в вызовы публичного API на элементе, который отрисует этот отрезок. Этим элементом может быть не только DOM-узел, а в принципе что угодно, что реализует интерфейс DrawingContext
. Именно поэтому мы не обращаемся к DOM напрямую из этого класса.
Сам DrawingContext
, к слову, в нашем примере — это всего лишь обёртка:
export type DrawingContext = Nullable<CanvasRenderingContext2D>;
Это не очень хорошо, потому что сейчас при использовании мы завязываемся на методы CanvasRenderingContext2D
:
// src/graphics/drawer/implementation.ts
export class CanvasDrawer implements Drawer {
// ...
public drawLine({ start, end }: Line, { color, width }: BrushSettings = {}): void {
if (!this.context) return;
this.context.strokeStyle = color ?? DEFAULT_COLOR;
this.context.lineWidth = width ?? DEFAULT_WIDTH;
// Методы beginPath, moveTo, lineTo и stroke —
// это прямое использование `CanvasRenderingContext2D`:
this.context.beginPath();
this.context.moveTo(start.x, start.y);
this.context.lineTo(end.x, end.y);
this.context.stroke();
}
}
В идеале нам стоит написать фасад, который будет прятать за собой вызов этих методов и предоставлять наружу нечто типа:
this.context.line(start, end);
Но пост не резиновый, поэтому реализовывать фасад не будем, просто будем иметь это в виду.
Регистрируем рисовалку
Добавляем регистрацию в контейнер:
// src/graphics/drawer/composition.ts
import { container } from '../../composition';
import { CanvasDrawer } from './implementation';
import { Drawer } from './types';
container.registerSingleton<Drawer, CanvasDrawer>();
Проектируем DrawingContextProvider
Объекту Drawer
нужен будет элемент, которым он будет управлять. Задача провайдера — предоставить такой элемент.
DrawingContextProvider
будет зависеть от двух вещей:
- источника элементов
ElementSource
; - источника информации о плотности пикселей на экране
PixelRatioSource
.
Первый нам нужен, чтобы получить доступ к собственно элементу canvas
, а второй — чтобы нормализовать его размеры на ретина-дисплеях. Без нормализации изображение будет мыльным.

Опишем интерфейс для провайдера и контекста. (Помним, что по-хорошему контекст должен быть фасадом над CanvasRenderingContext2D
.)
// src/graphics/context/types.ts
export type DrawingContext = Nullable<CanvasRenderingContext2D>;
export interface DrawingContextProvider {
getInstance(): DrawingContext;
}
Пишем реализацию
В классе кроме зависимостей в приватных полях будем держать ссылки на элемент canvas
и его 2D-контекст:
import { AppSettings } from '../../settings';
import { ElementSource, PixelRatioSource } from '../../dom/types';
import { DrawingContext, DrawingContextProvider } from './types';
export class CanvasContextProvider implements DrawingContextProvider {
private element: Nullable<HTMLCanvasElement> = null;
private context: Nullable<DrawingContext> = null;
constructor(
private elementSource: ElementSource,
private pixelRatioSource: PixelRatioSource,
private settings: AppSettings
) {
const element = this.elementSource.getElementById('canvas');
if (!element) throw new Error('Failed to find a canvas element.');
this.element = element as HTMLCanvasElement;
this.context = this.element.getContext('2d');
this.normalizeScale();
}
public getInstance(): DrawingContext {
return this.context;
}
// ...
}
В конструкторе обращаемся к ElementSource
и получаем элемент. Если элемент есть, получаем его 2D-контекст. После этого нормализуем масштаб, чтобы не было «мыла».
Из метода getInstance
возвращаем 2D-контекст. Технически можно было использовать геттер, чтобы превратить приватное поле context
в публичное свойство, но это не критично.
Заметьте, что элемент наружу мы никак не показываем. Он инкапсулирован в этом классе, и никто больше не знает, как и откуда именно мы получаем контекст. Поэтому если мы переедем с canvas
на что-то ещё, изменится только этот класс. (При условии, что DrawingContext
— это фасад 😃)
Нормализацию масштаба мы проводим здесь по той же причине: никого больше не должно волновать, как именно нужно готовить полотно. Код метода нормализации можно подсмотреть
Регистрируем провайдер
Тут всё, как везде:
// src/graphics/context/composition.ts
import { container } from '../../composition';
import { CanvasContextProvider } from './implementation';
import { DrawingContextProvider } from './types';
container.registerSingleton<DrawingContextProvider, CanvasContextProvider>();
Что ещё
Нам также необходимо создать и зарегистрировать ElementSource
и PixelRatioSource
. За первый будет отвечать адаптер над document
, за второй — window
.
// src/dom/types.ts
export interface ElementSource {
getElement(id: string): Nullable<HTMLElement>;
}
export interface PixelRatioSource {
devicePixelRatio?: number;
}
Реализацию этих адаптеров и их регистрацию можно посмотреть на Гитхабе. Она короткая.
Собираем всё вместе
Сейчас наши модули находятся вот в таком соотношении друг с другом:

Ни один модуль не зависит напрямую от конкретной реализации другого. Это даёт нам свободу в выборе, обновлении, рефакторинге и замене реализации на другую.
Проверяем работу
Чтобы проверить, как рисовалка работает, получим из контейнера объект, реализующий Drawer
, и вызовем метод drawLine
, передав две точки:
// src/index.ts
import { container } from './composition';
import { Drawer } from './graphics/types';
const drawer = container.get<Drawer>();
drawer.drawLine({
start: { x: 0, y: 0 },
end: { x: 100, y: 100 }
});
Этот код должен нарисовать диагональный отрезок внутри элемента canvas
:

Сработало! 🎉
Осталось теперь связать графику и доменный слой 🤓
В следующей серии
В третьей части напишем «переводчик» с языка L-систем на язык команд для рисования на canvas
. Сгенерируем дерево Пифагора, добавим ему случайности и цвета, чтобы оно стало похоже на настоящее дерево.
Ссылки из поста
Принципы SOLID
- Принцип единственной ответственности, SRP
- Принцип открытости и закрытости, OCP
- Принцип подстановки Лисков, LSP
- Принцип разделения интерфейса, ISP
- Принцип инверсии зависимостей, DIP