Редактор схем

SchemeEditor/editor:Editor — это интерфейсный контрол, предназначенный для отображения и редактирования графических схем. Редактор схем отображает данные как набор абсолютно спозиционированных элементов на двумерном полотне.

Базовые возможности контрола:

Справочные материалы и ресурсы

Термины

В этой статье мы будем использовать некоторые термины, которые стоит пояснить.

Полотно редактора

Контейнер, в котором располагаются фон, сетка и контейнер с элементами схемы. Если полотно больше самого редактора, то его можно прокручивать.

Система координат полотна аналогична координатам в SVG: начало системы — это верхний левый угол полотна, координаты по оси Y увеличиваются вниз, а по оси X — вправо.

Содержимое редактора

Все элементы, которые показываются в редакторе (блоки и линии). Под размером содержимого подразумевается прямоугольник, описанный вокруг всех элементов схемы.

Прямоугольник задаётся координатой левого верхнего угла, шириной и высотой.

Рис. 1. Структура редактора схем

Данные представления

Данные, которые отображает редактор схем. Они задаются в виде источника данных через опцию source, как в списочных контролах. Данные представления должны содержать всю информацию, необходимую для отображения: если элемент данных нужно отобразить в виде блока определённого размера по заданным координатам, то высота и ширина блока, его координаты X и Y должны быть в этом элементе данных.

Типы элементов

Каждый элемент данных представления должен принадлежать к какому-то типу. Эти типы определяются прикладными разработчиками, использующими редактор схем. По сути, тип элемента — это набор характеристик, таких как шаблон элемента, возможность выделения, перетаскивания и изменения размера, маркеры изменения размера, минимальная и максимальная высота и ширина и т.д. Типы элементов реализуются в виде классов, методы которых определяют поведение всех элементов типа.

Руководство разработчика по конфигурации контрола

Выделить типы элементов

Редактор схем поддерживает два базовых типа элементов — блоки и связи.

  • Блоки — это элементы, которые отображаются в виде прямоугольников. Блок характеризуется прямоугольником, в который он вписан, углом поворота (кратным 90).
  • Связи — это элементы, отображающиеся в виде линий, связывающих блоки. Связь характеризуется блоками, которые они связывает, точками начала и конца и описанием SVG-элемента, который используется для отображения (по умолчанию это атрибут d элемента <path>).

Каждый тип элемента имеет свой шаблон для отображения. Так что при разделении элементов на типы можно руководствоваться в том числе тем, насколько разные нужны шаблоны. Если шаблоны двух элементов сильно отличаются, следует отнести элементы к разным типам.

Для примера рассмотрим схему, на которой отображается план офиса. На схеме должны отображаться рабочие столы и перегородки (это очень простая схема). Очевидно, что в такой схеме используется два типа элементов — столы и перегородки. Для каждого типа нужно создать классы, которые наследуются от одного из базовых типов. Так как оба типа — это блоки, необходимо наследовать от SchemeEditor/editor:types.Block.

Всю информацию, необходимую для отображения элементов, редактор схем получает через методы типа. Так, шаблон элемента получается через getItemTemplate, положение элемента на полотне и угол поворота — через getRect и getAngle соответственно.
Необходимо реализовать эти методы в нашем классе. Так как мы хотим, чтоб редактор изменял элементы (позволял перемещать и поворачивать их), нужно дополнительно реализовать методы для сохранения новых параметров обратно в данные: setRect, setAngle.

Таким образом, редактор схем не предъявляет никаких требований к формату данных, всю необходимую информацию должен предоставлять тип элемента, который, можно сказать, является ещё и адаптером для доступа к данным.

Создаём тип элемента для столов (OfficePlan/Desk.ts):

import {geometry, types} from 'SchemeEditor/editor';
import deskTemplate = require('wml!OfficePlan/Desk');
 
export default class Desk extends types.Block {
    static readonly WIDTH: number = 100;
    static readonly HEIGHT: number = 50;
     
    getItemTemplate(item) {
        return deskTemplate;
    }
     
