Реалізація інтерактивних діаграм за допомогою ООП на прикладі прототипу редактора UML-діаграм. Частина 2

Це — закінчення попередній публікації, в якій я розповідав, як з нуля створювати інтерфейс з інтерактивною графікою в об'єктно-орієнтованої середовищі розробки. У першій частині ми розглянули ключові архітектурні ідеї цієї системи: використання композиції, делегування і шаблонних методів.

Зараз я переходжу до більш рутинним питань і розгляну побудова класу, що відповідає за обробку подій миші та масштабування, панорамування, виділення об'єктів і drag&drop.



Клас DiagramPanel
Візуальні компоненти
Вихідний код класу DiagramPanel, на відміну від DiagramObject, не містить в собі ніяких нетривіальних структурних рішень, він лише виконує рутинні завдання. Однак його код при цьому приблизно на 30% довше коду DiagramObject. Саме в ньому міститься вся специфіка, пов'язана із середовищем розробки: якщо ви захочете переписати фреймворк з Java в інше середовище (як у свій час ми це зробили, перевівши його з Delphi в Java), саме з DiagramObject будуть пов'язані основні труднощі.

У разі роботи з Swing клас DiagramPanel успадковується від класу javax.swing.JPanel і являє собою візуальний компонент інтерфейсу, який може бути поміщений на форму додатка. У нашого демо-додатки складається тільки з однієї цієї панелі. Структурно DiagramPanel складається з:

  • горизонтальної і вертикальної смуги прокрутки javax.swing.JScrollBar (змінні hsb і vsb),
  • верхньої панелі з екранними кнопками,
  • заповнює всі центральний простір області «для малювання», зайнятої об'єктом DiagramPanel.DiagramCanvas, спадкоємцем java.awt.Canvas. Клас java.awt.Canvas передбачає створення спадкоємця з переопределенным методом paint(Graphics g), отримують контекст для малювання (Графіка) в якості аргументу, що ми і робимо в класі DiagramCanvas, вкладеному в DiagramPanel. Метод paint(Graphics g) автоматично викликається, зокрема, при зміні розмірів вікна і зміни перекриття вікон, тому діаграма не «псується» від цих дій.


Побудова діаграми з точки зору DiagramPanel

Подивимося пильніше на код методу paint(Graphics g) класу DiagramPanel.DiagramCanvas. Якщо опустити деякі деталі, він виглядає так: джерело
private static final double SCROLL_FACTOR = 10.0;
public void paint(Graphics g) {
// not ready for painting yet
if (rootDiagramObject == null || g == null)
return;

double worldHeight = rootDiagramObject.getMaxY() - rootDiagramObject.getMinY();
double worldWidth = rootDiagramObject.getMaxX() - rootDiagramObject.getMinX();
int hPageSize = round(canvas.getWidth() / scale);

...
int vPageSize = round(canvas.getHeight() / scale);
...

hsb.setMaximum(round(worldWidth * SCROLL_FACTOR));
vsb.setMaximum(round(worldHeight * SCROLL_FACTOR));
g.clearRect(0, 0, getWidth(), getHeight());
double dX = hsb.getValue() / SCROLL_FACTOR;
double dY = vsb.getValue() / SCROLL_FACTOR;
rootDiagramObject.draw(g, dX, dY, scale);
}


(Код методу спрощено для кращої читаності. Реальну реалізацію, коректно враховує всі крайні випадки, можна побачити в повних основи прикладу статті.)



Метод виконує наступне:

  • За допомогою методів getMaxX/Y кореневого об'єкта-отрисовщика отримує ширину і висоту діаграми у світових координатах.
  • Отримує розмір видимої області (ширину і висоту) в світових координатах. Для цього екранна ширина і висота компонента діляться на коефіцієнт масштабування (щоб отримати екранну координату з світової, ми на коефіцієнт масштабування множимо).
  • Виставляються максимальні значення ширини та бігунків смуг прокручування, рівні відповідним значенням у світових координатах, помножених на коефіцієнт SCROLL_FACTOR.


