Саша Беспоясов
Это я.

Рисуем деревья, часть 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, тулинг и паттерны

Рисуем деревья на Canvas с помощью L-систем, TypeScript и ООПРисуем деревья, часть 3. От дерева Пифагора к настоящему