    /**
     * Получить прямоугольник, который элемент занимает на полотне.
     * Координаты мы будем хранить в полях "x" и "y", а размеры будут
     * фиксированы — 100 на 50, их можно задать константами и не хранить в данных.
     */
    getRect(item) {
        return new geometry.Rect(
            item.get('x'),
            item.get('y'),
            Desk.WIDTH,
            Desk.HEIGHT
        );
    }
     
    /**
     * Сохранить новое положение элемента.
     * Этот метод вызывается при перемещении элемента, так как ширину и высоту стола
     * нельзя изменить, мы сохраняем только новые координаты.
     */
    setRect(item, newRect) {
        item.set({
            x: newRect.left,
            y: newRect.top
        });
    }
     
    /**
     * Пока поворачивать столы нельзя, поэтому угол всегда один и тот же — 0 градусов.
     */
    getAngle(item) {
        return 0;
    }
     
    /**
     * Менять размеры столов нельзя, поэтому маркеры изменения размера у столов нам не нужны.
     * Возвращаем пустой массив. Если у элемента нет маркеров, то нельзя изменить его размер.
     */
    getResizePoints(item) {
        return [];
    }
}

Создаём тип элемента для перегородок (OfficePlan/Partition.ts):

import {geometry, types} from 'SchemeEditor/editor';
import partitionTemplate = require('wml!OfficePlan/Partition');
 
export default class Partition extends types.Block {
    getItemTemplate(item) {
        return partitionTemplate;
    }
     
    /**
     * Пока поворачивать перегородки нельзя,
     * поэтому угол всегда один и тот же — 0 градусов.
     */
    getAngle(item) {
        return 0;
    }
     
    /**
     * Растягивать перегородки можно только в одном направлении, 
     * поэтому настраиваем два маркера изменения размера — сверху и снизу.
     */
    getResizePoints(item) {
        return [
            geometry.ResizePoint.TOP,
            geometry.ResizePoint.BOTTOM
        ];
    }
}

Для определения, какой тип имеет элемент, редактор схем использует функцию из опции typeGetter. Если опция не задана, редактор использует свою внутреннюю реализацию этой функции, которая читает идентификатор типа из поля type. Сам тип элемента получается по идентификатору из опции types.

Определиться с форматом данных представления

Редактор схем не требует передачи ему данных в каком-то определённом формате, не требует обязательного наличия каких-то полей, кроме keyProperty. Поэтому у прикладного разработчика есть выбор:

  • Есть готовые данные и хочется использовать их, не меняя формата. Например, уже есть методы БЛ и какие-то визуальные контролы для отображения. В таком случае в типах элементов просто нужно реализовать методы чтения/записи информации из записей (getRect, setRect, getAngle, setAngle).
  • Данные будут готовиться специально для редактора схем. Тогда можно подготовить данные в формате редактора схем и в типах элементов не реализовывать методы getRect, setRect, getAngle, setAngle, потому что базовые классы типов уже работают с внутренним форматом данных представления.

Настроить опции редактора

В примере ниже мы подготовим данные для обязательных опций редактора.

OfficePlan/Plan.ts

// ...
import Desk from './Desk';
import Partition from './Partition';
 
export default class Plan extends Control {
    // ...
     
    _beforeMount(): void {
        this._types = {
            desk: new Desk(),
            partition: new Partition()
        };
         
        this._source = this._createSource();
        this._itemActions = [
            actions.remove.getConfig()
        ];
    }
     
    private _createSource() {
        // Создать источник данных для плана со столами и перегородками.
    }
}

OfficePlan/Plan.wml

<SchemeEditor.editor:Editor
   attr:style="width: 500px; height: 500px;"
   name="scheme"
   autoResize="{{ true }}"
   itemActions="{{ _itemActions }}"
   types="{{ _types }}"
   source="{{ _source }}">
</SchemeEditor.editor:Editor>

Базовые возможности просмотра

Масштабирование

Содержимое редактора схем можно просматривать в увеличенном или уменьшенном масштабе. Для этого существует опция zoom.

Масштабировать схему можно как колёсиком мыши (опция zoom.onWheel = true), так и используя метод zoomTo.