Для зручності роботи користувача дуже важливо, щоб ширини бігунків смуг прокручування при зміні масштабу зображення (і розмірів видимої області) змінювалися б коректно. На цій анімації зверніть увагу на мінливі розміри бігунків смуг прокручування та їх зникнення в момент, коли картинка починає уміщатися цілком:



Множення на SCROLL_FACTOR необхідно тому, що значення, що приймаються смугою прокрутки, є цілочисельними, а світові координати, як ми пам'ятаємо, мають тип double. Відповідно, при великому масштабі збільшення «дискретність» переміщення смуг прокручування може стати надто помітною і за допомогою множника SCROLL_FACTOR ми, по суті, підміняємо їх цілочисельне значення на значення з фіксованою точкою.

Оскільки метод перемальовування діаграми коректно враховує поточний масштаб і положення смуг прокручування,
  1. обробка зміни значень смуг прокручування зводиться лише до викликом canvas.paint(canvas.getGraphics()) (див. метод scrollBarChange() класу DiagramPanel),
  2. обробка подій колеса миші (без натиснутій клавіші Ctrl) зводиться до
    1. визначення того, до якої з смуг прокручування ближче вказівник миші,
    2. модифікації положення відповідної смуги прокрутки,

    3. викликом canvas.paint(canvas.getGraphics()) (див. метод canvasMouseWheel(MouseWheelEvent) класу DiagramPanel).


Масштаб діаграми із збереженням заданої нерухомої точки
Змінити масштаб діаграми в нашому прикладі користувач може двома способами: натискаючи на екранні кнопки «Zoom in» і «Zoom out» і за допомогою комбінації Ctrl+коліщатко миші. У будь-якому разі це призводить до виклику методу setScale(double s) класу DiagramPanel. Але для того, щоб це було зручно для користувача, необхідні деякі хитрощі.

Насамперед звернемо увагу на те, які значення має приймати масштаб. Наш давній досвід показує, що найбільш зручним для користувача поведінкою діаграми є подвоєння масштабу за два натискання на кнопку «Zoom in». Це означає, що натискання кнопок «Zoom in»/«Zoom out» повинні множити/ділити поточне значення масштабу на квадратний корінь з двох (1.41).

Якщо ж користувачеві пропонуються значення масштабів зі списку, для візуальної рівномірності збільшення/зменшення масштабу вони повинні бути обрані з ряду ступенів квадратного кореня з двох: 50%, 70%, 100%, 140%, 200%.

Несподіваним на перший погляд може здатися код обробника події Ctrl+коліщатко миші»:
private static final double WHEEL_FACTOR = 2.8854; // 1/ln(sqrt(2))
setScale(scale * Math.exp(-e.getPreciseWheelRotation() / WHEEL_FACTOR)); 

Здавалося б, навіщо тут експонента? Обробник обертання колеса миші отримує кількість зроблених користувачем «кліків» колеса (в деяких випадках видаються навіть дробові частини «кліка»), і загальне правило те ж: за два кліка картинка повинна збільшуватися вдвічі. Але це означає, що значення масштабу повинні в залежності від кута повороту колеса змінюватися як ступінь квадратного кореня з двох:

поворот колеса -2 -1 0 1 2
зміна масштабу 0.5 0.7 1.0 1.4 2.0


Проста арифметика зводить ці обчислення до експоненті.

Звернемося тепер до методу setScale(double s) класу DiagramPanel:
джерело
if (s < 0.05 || s > 100 || s == scale)
return;
Point p = MouseInfo.getPointerInfo().getLocation();
SwingUtilities.convertPointFromScreen(p, canvas);

