С потребностью создания двумерных интерактивных графических компонент разработчикам программного обеспечения приходится сталкиваться довольно часто. Программисты, ранее привыкшие работать только с алгоритмами обработки данных, при возникновении подобных задач сталкиваются с большими трудностями, если только нельзя обойтись каким-нибудь совсем уж примитивным решением, вроде статической картинки с заранее определёнными «активными» областями. Нестандартность задачи многих отпугивает и заставляет искать готовые средства и библиотеки для отрисовки графов. Но сколь бы многофункциональной не была библиотека, для решения именно вашей задачи в ней будет чего-то недоставать.
В этой статье мы подробно разберём создание «с нуля» компоненты с интерактивными, «перетаскиваемыми» элементами в объектно-ориентированной среде разработки. В качестве примера мы построим прототип UML-редактора.
Список задач, требующих реализации интерактивной графики, довольно обширен. Это могут быть
— и так далее, и тому подобное. Хотя внешний вид всех этих диаграмм различен, во всех случаях нужно реализовывать некоторые общие требования. Вот они:
Важность возможности «перетаскивания» элементов следует подчеркнуть особо. Если ваша задача включает в себя необходимость визуализации графов, нужно помнить: ни один из многочисленных алгоритмов автоматического расположения узлов графа на плоскости не может дать решение, полностью удовлетворительное во всех случаях, и для удобства пользователя «ручная» перестановка узлов графа просто необходима.
Какие же «ингриденты» нужны для приготовления этого «блюда»? В этой статье мы покажем общие принципы, которые можно применять в любой среде разработки при выполнении всего четырёх ключевых условий:
Наш иллюстрирующий пример являет собой прототип редактора UML Use Case-диаграмм, мы будем пользоваться красивой диаграммой из этого руководства. Исходные коды нашего примера доступны по адресу https://github.com/inponomarev/graphexample и могут быть скомпилированы при помощи Maven. Если вы хотите лучше усвоить изложенные в статье принципы, я настоятельно рекомендую скачать эти исходники и изучать их вместе со статьёй.
Пример построен на Java 8 со стандартной библиотекой Swing. Однако в изложенных принципах нет ничего Java-специфичного. Мы впервые реализовали изложенные тут принципы в Delphi (Windows-приложения), а затем в Google Web Toolkit (веб-приложения с выводом графики на HTML Canvas). При выполнении четырёх вышеуказанных условий предложенный пример можно сконвертировать и в другую среду разработки.
Вообще, нарисовать какую-то схему на экране, используя методы вывода графических примитивов — задача вроде нетрудная. Палка, палка, огуречик (с подобного упражнения на языке BASIC когда-то давно я впервые познакомился с программированием):
Но пока что наш «человечек» не «ожил»: его нельзя выделять, масштабировать, передвигать по холсту, он не умеет взаимодействовать с обществом других «человечков». Значит, надо написать код, ответственный за все эти операции. Для простой картинки это кажется несложным, однако по мере усложнения того, что мы хотим получить, нас поджидают проблемы.
Попытка решить задачу «в лоб» усложнением процедур обречена на провал в силу быстрого усложнения исходного кода, количество которого будет расти лавинообразно по мере усложнения диаграммы. Однако применение объектно-ориентированной разработки, универсальный принцип «разделяй и властвуй», а также паттерны проектирования дают нам достаточно мощной инструментарий, чтобы изящно разделаться с перечисленными проблемами и реализовать нужную функциональность.
Итак, мы приступаем к решению.
Начнём с того, что разобьём задачу на маленькие части, построив иерархический перечень того, что нам нужно нарисовать.
Сначала нарисуем картину, которую хотим получить, на доске или на бумаге:
И выстроим следующую иерархию:
Наш пример, в действительности, очень прост, поэтому иерархия получилась неглубокой. Чем сложнее картинка, тем шире и глубже будет иерархия.
Обратите внимание на то, что некоторые пункты выделены курсивом. Это те объекты на диаграмме, которые мы хотим сделать выделяемыми и перемещаемыми при помощи курсора мыши.
Каждому из пунктов этой иерархии будет соответствовать класс-отрисовщик, а иерархическая связь между ними позволяет применить паттерн Composite — «компоновщик», который (цитирую книгу “Design Patterns”) «компонует объекты в древовидные структуры для представления иерархий часть-целое, позволяет… единообразно трактовать индивидуальные и составные объекты». Т. е. делает ровно то, что нам нужно.
На диаграмме классов наша система имеет следующий вид:
В верхней части диаграммы классов находятся два класса (DiagramPanel и DiagramObject), которые «ничего не знают» о конкретике отрисовываемой диаграммы и образуют фреймворк, на основе которого можно делать диаграммы различного вида. DiagramPanel (в нашем случае это наследник класса javax.swing.JPanel) представляет собой визуальный компонент интерфейса, ответственный за отображение диаграммы и её взаимодействие с пользователем. Объект DiagramPanel содержит в себе ссылку на DiagramObject — корневой объект-отрисовщик, соответствующий самому верхнему уровню иерархии отрисовки (в нашем случае это будет экземпляр класса UseCaseDiagram).
DiagramObject — это базовый класс всех объектов-отрисовщиков, реализующий их иерархию через паттерн Composite и многое другое, о чём речь пойдёт далее.
В нижней части находится пример использования фреймворка. Класс Example (наследник javax.swing.JFrame) — это главное окно приложения, которое в нашем примере содержит в себе в качестве одной единственной компоненты экземпляр DiagramPanel. Все прочие классы — наследники DiagramObject. Они соответствуют задачам в иерархическом перечне отрисовки. Обратите внимание, что иерархия наследования этих классов и иерархия отрисовки — это разные иерархии!
Иерархия отрисовки, в соответствии со сказанным выше, выглядит так:
Далее мы подробно опишем устройство классов DiagramObject и DiagramPanel и то, как их следует использовать.
Класс DiagramObject устроен так, что внутри каждого из его экземпляров находится двусвязный список подчинённых отрисовщиков. Это достигается при помощи переменных previous, next, first и last, позволяющих ссылаться на соседние элементы в списках и иерархии. Когда объекты инстанцированы, получается примерно такая картина:
Эта, подобная простому двусвязному списку, структура данных хороша тем, что мы можем за время O(N) собрать нужную нам иерархию, а при необходимости — за время О(1) и модифицировать её, удалив заданный элемент или вставив новый в список после какого-либо заданного элемента. Доступ к элементам этой структуры нас интересует только последовательный, соответствующий обходу дерева в глубину, что достигается проходом по ссылкам. Движение по красным стрелкам соответствует обходу в прямую, а по синим стрелкам — обходу в обратную сторону.
Для добавления нового объекта во внутренний список DiagramObject служит метод addToQueue(DiagramObject subObj):
Чтобы собрать желаемую картину, остаётся лишь проинстанцировать нужное количество нужных отрисовщиков и объединить их в очереди в нужном порядке. В нашем примере большая часть этой работы происходит в конструкторе класса UseCaseDiagram:
В реальной жизни следует поступать, конечно, не так: вместо «зашивания в код» процесса создания объектов-отрисовщиков, в конструктор корневого класса вам необходимо будет передать модель данных вашей системы. Обходя уже в циклах объекты этой модели, вы будете создавать отрисовщики. Например, для каждого связанного с текущей диаграммой экземпляра класса Actor (соответствующего «роли» в вашей «модели документа UML») необходимо проинстанцировать объект класса-отрисовщика DiagramActor.
Удобно, когда отрисовщики хранят ссылки на соответствующие объекты модели. Передавать эти ссылки удобнее всего прямо в виде параметров конструкторов отрисовщиков. В нашем примере вместо них передаются мировые координаты объектов и их параметры, такие как название и стереотип.
Коль скоро мы применили термин «мировые координаты» — нужно уточнить, что это такое в нашем случае. «Мировые координаты» у нас — это координаты объектов диаграммы на «воображаемой миллиметровой бумаге», на которой умещается диаграмма целиком, которая имеет начало координат в левом верхнем углу и не подвергается никакому масштабированию. Мировые координаты совпадают с экранными, если масштаб картинки 1:1 и полосы прокрутки находятся в своих минимальных позициях. Мировая координата, в отличие от экранной, имеет не целочисленный тип, а принимает значение с плавающий точкой. Это нужно, чтобы не происходила пикселизация картинки при увеличении её масштабов. Например, хотя при масштабе 1:1 значение мировой координаты 0.3 не отличимо от нуля экранных пикселов, в масштабе 100:1 оно превращается уже в 30 экранных пикселов.
Рассчитывать и хранить модель диаграммы удобно именно в мировых координатах, т. к. они не зависят от таких сиюминутных действий пользователя, как изменение масштаба и прокрутки.
Для перевода мировых координат в экранные класс DiagramObject содержит важные методы scaleX(…), scaleY(…) и просто scale(…). Первые два применяют к мировой координате масштабный коэффициент и учитывают сдвиг горизонтальной и вертикальной полосы прокрутки, соответственно. Последний метод, scale(…), применяет масштабный коэффициент, но не учитывает сдвиг: он необходим для расчёта не позиции, а размера (например, ширины прямоугольника или радиуса окружности).
Для отрисовки диаграммы вызывается метод draw(Graphics canvas, double aDX, double aDY, double scale) корневого DiagramObject. Его параметрами являются:
Этот метод реализует паттерн проектирования Template Method (шаблонный метод) и выглядит следующим образом:
Т. е. метод draw(…):
Таким образом, инвариантная часть алгоритма реализована в методе draw(…), а изменяемая часть (собственно рисование) реализуется в классах-наследниках, что и составляет суть паттерна Template Method.
Предназначение методов saveCanvasSetup() и restoreCanvasSetup() — сохранить состояние контекста рисования, так чтобы каждый из объектов-отрисовщиков получил его в «нетронутом» виде. Если эти методы не применять, и в одном из наследников-отрисовщиков, допустим, цвет чернил изменить на красный, то всё, что будет нарисовано далее, будет нарисовано красным цветом. Реализация данных методов зависит от вашей среды разработки и возможностей, предоставляемых механизмом рисования. В Delphi и Java Swing, к примеру, надо сохранять множество параметров контекста, а в HTML Canvas2D специально для этой цели имеются готовые методы save() и restore(), сразу сохраняющие в специальный стек всё состояние контекста.
Вот как выглядит метод internalDraw в классе DiagramActor (сравните с «наивным примером», с которого мы начали):
В точке (mX, mY) находится середина объекта. Т. к. начало координат «наивного примера» находится в левом верхнем углу, их необходимо сместить на половину ширины и половину высоты объекта. «Наивный пример» не учитывал необходимость масштабирования и смещения картинки, мы же учитываем это, переводя мировые координаты в экранные при помощи методов scaleX(…), scaleY(…) и scale(…).
Обекты DiagramActor и DiagramUseCase полностью «самостоятельны», их позиции целиком определяются внутренним состоянием, хранимым в полях mX и mY. В то же время всевозможные соединительные стрелки собственного состояния не имеют — их позиция на экране полностью определена позициями объектов, которые они соединяют, они полностью «не самостоятельны», они проходят по прямой, соединяющей центры объектов:
И отдельно следует обратить внимание на подписи к объектам. В своём внутреннем состоянии они хранят не абсолютные координаты, а смещение относительно родительского объекта-отрисовщика, поэтому они ведут себя «полу-самостоятельно»:
Разобравшись с отрисовкой, переходим к вопросу о том, каким же образом диаграмма «понимает», на какой объект мы кликнули мышью. Оказывается, что задача определения объекта, находящегося под курсором мыши, очень похожа на задачу отрисовки и в определённом смысле симметрична ей.
Сначала заметим, что для каждого заданного объекта диаграммы не составляет труда написать метод, определяющий по координатам курсора мыши, находится ли курсор над этим объектом или нет.
Например, для DiagramActor речь идёт о попадании в прямоугольную область:
Для DiagramUseCase речь идёт о попадании в область, имеющую вид эллипса:
Теперь, если мы хотим определить объект, над которым сейчас находится курсор, мы можем методом последовательного перебора вызывать internalTestHit для каждого из объектов диаграммы, и первый, вернувший true, окажется искомым объектом. Только делать это надо в порядке, обратном порядку отрисовки (движение по синим стрелкам на иллюстрации, показывающей структуру данных)! Если курсор мыши находится в области, на которой пересекается несколько объектов, именно поиск в обратном порядке обеспечит попадание курсором в объект, отрисованный позже других, т. е. визуально находящийся «над другими».
Вот как это реализовано в ещё одном шаблонном методе DiagramObject:
Объект DiagramPanel вызывает метод testHit корневого объекта отрисовки. Во время его выполнения происходит рекурсия, выполняющая обход дерева отрисовки в глубину в направлении, противоположном направлению отрисовки. Возвращается первый найденный объект: это и будет самый «верхний» с точки зрения пользователя объект, находящийся под курсором мыши.
Объект, находящийся под курсором мыши, может оказаться лишь составной частью более крупного объекта и не иметь самостоятельного значения. Если мы хотим произвести некоторую операцию над объектом и кликнули мышью на его часть, то операцию всё равно нужно производить над родительским объектом. Правильно показать контекст можно при помощи делегирования — приёма, связанного с использованием паттерна Composite (см. на этот счёт книгу Design Patterns). В нашем примере мы применяем делегирование для получения всплывающей подсказки объекта: например, если пользователь наводит курсор мыши на подпись под Actor-ом, он получает ту же подсказку, что и при наведении курсора собственно на Actor-а.
Идея очень проста: метод getHint() класса DiagramObject выполняет следующее: если его собственная реализация метода internalGetHint() в состоянии вернуть строку-подсказку — то она же и возвращается. Если не в состоянии, то идёт обращение к родительскому (в иерархии отрисовки) объекту — не может ли он выполнить работу метода getHint(). В случае, если и он «не берётся», «передача ответственности» будет продолжаться до самого корневого объекта-отрисовщика. Помимо механизма делегирования, мы вновь применяем паттерн Template Method:
Наследники DiagramObject могут переопределить следующие методы — их использование в классе DiagramPanel станет понятно из дальнейшего:
И наконец, ещё одна важная функциональность, реализованная на уровне DiagramObject, которая может быть переопределена в его наследниках — отрисовка выделения, т. е. графической метки, по которой пользователь может понять, что объект находится в выделенном состоянии. По умолчанию это четыре синие квадратные точки по углам объекта:
Обратите внимание на целочисленные (а значит, в экранных координатах) параметры dX, dY и на вызов setXORMode(), переключающий контекст отрисовки в «XOR-режим»: в этом режиме для того, чтобы стереть ранее нарисованное изображение, достаточно прорисовать его ещё раз. Это нужно для того, чтобы реализовать Drag&Drop для объектов диаграммы: для простоты, мы «перетаскиваем» мышью не само изображение, а его выделение, и затем уже перебрасываем изображение на новое место, при этом в параметрах dX, dY будет передано смещение объекта в экранных координатах относительно исходного положения:
Если такое поведение системы не устраивает, то можно переопределить метод internalDrawSelection в наследниках класса DiagramObject, чтобы рисовать в качестве выделения (и передвигать при drag&drop) что-нибудь более сложное.
Это всё, что касается класса DrawObject. Во второй части статьи будет рассмотрено построение класса DiagramPanel, отвечающего за обработку событий мыши и масштабирование, панорамирование, выделение объектов и drag&drop. Полный исходный код нашего примера, напоминаю, доступен по адресу https://github.com/inponomarev/graphexample и может быть скомпилирован при помощи Maven.
В этой статье мы подробно разберём создание «с нуля» компоненты с интерактивными, «перетаскиваемыми» элементами в объектно-ориентированной среде разработки. В качестве примера мы построим прототип UML-редактора.
Постановка задачи
Основные требования к решению
Список задач, требующих реализации интерактивной графики, довольно обширен. Это могут быть
- условные схемы оборудования (установок, конвейеров, транспортных систем), на которых отображается состояние работы узлов, «по щелчку мыши» пользователь желает получать дополнительную информацию или вводить управляющие команды,
- аналитические графики (гистограммы, пузырьковые диаграммы), «по щелчку мыши» на элементы которых пользователь желает получать информацию, или же пользователь желает напрямую «подтаскивать» мышью элементы, изменяя картину в нужную сторону, чтобы узнать необходимые числовые показатели,
- визуальный анализ данных, представимых в виде графов (например: взаимосвязи между юридическими лицами в базе данных), пользователь желает «перетаскивать» элементы графа вручную, чтобы в итоге вставить получившуюся картинку в печатный отчёт,
- все средства визуального моделирования/конструирования чего-либо из блоков, в частности, все CASE-средства,
— и так далее, и тому подобное. Хотя внешний вид всех этих диаграмм различен, во всех случаях нужно реализовывать некоторые общие требования. Вот они:
- Картинка должна состоять из дискретных элементов различной графической сложности,
- Картинка должна быть масштабируемой и прокручиваемой, т. е. пользователь должен иметь возможность покрупнее разглядеть любой из фрагментов диаграммы, используя изменение масштаба и полосы прокрутки,
- Некоторые из элементов картинки должны быть «кликабельными», т. е. система в каждый момент должна «понимать», на какой именно элемент наведён указатель мыши, иметь возможность показывать для них всплывающие подсказки,
- Некоторые из «кликабельных» элементов должны быть «выделяемыми», т. е. пользователь должен иметь возможность «поставить выделение» на элемент щелчком мыши, должно быть доступно выделение группы элементов с нажатой клавишей Shift и при помощи «прямоугольного лассо». С выделенными объектами, в зависимости от задачи, могут производиться некоторые действия или изменение свойств.
- Некоторые из «кликабельных» элементов должны быть «перетаскиваемыми», т. е. пользователь должен иметь возможность передвинуть мышью один элемент или группу выделенных элементов:
Важность возможности «перетаскивания» элементов следует подчеркнуть особо. Если ваша задача включает в себя необходимость визуализации графов, нужно помнить: ни один из многочисленных алгоритмов автоматического расположения узлов графа на плоскости не может дать решение, полностью удовлетворительное во всех случаях, и для удобства пользователя «ручная» перестановка узлов графа просто необходима.
Какие же «ингриденты» нужны для приготовления этого «блюда»? В этой статье мы покажем общие принципы, которые можно применять в любой среде разработки при выполнении всего четырёх ключевых условий:
- Объектно-ориентированный язык программирования.
- Доступность объекта-«холста» (Canvas), с возможностью отрисовки графических примитивов (линий, дуг, многоугольников и т. п.).
- Компоненты, реализующие управляемые полосы прокрутки.
- Доступность обработки событий мыши.
Наш иллюстрирующий пример являет собой прототип редактора UML Use Case-диаграмм, мы будем пользоваться красивой диаграммой из этого руководства. Исходные коды нашего примера доступны по адресу https://github.com/inponomarev/graphexample и могут быть скомпилированы при помощи Maven. Если вы хотите лучше усвоить изложенные в статье принципы, я настоятельно рекомендую скачать эти исходники и изучать их вместе со статьёй.
Пример построен на Java 8 со стандартной библиотекой Swing. Однако в изложенных принципах нет ничего Java-специфичного. Мы впервые реализовали изложенные тут принципы в Delphi (Windows-приложения), а затем в Google Web Toolkit (веб-приложения с выводом графики на HTML Canvas). При выполнении четырёх вышеуказанных условий предложенный пример можно сконвертировать и в другую среду разработки.
Трудности «наивного» подхода
Вообще, нарисовать какую-то схему на экране, используя методы вывода графических примитивов — задача вроде нетрудная. Палка, палка, огуречик (с подобного упражнения на языке BASIC когда-то давно я впервые познакомился с программированием):
canvas.drawOval(10, 0, 10, 10);
canvas.drawLine(15, 10, 15, 25);
canvas.drawLine(5, 15, 25, 15);
canvas.drawLine(5, 35, 15, 25);
canvas.drawLine(25, 35, 15, 25);
Но пока что наш «человечек» не «ожил»: его нельзя выделять, масштабировать, передвигать по холсту, он не умеет взаимодействовать с обществом других «человечков». Значит, надо написать код, ответственный за все эти операции. Для простой картинки это кажется несложным, однако по мере усложнения того, что мы хотим получить, нас поджидают проблемы.
- С усложнением картины растёт длина «процедуры отрисовки». Для сложной схемы процедура становится очень длинной и запутанной.
- Код, рисующий картинку, сам «по волшебству» не задаёт критерий, по которому можно было бы определить объект, выделяемый в текущий момент курсором мыши. Мы должны писать отдельную процедуру, определяющую объект, над которым находится курсор мыши, и при этом должны постоянно синхронизировать код процедуры отрисовки и процедуры распознавания объектов.
- Вместе со сложностью процедуры отрисовки растёт сложность процедуры распознавания.
Попытка решить задачу «в лоб» усложнением процедур обречена на провал в силу быстрого усложнения исходного кода, количество которого будет расти лавинообразно по мере усложнения диаграммы. Однако применение объектно-ориентированной разработки, универсальный принцип «разделяй и властвуй», а также паттерны проектирования дают нам достаточно мощной инструментарий, чтобы изящно разделаться с перечисленными проблемами и реализовать нужную функциональность.
Итак, мы приступаем к решению.
Декомпозиция задачи. Структура классов
Начнём с того, что разобьём задачу на маленькие части, построив иерархический перечень того, что нам нужно нарисовать.
Сначала нарисуем картину, которую хотим получить, на доске или на бумаге:
И выстроим следующую иерархию:
- Диаграмма целиком
- Роли (Actors)
- Подписи ролей
- Варианты использования (Use Cases)
- Наследования (generalizations)
- Связи (associations)
- Зависимости (dependencies)
- Подписи стереотипов зависимостей
- Роли (Actors)
Наш пример, в действительности, очень прост, поэтому иерархия получилась неглубокой. Чем сложнее картинка, тем шире и глубже будет иерархия.
Обратите внимание на то, что некоторые пункты выделены курсивом. Это те объекты на диаграмме, которые мы хотим сделать выделяемыми и перемещаемыми при помощи курсора мыши.
Каждому из пунктов этой иерархии будет соответствовать класс-отрисовщик, а иерархическая связь между ними позволяет применить паттерн Composite — «компоновщик», который (цитирую книгу “Design Patterns”) «компонует объекты в древовидные структуры для представления иерархий часть-целое, позволяет… единообразно трактовать индивидуальные и составные объекты». Т. е. делает ровно то, что нам нужно.
На диаграмме классов наша система имеет следующий вид:
В верхней части диаграммы классов находятся два класса (DiagramPanel и DiagramObject), которые «ничего не знают» о конкретике отрисовываемой диаграммы и образуют фреймворк, на основе которого можно делать диаграммы различного вида. DiagramPanel (в нашем случае это наследник класса javax.swing.JPanel) представляет собой визуальный компонент интерфейса, ответственный за отображение диаграммы и её взаимодействие с пользователем. Объект DiagramPanel содержит в себе ссылку на DiagramObject — корневой объект-отрисовщик, соответствующий самому верхнему уровню иерархии отрисовки (в нашем случае это будет экземпляр класса UseCaseDiagram).
DiagramObject — это базовый класс всех объектов-отрисовщиков, реализующий их иерархию через паттерн Composite и многое другое, о чём речь пойдёт далее.
В нижней части находится пример использования фреймворка. Класс Example (наследник javax.swing.JFrame) — это главное окно приложения, которое в нашем примере содержит в себе в качестве одной единственной компоненты экземпляр DiagramPanel. Все прочие классы — наследники DiagramObject. Они соответствуют задачам в иерархическом перечне отрисовки. Обратите внимание, что иерархия наследования этих классов и иерархия отрисовки — это разные иерархии!
Иерархия отрисовки, в соответствии со сказанным выше, выглядит так:
- UseCaseDiagram — диаграмма целиком,
- DiagramActor — роль,
- Label — подпись роли,
- DiagramUseCase — вариант использования,
- DiagramGeneralization — наследование,
- DiagramAssociation — связь,
- DiagramDependency — зависимость,
- Label — подпись стереотипа зависимости.
- DiagramActor — роль,
Далее мы подробно опишем устройство классов DiagramObject и DiagramPanel и то, как их следует использовать.
Класс DiagramObject и его наследники
Структура данных
Класс DiagramObject устроен так, что внутри каждого из его экземпляров находится двусвязный список подчинённых отрисовщиков. Это достигается при помощи переменных previous, next, first и last, позволяющих ссылаться на соседние элементы в списках и иерархии. Когда объекты инстанцированы, получается примерно такая картина:
Эта, подобная простому двусвязному списку, структура данных хороша тем, что мы можем за время O(N) собрать нужную нам иерархию, а при необходимости — за время О(1) и модифицировать её, удалив заданный элемент или вставив новый в список после какого-либо заданного элемента. Доступ к элементам этой структуры нас интересует только последовательный, соответствующий обходу дерева в глубину, что достигается проходом по ссылкам. Движение по красным стрелкам соответствует обходу в прямую, а по синим стрелкам — обходу в обратную сторону.
Для добавления нового объекта во внутренний список DiagramObject служит метод addToQueue(DiagramObject subObj):
if (last!=null)) {
last.next = subObj;
subObj.previous = last;
} else {
first = subObj;
subObj.previous = null;
}
subObj.next = null;
subObj.parent = this;
last = subObj;
Чтобы собрать желаемую картину, остаётся лишь проинстанцировать нужное количество нужных отрисовщиков и объединить их в очереди в нужном порядке. В нашем примере большая часть этой работы происходит в конструкторе класса UseCaseDiagram:
DiagramActor a1 = new DiagramActor(70, 150, "Customer");
addToQueue(a1);
DiagramActor a2 = new DiagramActor(50, 350, "NFRC Customer");
addToQueue(a2);
DiagramActor a3 = new DiagramActor(600, 50, "Bank Employee");
addToQueue(a3);
…
DiagramUseCase uc1 = new DiagramUseCase(250, 50, "Open account");
addToQueue(uc1);
DiagramUseCase uc2 = new DiagramUseCase(250, 150, "Deposit funds");
addToQueue(uc2);
…
addToQueue(new DiagramAssociation(a1, uc1));
addToQueue(new DiagramAssociation(a1, uc2));
…
addToQueue(new DiagramDependency(uc2, uc5, DependencyStereotype.EXTEND));
addToQueue(new DiagramDependency(uc2, uc6, DependencyStereotype.INCLUDE));
…
addToQueue(new DiagramGeneralization(a2, a1));
В реальной жизни следует поступать, конечно, не так: вместо «зашивания в код» процесса создания объектов-отрисовщиков, в конструктор корневого класса вам необходимо будет передать модель данных вашей системы. Обходя уже в циклах объекты этой модели, вы будете создавать отрисовщики. Например, для каждого связанного с текущей диаграммой экземпляра класса Actor (соответствующего «роли» в вашей «модели документа UML») необходимо проинстанцировать объект класса-отрисовщика DiagramActor.
Удобно, когда отрисовщики хранят ссылки на соответствующие объекты модели. Передавать эти ссылки удобнее всего прямо в виде параметров конструкторов отрисовщиков. В нашем примере вместо них передаются мировые координаты объектов и их параметры, такие как название и стереотип.
Мировые и экранные координаты
Коль скоро мы применили термин «мировые координаты» — нужно уточнить, что это такое в нашем случае. «Мировые координаты» у нас — это координаты объектов диаграммы на «воображаемой миллиметровой бумаге», на которой умещается диаграмма целиком, которая имеет начало координат в левом верхнем углу и не подвергается никакому масштабированию. Мировые координаты совпадают с экранными, если масштаб картинки 1:1 и полосы прокрутки находятся в своих минимальных позициях. Мировая координата, в отличие от экранной, имеет не целочисленный тип, а принимает значение с плавающий точкой. Это нужно, чтобы не происходила пикселизация картинки при увеличении её масштабов. Например, хотя при масштабе 1:1 значение мировой координаты 0.3 не отличимо от нуля экранных пикселов, в масштабе 100:1 оно превращается уже в 30 экранных пикселов.
Рассчитывать и хранить модель диаграммы удобно именно в мировых координатах, т. к. они не зависят от таких сиюминутных действий пользователя, как изменение масштаба и прокрутки.
Для перевода мировых координат в экранные класс DiagramObject содержит важные методы scaleX(…), scaleY(…) и просто scale(…). Первые два применяют к мировой координате масштабный коэффициент и учитывают сдвиг горизонтальной и вертикальной полосы прокрутки, соответственно. Последний метод, scale(…), применяет масштабный коэффициент, но не учитывает сдвиг: он необходим для расчёта не позиции, а размера (например, ширины прямоугольника или радиуса окружности).
Отрисовка диаграммы с точки зрения DiagramObject. Самостоятельные, полу-самостоятельные и зависимые объекты
Для отрисовки диаграммы вызывается метод draw(Graphics canvas, double aDX, double aDY, double scale) корневого DiagramObject. Его параметрами являются:
- canvas — контекст рисования
- aDX, aDY — положения полос прокрутки
- scale — масштаб (1.0 — для масштаба 1:1, больше/меньше — для увеличения/уменьшения).
Этот метод реализует паттерн проектирования Template Method (шаблонный метод) и выглядит следующим образом:
this.canvas = canvas;
this.scale = scale;
dX = aDX;
dY = aDY;
saveCanvasSetup();
internalDraw(canvas);
restoreCanvasSetup();
DiagramObject curObj = first;
while (assigned(curObj)) {
curObj.draw(canvas, aDX, aDY, scale);
curObj = curObj.next;
}
Т. е. метод draw(…):
- запоминает в полях объекта параметры (они затем неоднократно используются разными методами),
- сохраняет с помощью saveCanvasSetup() все настройки контекста отрисовки (цвет, перья, размер шрифта и т. п.),
- вызывает метод internalDraw(), который на уровне DiagramObject не делает ничего, а в его наследниках переопределяется процедурой отрисовки объекта,
- восстанавливает с помощью restoreCanvasSetup() настройки, которые могли быть нарушены после выполнения internalDraw,
- пробегает по очереди всех своих подобъектов и вызывает метод draw для каждого из них.
Таким образом, инвариантная часть алгоритма реализована в методе draw(…), а изменяемая часть (собственно рисование) реализуется в классах-наследниках, что и составляет суть паттерна Template Method.
Предназначение методов saveCanvasSetup() и restoreCanvasSetup() — сохранить состояние контекста рисования, так чтобы каждый из объектов-отрисовщиков получил его в «нетронутом» виде. Если эти методы не применять, и в одном из наследников-отрисовщиков, допустим, цвет чернил изменить на красный, то всё, что будет нарисовано далее, будет нарисовано красным цветом. Реализация данных методов зависит от вашей среды разработки и возможностей, предоставляемых механизмом рисования. В Delphi и Java Swing, к примеру, надо сохранять множество параметров контекста, а в HTML Canvas2D специально для этой цели имеются готовые методы save() и restore(), сразу сохраняющие в специальный стек всё состояние контекста.
Вот как выглядит метод internalDraw в классе DiagramActor (сравните с «наивным примером», с которого мы начали):
static final double ACTOR_WIDTH = 25.0;
static final double ACTOR_HEIGHT = 35.0;
@Override
protected void internalDraw(Graphics canvas) {
double mX = getmX();
double mY = getmY();
canvas.drawOval(scaleX(mX + 10 - ACTOR_WIDTH / 2), scaleY(mY + 0 - ACTOR_HEIGHT / 2), scale(10), scale(10));
canvas.drawLine(scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 10 - ACTOR_HEIGHT / 2),
scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2));
canvas.drawLine(scaleX(mX + 5 - ACTOR_WIDTH / 2), scaleY(mY + 15 - ACTOR_HEIGHT / 2),
scaleX(mX + 25 - ACTOR_WIDTH / 2), scaleY(mY + 15 - ACTOR_HEIGHT / 2));
canvas.drawLine(scaleX(mX + 5 - ACTOR_WIDTH / 2), scaleY(mY + 35 - ACTOR_HEIGHT / 2),
scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2));
canvas.drawLine(scaleX(mX + 25 - ACTOR_WIDTH / 2), scaleY(mY + 35 - ACTOR_HEIGHT / 2),
scaleX(mX + 15 - ACTOR_WIDTH / 2), scaleY(mY + 25 - ACTOR_HEIGHT / 2));
}
В точке (mX, mY) находится середина объекта. Т. к. начало координат «наивного примера» находится в левом верхнем углу, их необходимо сместить на половину ширины и половину высоты объекта. «Наивный пример» не учитывал необходимость масштабирования и смещения картинки, мы же учитываем это, переводя мировые координаты в экранные при помощи методов scaleX(…), scaleY(…) и scale(…).
Обекты DiagramActor и DiagramUseCase полностью «самостоятельны», их позиции целиком определяются внутренним состоянием, хранимым в полях mX и mY. В то же время всевозможные соединительные стрелки собственного состояния не имеют — их позиция на экране полностью определена позициями объектов, которые они соединяют, они полностью «не самостоятельны», они проходят по прямой, соединяющей центры объектов:
И отдельно следует обратить внимание на подписи к объектам. В своём внутреннем состоянии они хранят не абсолютные координаты, а смещение относительно родительского объекта-отрисовщика, поэтому они ведут себя «полу-самостоятельно»:
Определение объекта под курсором мыши
Разобравшись с отрисовкой, переходим к вопросу о том, каким же образом диаграмма «понимает», на какой объект мы кликнули мышью. Оказывается, что задача определения объекта, находящегося под курсором мыши, очень похожа на задачу отрисовки и в определённом смысле симметрична ей.
Сначала заметим, что для каждого заданного объекта диаграммы не составляет труда написать метод, определяющий по координатам курсора мыши, находится ли курсор над этим объектом или нет.
Например, для DiagramActor речь идёт о попадании в прямоугольную область:
protected boolean internalTestHit(double x, double y) {
double dX = x - getmX();
double dY = y - getmY();
return dY > -ACTOR_HEIGHT / 2 && dY < ACTOR_HEIGHT / 2
&& dX > -ACTOR_WIDTH / 2 && dX < ACTOR_WIDTH / 2;
}
Для DiagramUseCase речь идёт о попадании в область, имеющую вид эллипса:
protected boolean internalTestHit(double x, double y) {
double dX = 2 * getScale() * (x - getmX()) / (width + 2 * MARGIN / getScale());
double dY = 2 * (y - getmY()) / HEIGHT;
return dX * dX + dY * dY <= 1;
}
Теперь, если мы хотим определить объект, над которым сейчас находится курсор, мы можем методом последовательного перебора вызывать internalTestHit для каждого из объектов диаграммы, и первый, вернувший true, окажется искомым объектом. Только делать это надо в порядке, обратном порядку отрисовки (движение по синим стрелкам на иллюстрации, показывающей структуру данных)! Если курсор мыши находится в области, на которой пересекается несколько объектов, именно поиск в обратном порядке обеспечит попадание курсором в объект, отрисованный позже других, т. е. визуально находящийся «над другими».
Вот как это реализовано в ещё одном шаблонном методе DiagramObject:
public final DiagramObject testHit(int x, int y) {
DiagramObject result;
DiagramObject curObj = last;
while (assigned(curObj)) {
result = curObj.testHit(x, y);
if (assigned(result))
return result;
curObj = curObj.previous;
}
if (internalTestHit(x / scale + dX, y / scale + dY))
result = this;
else {
result = null;
}
return result;
}
Объект DiagramPanel вызывает метод testHit корневого объекта отрисовки. Во время его выполнения происходит рекурсия, выполняющая обход дерева отрисовки в глубину в направлении, противоположном направлению отрисовки. Возвращается первый найденный объект: это и будет самый «верхний» с точки зрения пользователя объект, находящийся под курсором мыши.
Определение текущего контекста под курсором мыши
Объект, находящийся под курсором мыши, может оказаться лишь составной частью более крупного объекта и не иметь самостоятельного значения. Если мы хотим произвести некоторую операцию над объектом и кликнули мышью на его часть, то операцию всё равно нужно производить над родительским объектом. Правильно показать контекст можно при помощи делегирования — приёма, связанного с использованием паттерна Composite (см. на этот счёт книгу Design Patterns). В нашем примере мы применяем делегирование для получения всплывающей подсказки объекта: например, если пользователь наводит курсор мыши на подпись под Actor-ом, он получает ту же подсказку, что и при наведении курсора собственно на Actor-а.
Идея очень проста: метод getHint() класса DiagramObject выполняет следующее: если его собственная реализация метода internalGetHint() в состоянии вернуть строку-подсказку — то она же и возвращается. Если не в состоянии, то идёт обращение к родительскому (в иерархии отрисовки) объекту — не может ли он выполнить работу метода getHint(). В случае, если и он «не берётся», «передача ответственности» будет продолжаться до самого корневого объекта-отрисовщика. Помимо механизма делегирования, мы вновь применяем паттерн Template Method:
public String getHint() {
StringBuilder hintStr = new StringBuilder();
if (internalGetHint(hintStr))
return hintStr.toString();
else if (assigned(parent))
return parent.getHint();
else {
return "";
}
}
protected boolean internalGetHint(StringBuilder hintStr) {
return false;
}
Вспомогательные методы DiagramObject
Наследники DiagramObject могут переопределить следующие методы — их использование в классе DiagramPanel станет понятно из дальнейшего:
- boolean isCollectable() — можно ли будет захватить объект с помощью «лассо» (прямоугольного выделения). Используется механизмами DiagramPanel, о которых речь пойдёт далее
- boolean isMoveable() — является ли объект перемещаемым с помощью Drag and Drop. В нашем примере узлы диаграммы (Actor и UseCase) являются перемещаемыми и захватываемыми при помощи лассо, а соединительные линии (Association, Generalization, Dependency) таковыми не являются.
- double getMinX(), getMinY(), getMaxX(), getMaxY() — мировые координаты самой левой, самой верхней, самой правой и самой нижней точки объекта. Нужны, во-первых, для корректной работы прямоугольного выделения (чтобы выделить объект, нужно захватить его целиком), а во-вторых, они используются в дефолтной реализации метода internalDrawSelection(), чтобы нарисовать выделение объекта по его углам.
- final int minX(), minY(), maxX(), maxY() — то же самое, но уже переведённое в экранные координаты (не переопределяемые методы).
Отрисовка выделения
И наконец, ещё одна важная функциональность, реализованная на уровне DiagramObject, которая может быть переопределена в его наследниках — отрисовка выделения, т. е. графической метки, по которой пользователь может понять, что объект находится в выделенном состоянии. По умолчанию это четыре синие квадратные точки по углам объекта:
private static final int L = 4;
protected void internalDrawSelection(Graphics canvas, int dX, int dY) {
canvas.setColor(Color.BLUE);
canvas.setXORMode(Color.WHITE);
canvas.fillRect(minX() + dX - L, minY() + dY - L, L, L);
canvas.fillRect(maxX() + dX, minY() + dY - L, L, L);
canvas.fillRect(minX() + dX - L, maxY() + dY, L, L);
canvas.fillRect(maxX() + dX, maxY() + dY, L, L);
canvas.setPaintMode();
}
Обратите внимание на целочисленные (а значит, в экранных координатах) параметры dX, dY и на вызов setXORMode(), переключающий контекст отрисовки в «XOR-режим»: в этом режиме для того, чтобы стереть ранее нарисованное изображение, достаточно прорисовать его ещё раз. Это нужно для того, чтобы реализовать Drag&Drop для объектов диаграммы: для простоты, мы «перетаскиваем» мышью не само изображение, а его выделение, и затем уже перебрасываем изображение на новое место, при этом в параметрах dX, dY будет передано смещение объекта в экранных координатах относительно исходного положения:
Если такое поведение системы не устраивает, то можно переопределить метод internalDrawSelection в наследниках класса DiagramObject, чтобы рисовать в качестве выделения (и передвигать при drag&drop) что-нибудь более сложное.
* * *
Это всё, что касается класса DrawObject. Во второй части статьи будет рассмотрено построение класса DiagramPanel, отвечающего за обработку событий мыши и масштабирование, панорамирование, выделение объектов и drag&drop. Полный исходный код нашего примера, напоминаю, доступен по адресу https://github.com/inponomarev/graphexample и может быть скомпилирован при помощи Maven.