При масштабировании колёсиком мыши масштаб изменяется относительно точки полотна, в которую указывает курсор. Если полотно меньше редактора, то масштабирование происходит относительно начала координат.

Пример. Включим масштабирование по колесу мыши, а шаг масштабирования установим равным 2. Это будет означать, что при повороте колеса мыши на одно деление масштаб будет изменяться в 2 раза.

<SchemeEditor.editor:SchemeEditor>
    <ws:zoom onWheel="{{true}}" step="{{2}}"/>
</SchemeEditor.editor:SchemeEditor>

Следующие рисунки отражают поведение редактора с этими параметрами.

Рис. 2. Масштабирование относительно точки при помощи колеса мыши

Рис. 3. Масштабирование, когда полотно меньше редактора

Опции zoom.minScale и zoom.maxScale определяют минимально или максимально возможный масштаб для редактора соответственно.

Опция zoom.autoMinScale устанавливает такой минимально возможный масштаб, при котором все элементы редактора будут видны. Работа этой опции продемонстрирована на рисунке. Справа изображен редактор с минимальным масштабом.

Рис. 4. Работа опции zoom.autoMinScale

Вписывание содержимого

С помощью редактора схем можно отобразить контент таким образом, чтобы он, сохраняя пропорции, был полностью виден в редакторе, даже когда размеры редактора динамически меняются. Иными словами — редактор схем позволяет вписать в себя содержимое с сохранением пропорций.

Режим вписывания может быть активирован только вместе с режимом чтения, то есть когда контент нельзя редактировать.

Схема в режиме вписывания центрируется относительно редактора, но если значение опции inscribingPosition равно 'top', то контент прижимается к верху редактора.

Чтобы включить режим вписывания, нужно задать опциям inscribing и readOnly значение true.

Пример. Включим режим вписывания для редактора схем и зададим фоновый рисунок.

<SchemeEditor.editor:SchemeEditor
    inscribing="{{true}}"
    readOnly="{{true}}">
        <ws:background
            url="../lines.png"
            repeat="repeat"/>
</SchemeEditor.editor:SchemeEditor>

На рисунке ниже показана работа режима вписывания для редактора с тремя элементами и заданным фоновым рисунком (пересекающиеся линии). Пунктиром обозначен ограничивающий прямоугольник элементов.

Рис. 5. Работа режима вписывания редактора схем

Обратите внимание, что после вписывания содержимого остается пространство по горизонтали, не занятое ограничивающим прямоугольником элементов, поэтому содержимое выравнивается по горизонтали.

Важно

Содержимое выравнивается независимо от того, помещается полотно в редактор или нет. Это значит, что в режиме вписывания могут быть видны границы полотна, например, в случае задания фонового рисунка, так как фоновый рисунок редактора схем — это фоновый рисунок его полотна.

На рисунке ниже продемонстрировано вписывание в различных размерах редактора.
Содержимое центрируется, так как опция inscribingPosition не задана.

Рис. 6. Режим вписывания при различных размерах редактора схем

Режим вписывания зависит от свойства опции масштабирования zoom.maxScale — если максимальный масштаб меньше, чем рассчитанный для режима вписывания, то используется максимальный масштаб с центрированием контента.
На рисунках ниже продемонстрирована такая ситуация с разными опциями выравнивания вписанного контента.

Рис. 7. Режим вписывания с различными опциями центрирования

Пример для редактора схем на рисунке справа.

<SchemeEditor.editor:SchemeEditor
    inscribing="{{true}}"
    readOnly="{{true}}"
    inscribingPosition="top">
        <ws:background
            url="../lines.png"
            repeat="repeat"/>
        <ws:zoom maxScale="{{3}}"/>
</SchemeEditor.editor:SchemeEditor>

Базовые возможности редактирования

Чтоб редактирование элементов было доступно пользователям, необходимо выполнение следующих условий:

  • опция readOnly должна быть false
  • должны быть правильно реализованы соответствующие методы в типах элементов.

Выравнивание по сетке

Cетка — это инструмент для помощи в выравнивании элементов. Она настраивается с помощью опции grid.