double xQuot;
double yQuot;
if (p.x > 0 && p.y > 0 && p.x < canvas.getWidth() && p.y < canvas.getHeight()) {
xQuot = p.getX() / (double) canvas.getWidth();
yQuot = p.getY() / (double) canvas.getHeight();
} else {
xQuot = 0.5;
yQuot = 0.5;
}
int newHVal = hsb.getValue() + round(hsb.getVisibleAmount() * xQuot * (1 - scale / s));
int newVVal = vsb.getValue() + round(vsb.getVisibleAmount() * yQuot * (1 - scale / s));

hsb.setValue(newHVal);
vsb.setValue(newVVal);
scale = s;
canvas.paint(canvas.getGraphics());


Ми бачимо, що метод, попередньо перевіривши значення нового масштабу на «розумність»,

  • отримує позицію курсору миші,
  • якщо курсор знаходиться над об'єктом Canvas, то обчислює його відносну позицію, в іншому випадку, відносної позицією вважається середина Canvas,
  • готує нові позиції смуг прокручування,
  • і тільки потім (!) змінює масштаб і перемальовує діаграму.


До чого ці складності? Все це робиться для того, щоб під час масштабування для користувача візуально залишилася нерухомою та точка на діаграмі, на якій в поточний момент часу сфокусований його погляд. Якщо цього не робити, то користувачеві доведеться «підкручувати» смуги прокручування після кожної зміни масштабу. Якщо користувач змінює масштаб за допомогою екранних кнопок, то залишається нерухомим центр видимій області діаграми, якщо ж користувач робить це за допомогою колеса миші, то нерухомою буде точка, над якою розташований курсор. Виглядає це так:



Режим панорамування
Наш приклад може працювати в двох режимах: панорамування (panning mode) і виділення об'єктів, перемикаються екранними кнопками. В режимі панорами рух мишею з натиснутою лівою кнопкою призводить до переміщення цілком всій видимій області діаграми. Найбільш широко відомий приклад режиму панорамування — це, звичайно, відповідний режим перегляду PDF-файлів в Adobe Acrobat Reader.

Рух мишею з натиснутою лівою кнопкою обробляється в методі canvasMouseDragged, і в режимі панорами нам достатньо корегувати положення смуг прокручування щодо первісної позиції курсору миші:

if (panningMode) {
hsb.setValue(round((startPoint.x - cursorPos.x / scale) * SCROLL_FACTOR));
vsb.setValue(round((startPoint.y - cursorPos.y / scale) * SCROLL_FACTOR));
}


Вже реалізована машинерія буде перемальовувати зображення коректним чином:



Режим виділення об'єктів
Логіка, пов'язана з механізмом виділення об'єктів, стоїть дещо окремо, тому для зручності вона виділена під вкладений клас SelectionManager класу DiagramPanel. У своєму полі ArrayList items цей клас зберігає всі поточні виділені об'єкти. Він відповідає за «нащелкивание» об'єктів з клавішею Shift, їх виділення з допомогою «ласо» і перетягування. Все це — досить складна як для опису, так і для реалізації функціональність. Однак несподівано швидко розібратися в ній і все реалізувати допомагає концепція кінцевого автомата. (Кінцевий автомат хоча і не входить в перелік патернів проектування GoF і застосовний лише для обмеженого класу задач, його зручність і міць для спрощення деяких завдань змушують мене ставитися до нього як до ще одного, дуже корисного і стандартизованому паттерну проектування.)

З точки зору механізму виділення об'єктів, всяке рух курсору миші над діаграмою може відбуватися в одному з трьох станів автомата, відповідних елементам перерахування DiagramPanel.State:
  1. Ліва клавіша миші не натиснута — початковий стан (SELECTING). Виділення декількох об'єктів з натиснутою клавішею Shift.
  2. Ліва клавіша миші натиснута — два подслучая:
    1. переміщається група виділених об'єктів (DRAGGING).
    2. отрісовиваємих прямокутне «ласо» (LASSO).



При реалізації класу SelectionManager я накидав на папері приблизно таку схему станів і переходів:



Втілити цю схему в код можна так само, як ми реалізуємо всякий кінцевий автомат.

