Рисуем деревья, часть 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. Спроектируем входы и выходы:

Диаграмма публичного API и зависимостей модуля
Диаграмма публичного API и зависимостей модуля

Модуль будет предоставлять наружу интерфейс 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, а второй — чтобы нормализовать его размеры на ретина-дисплеях. Без нормализации изображение будет мыльным.

Зависимости и публичное API провайдера
Зависимости и публичное API провайдера

Опишем интерфейс для провайдера и контекста. (Помним, что по-хорошему контекст должен быть фасадом над 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

TypeScript, тулинг и паттерны