Отображение и работа сетки не зависят друг от друга и их можно включать отдельно: можно отключить выравнивание, но оставить сетку видимой или включить выравнивание по сетке, но скрыть линии сетки.

Интервалы сетки можно настроить отдельно как по оси X, так и по оси Y.

Работу сетки можно наблюдать при перетаскивании или ресайзе элементов. При перетаскивании элемента левый верхний угол его прямоугольника выравнивается по точкам пересечения линий сетки. При изменении размеров элемента стороны его прямоугольника выравниваются по линиям сетки.

Пример. Включим сетку и ее отображение, установим разные значения интервалов по осям.

<SchemeEditor.editor:SchemeEditor>
    <ws:grid
        xStep="{{20}}"
        yStep="{{40}}"
        visible="{{true}}"
        enabled="{{true}}"/>
</SchemeEditor.editor:SchemeEditor>

На рисунке ниже показана работа сетки с этими параметрами при растягивании квадрата 30 х 30.

Рис. 8. Работа сетки (xStep = 20; yStep = 40) при ресайзе элемента

Для квадратной сетки (xStep = 40, yStep = 40) покажем ее работу при перемещении элемента.

Рис. 9. Работа сетки (xStep = 40; yStep = 40) при перемещении элемента

Перемещение

Блочные элементы в редакторе схем можно перетаскивать с помощью DragNDrop. Если включена сетка, то верхний левый угол элемента будет выравниваться по узлам сетки при перемещении.

Возможность перетаскивания элементов определяется методом типа isDraggable. Чтоб запретить перетаскивание элемента, метод должен возвращать false.

isDraggable(item) {
    // Элементы с полем "static" == true перетаскивать нельзя.
    return !item.get('static');
}

При перетаскивании редактор схем вычисляет новый прямоугольник элемента. За проверку и сохранение новой позиции элемента отвечает метод раскладки setItemRect. Для дополнительных проверок и корректировок нужно реализовать свой класс раскладки, перекрыв этот метод.
Предположим, в нашей схеме есть элементы-контейнеры, внутри которых можно двигать вложенные элементы, но так, чтоб вложенные элементы не выходили за границы контейнеров. Реализуем такое поведение в своей раскладке MyModule/ContainerLayout.ts:

import {layout, geometry} from 'SchemeEditor/editor';
 
export default class ContainerLayout extends layout.Base {
    private getContainerRect(item): geometry.IRect {
        // Этот метод находит элемент-контейнер для переданного элемента,
        // определяет тип контейнера и получает его прямоугольник.
        // Все данные для этого есть в раскладке, но мы опустим код в примере.
    }
     
    private isInContainer(itemRect, containerRect): boolean {
        // Возвращает true, если прямоугольник itemRect находится 
        // полностью внутри прямоугольника containerRect.
    }
     
    setItemRect(item, newRect) {
        const containerRect = this.getContainerRect(item);
         
        if (containerRect && this.isInContainer(newRect, containerRect)) {
            // Базовая версия метода просто сохраняет прямоугольник без проверок,
            // используем её, чтоб не писать сохранение самим.
            super.setItemRect(item, newRect);
        }
    }
}

Теперь просто указываем нашу раскладку в опциях редактора схем.

<SchemeEditor.editor:Editor>
    <ws:layout>
        <MyModule.ContainerLayout />
    </ws:layout>
</SchemeEditor.editor:Editor>

Изменение размеров

В редакторе схем у выделенного элемента есть маркеры, перетаскивая которые пользователь может менять размеры элемента. У элемента может быть от 0 до 8 маркеров. То, какие маркеры показываются, определяет метод getResizePoints в типе элемента. Чтоб запретить изменение размера, метод должен возвращать пустой массив. Реализация метода в базом классе Block возвращает все 8 маркеров (по углам и на серединах сторон прямоугольника элемента), так что по умолчанию все блоки можно растягивать во всех направлениях.

Маркеры автоматически поворачиваются в зависимости от угла элемента: например, если у элемент есть только маркер TOP, который отображается на верхней стороне, то после поворота на элементе отобразится маркер RIGHT, после следующего поворота — маркер BOTTOM и т.д.