Для відтворення прямокутного ласо застосовується ще один спадкоємець DiagramObject – клас DiagramPanel.Lasso. На відміну від інших отрисовщиков, він не належить діаграмі і не малюється разом з нею, а створюється класом DiagramPanel в момент, коли треба намалювати виділяє прямокутник. Він повинен «встигати» за курсором миші і тому виводиться в методі internalDrawSelection з використанням XOR-режиму графічного контексту.



Слід пам'ятати, що малюється прямокутник від свого верхнього лівого кута, а початкова точка «ласо» може виявитися в будь-якому куті (див. анімацію), тому для малювання прямокутника потрібна акуратність, спочатку потрібно визначити ЛВУ:

int x0 = dX > 0 ? startPoint.x : startPoint.x + dX;
int y0 = dY > 0 ? startPoint.y : startPoint.y + dY;
g2.drawRect(x0, y0, Math.abs(dX), Math.abs(dY));


Групове зміщення об'єктів. Взаємодія з Undo

Завершивши переміщення групи об'єктів, користувач відпускає ліву кнопку миші. Що відбувається в системі? По ідеї, достатньо пробігтися по об'єктах, які потрапили у виділення, і «сказати їм, що користувач пересунув. Однак не все так тривіально:
if (commandManager != null)
commandManager.beginMacro("drag & drop");
for (DiagramObject i : items) {
DiagramObject curItem = i.getParent();
while (curItem != null && !items.contains(curItem)) {
curItem = curItem.getParent();
}
if (curItem == null)
i.drop(dX, dY);
}
if (commandManager != null)
commandManager.endMacro();

Як ми пам'ятаємо, об'єкти у нас бувають «самостійні» і «напів-самостійні». Якщо виділення потрапив одночасно батьківський і залежний від нього підлозі-самостійний об'єкт (наприклад, Actor і підпис до нього), то пересунути достатньо лише батьківський об'єкт: залежить від нього «піде за ним слідом».

Саме тому в циклі ми виключаємо залежні об'єкти, якщо вони потрапили до виділення разом з батьківськими, і тільки для відповідних об'єктів викликаємо метод drop(dX, dY), передає зміщення об'єкта в екранних координатах в сам об'єкт. DiagramObject перераховує «екранні» зміщення «світові» і викликає свій віртуальний метод internalDrop, реалізація якого на рівні спадкоємців DiagramObject повинна відпрацьовувати подія «перетягування миші», змінюючи внутрішній стан об'єктів моделі даних.

А для чого потрібні виклики commandManager.beginMacro/endMacro?

Якщо в системі реалізована функціональність undo/redo, то групове перетягування об'єктів необхідно позначити як дію. Якщо цього не зробити і користувач побажає скасувати свою операцію перетягування, йому доведеться стільки разів натиснути на кнопку undo(), скільки об'єктів було за один раз передвинуто. Детальніше про все це можна прочитати у моїй статті про реалізацію undo і redo.

Висновок
Отже, наш приклад готовий. При відносно невеликій кількості коду ми отримали цілком робочий рішення, з усіма зручностями для користувача, який бажає переглядати і редагувати інтерактивну діаграму, і з повним заділом для вбудовування в даний додаток, вирішувати реальні завдання.

Шкода, що часи повального захоплення CASE-засобами давно минули: з такими вміннями ми могли б створити небезпечну альтернативу для якого-небудь Rational Rose :-)

Завантажити повний вихідний код розглядається в статті прикладу у форматі проекту Maven можна за адресою https://github.com/inponomarev/graphexample.

За допомогою цього фреймворку в різні роки ми будували: портфельні матриці, діаграми Ганта, діаграми зв'язків між юридичними особами, азимутально-частотні діаграми для радіотехнічного обладнання, діаграми зв'язків між установками.

Зверніть увагу, що для використання в інших проектах цей код доступний під ліцензією GPL.

Автор вдячний творцям системи ShareX, за допомогою якої були створені анімовані GIF-зображення для статті.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.