При изменении размеров элементу назначается новый прямоугольник, который сохраняется таким же способом, как и при перемещении — через метод раскладки setItemRect и метод типа setRect (см. предыдущий раздел). Стороны прямоугольника элемента всегда выравниваются по сетке, если она включена.

Редактор схем может проверять минимальные и максимальный допустимые размеры для элементов и не давать растягивать элементы за границы указанных пределов. Ограничения размеров для растягиваемых элементов задаются в методе типа getResizeLimits. Ограничения автоматически "поворачиваются" при повороте элемента: у элементов, повёрнутых на 90 или 270 градусов ограничения ширины применяются к высоте и наоборот.

getResizeLimits(item) {
    // Элемент может менять размеры в пределах от 10х20 до 100х80.
    return {
        minWidth: 10,
        maxWidth: 100,
        minHeight: 20,
        maxHeight: 80
    };
}

Если элемент уменьшается и уже достиг минимального размера, то при дальнейшем уменьшении произойдёт смещение элемента в сторону, куда двигается маркер: элемент переместится в позицию своего отражения относительно стороны, противолежащей передвигаемому маркеру.

Поворот

Редактор схем может поворачивать элементы по часовой стрелке на 90 градусов. Доработаем пример с планом офиса, добавив возможность поворачивать столы и перегородки.

Добавим встроенную команду "Поворот" в действия над элементом.

Доработаем OfficePlan.ts:

import {actions} from 'SchemeEditor/editor';
...
_beforeMount(options) {
    ...
    // Описание действия, его можно поменять
    const rotateAction = actions.rotate.getConfig();
     
    // Изменим стандартную подпись
    rotateAction.caption = rk('Повернуть по часовой стрелке');
     
    // Добавим действие к массиву
    this._itemActions.push(rotateAction);
}

Для удобства редактор схем сам поворачивает прямоугольник элемента относительно его центра. Такой поворот подходит для перегородок, но для столов мы сделаем другой поворот: предположим, столы должны поворачиваться так, что координата верхнего левого угла не меняется. Доработаем типы элементов.

OfficePlan/Desk.ts:

/**
 * Получить прямоугольник, который элемент занимает на полотне.
 * Координаты не зависят от угла, берём их из полей "x" и "y".
 * Размеры стола фиксированы — 100 на 50,
 * у повёрнутого на 90 или 270 градусов стола — 50 на 100.
 */
getRect(item) {
    const angle = this.getAngle(item);
    const isRotated = Boolean(angle % 180);
     
    return new geometry.Rect(
        item.get('x'),
        item.get('y'),
        isRotated ? Desk.HEIGHT : Desk.WIDTH,
        isRotated ? Desk.WIDTH : Desk.HEIGHT
    );
}
     
/**
 * Угол поворота будем хранить в поле "angle".
 */
getAngle(item) {
    return item.get('angle');
}
 
/**
 * При повороте элемента меняется не только угол, но и прямоугольник,
 * занимаемый элементом на полотне. Для удобства редактор схем
 * сам поворачивает прямоугольник элемента относительно его центра
 * и передаёт новый прямоугольник третьим аргументом.
 * Мы можем принять эти данные или нет. Здесь у нас поворот по другим
 * правилам и мы игнорируем предлагаемый редактором прямоугольник,
 * сохраняя только угол.
 */
setAngle(item, newAngle, newRect) {
    item.set('angle', newAngle);
}

OfficePlan/Partition.ts:

/**
 * Угол поворота будем хранить в поле "angle".
 */
getAngle(item) {
    return item.get('angle');
}
 
/**
 * При повороте элемента меняется не только угол, но и прямоугольник,
 * занимаемый элементом на полотне. Для перегородок нас устраивает
 * стандартный поворот и мы сохраняем новый прямоугольник.
 */
setAngle(item, newAngle, newRect) {
    item.set({
        angle: newAngle,
        x: newRect.left,
        y: newRect.top,
        width: newRect.width,
        height: newRect.height
    });
}

Нет необходимости менять шаблоны элементов, они будут автоматически повёрнуты с помощью CSS-преобразований. Но, предположим, в шаблоне стола появляется надпись, которую нельзя поворачивать. Тогда стандартный поворот шаблона для столов надо отключить. Это делается через параметр rotate шаблона элемента (опция itemTemplate).

Добавим в OfficePlan.ts метод, определяющий, можно ли использовать стандартное вращение стилями.

/**
 * Можно ли поворачивать элемент полностью стилями.
 */
_canRotate(item) {
    // Можно поворачивать любые элементы, кроме элементов типа "desk".
    return item.get('type') !== 'desk';
}

Настроим шаблон элемента в редакторе схем.

<SchemeEditor.editor:Editor>
    <ws:itemTemplate>
        <ws:partial
            template="SchemeEditor/editor:defaultItemTemplate"
            rotate="{{_canRotate(itemTemplate.itemData.item)}}"/>
    </ws:itemTemplate>
</SchemeEditor.editor:Editor>

Древовидная раскладка

Древовидная раскладка редактора схем используется, когда нужно отобразить данные в виде иерархической структуры — дерева.

Посмотрите интерактивный пример работы древовидной раскладки и исходный код примера.

Опции древовидной раскладки

За настройку древовидной раскладки помимо опций базовой раскладки отвечает опция layoutOptions специального вида — TreeOptions.

Создание пользовательской древовидной раскладки на основе встроенной

Рассмотрим на примере, как можно настроить и расширить поведение встроенных раскладок. Создадим древовидную раскладку, немного отличающуюся от уже имеющейся в редакторе схем. Предположим, нам нужно, чтобы начальные точки связей зависели от количества потомков узла и были распределены равномерно (см. рисунок 4).

Рис. 10. Стандартное и желаемое расположение соединяющих линий

Для этого:

  • Создадим новый класс, наследующий TreeLayout:
import {layout, geometry} from 'SchemeEditor/editor';
 
export default class CustomTreeLayout extends layout.Tree {
}
  • добавим метод getSourcePointCoord для расчета координаты точки, в которой связь присоединяется к блоку-источнику:
private getSourcePointCoord(sourceId: string, targetId: string): number {
    const sourceNode = this._relations.nodes[sourceId];
    const targetNode = this._relations.nodes[targetId];
    const childrenCount = sourceNode.children.length;
     
    return 1 / (childrenCount + 1) * (targetNode.index + 1);
}
  • добавим метод getDirConnectors для получения коннекторов связей в зависимости от направления роста дерева:
private getDirConnectors(direction: string, link): geometry.Connector[] {
    const sourcePoint = this.getSourcePointCoord(link.source.id, link.target.id);
     
    switch (direction) {
        case 'down':
            return [
                new geometry.Connector(sourcePoint, 1),
                geometry.Connector.TOP
            ];
        case 'left':
            return [
                new geometry.Connector(0, sourcePoint),
                geometry.Connector.RIGHT
            ];
        case 'right':
            return [
                new geometry.Connector(1, sourcePoint),
                geometry.Connector.LEFT
            ];
        case 'up':
            return [
                new geometry.Connector(sourcePoint, 0),
                geometry.Connector.BOTTOM
            ];
    }
}
  • перегрузим метод getLinkPoints, чтобы он возвращал новые точки линий:
protected getLinkPoints(link: Record): [geometry.IPoint, geometry.IPoint] {
    const dir = this._options.layoutOptions.growDirection;
    const relLink = this._relations.links[link.getKey()];
    const connectors = this.getDirConnectors(dir, relLink);
    const sourceBlock = this._resolveBlockConnector(relLink.source.id, connectors[0]);
    const targetBlock = this._resolveBlockConnector(relLink.target.id, connectors[1]);
     
    return [sourceBlock.point, targetBlock.point];
}
  • укажем новую раскладку в опциях редактора схем:
<SchemeEditor.editor:Editor>
    <ws:layout>
        <MyModule.CustomTreeLayout />
    </ws:layout>
</SchemeEditor.editor:Editor>