📜 ⬆️ ⬇️

The implementation of interactive diagrams using OOP on the example of the prototype editor of UML diagrams. Part 2

This is the end of a previous publication , in which I told you how to create an interface with interactive graphics from scratch in an object-oriented development environment. In the first part, we looked at the key architectural ideas of this system: the use of composition, delegation, and template methods.

Now I turn to more routine questions and consider building a class responsible for handling mouse events and scaling, panning, selecting objects and drag & drop.


')

Class DiagramPanel


Visual components


The source code of the DiagramPanel class, in contrast to the DiagramObject, does not contain any non-trivial structural solutions, it only performs routine tasks. However, its code is about 30% longer than the DiagramObject code. It contains all the specifics associated with the development environment: if you want to rewrite the framework from Java to another environment (as we did at one time, transferring it from Delphi to Java), the main difficulties will be associated with DiagramObject.

When working with Swing, the DiagramPanel class is inherited from the javax.swing.JPanel class and is a visual component of the interface that can be placed on the application form. Our demo application consists of only this one panel. Structurally, DiagramPanel consists of:



Diagram drawing from the point of view of DiagramPanel



Let's take a closer look at the code of the paint (Graphics g) method of the DiagramPanel.DiagramCanvas class. If you omit some details, it looks like this:
source code
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); } 


(The code of the method is simplified for better readability. A real implementation that correctly takes into account all extreme cases can be seen in the full source code of the sample article.)



The method does the following:



For the convenience of the user, it is very important that the widths of the scrollbar sliders change correctly when the image is scaled (and the size of the visible area). In this animation, pay attention to the changing size of the scrollbar sliders and their disappearance at the moment when the picture begins to fit entirely:



Multiplication by SCROLL_FACTOR is necessary because the values ​​taken by the scroll bar are integer, and the world coordinates, as we remember, are of type double. Accordingly, with a large scale of increase, the “discreteness” of moving the scroll bars may become too noticeable and with the help of the SCROLL_FACTOR multiplier, we essentially substitute their integer value with a fixed point value.

Since the chart redrawing method correctly takes into account the current scale and position of the scroll bars,
  1. processing the change of scrollbar values ​​is reduced only to calling canvas.paint (canvas.getGraphics ()) (see DiagramPanel class scrollBarChange () method)
  2. Mouse wheel event handling (without pressing the Ctrl key) is reduced to
    1. determining which of the scroll bars the mouse pointer is closer to,
    2. modification of the position of the corresponding scroll bar,
    3. call canvas.paint (canvas.getGraphics ()) (see the DiagramPanel class's canvasMouseWheel (MouseWheelEvent) method).


Scale the chart while maintaining the specified fixed point


The user can change the scale of the diagram in our example in two ways: by pressing the “Zoom in” and “Zoom out” buttons on the screen and using the Ctrl + mouse wheel combination. In any case, this leads to a call to the setScale method (double s) of the DiagramPanel class. But in order for it to look comfortable for the user, some tricks are needed.

First we will pay attention to what values ​​the scale should take. Our long-standing experience shows that the most user-friendly chart behavior is doubling the scale with two presses of the “Zoom in” button. This means that pressing the “Zoom in” / ”Zoom out” buttons should multiply / divide the current scale value by the square root of two (1.41).

If the user is offered scale values ​​from the list, for visual uniformity of zooming in / out, they should be selected from a number of degrees of the square root of two: 50%, 70%, 100%, 140%, 200%.

At first glance, the “Ctrl + mouse wheel” event handler code might appear unexpected:
 private static final double WHEEL_FACTOR = 2.8854; // 1/ln(sqrt(2)) setScale(scale * Math.exp(-e.getPreciseWheelRotation() / WHEEL_FACTOR)); 

It would seem, why is there an exhibitor? The mouse wheel handler receives the number of wheel clicks made by the user (in some cases even the fractional parts of a click are issued), and the general rule is the same: for two clicks, the picture should be doubled. But this just means that the scale values ​​should depend on the angle of rotation of the wheel as the degree of the square root of two:

wheel turn-2-one0one2
zoom0.50.71.01.42.0


Simple arithmetic reduces these calculations to exponential.

We now turn to the DiagramPanel class's setScale (double s) method:
source code
  if (s < 0.05 || s > 100 || s == scale) return; Point p = MouseInfo.getPointerInfo().getLocation(); SwingUtilities.convertPointFromScreen(p, canvas); double xQuot; double yQuot; if (px > 0 && py > 0 && px < canvas.getWidth() && py < 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()); 


We see that the method, having previously checked the value of the new scale for rationality,



Why these difficulties? All this is done to ensure that during scaling the user visually remains fixed that point on the diagram on which his current view is focused. If you do not do this, then the user will have to “twist” the scroll bars after each change of scale. If the user changes the scale using the on-screen buttons, the center of the visible area of ​​the diagram remains fixed, but if the user does this using the mouse wheel, the point above which the cursor is located will be fixed. It looks like this:



Pan mode


Our example can work in two modes: panning (panning mode) and selecting objects that are switched by on-screen buttons. In the pan mode, moving the mouse while holding down the left button moves the entire visible area of ​​the diagram. The most widely known example of panning mode is, of course, the corresponding mode of viewing PDF files in Adobe Acrobat Reader.

Moving the mouse with the left button pressed is processed in the canvasMouseDragged method, and in panning mode it is enough for us to adjust the position of the scroll bars relative to the initial position of the mouse cursor:

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


Already implemented machinery will redraw the image correctly:



Object selection mode


The logic associated with the object selection mechanism is somewhat separate, so for convenience, it is highlighted in the DiagramPanel class nested SelectionManager class. In its ArrayList items field, this class stores all currently selected objects. He is responsible for “clicking” objects with the Shift key, selecting them with a “lasso”, and dragging. All this is quite complicated for both the description and the implementation of the functionality. However, unexpectedly quickly understand it and help realize the concept of a finite state machine . (Although the finite state machine is not included in the list of GoF design patterns and is applicable only for a limited class of tasks, its convenience and power to simplify some tasks make me treat it as another very useful and standardized design pattern.)

From the point of view of the mechanism for selecting objects, any movement of the mouse cursor over a diagram can occur in one of three states of the automaton corresponding to the elements of the DiagramPanel.State enumeration:
  1. Left mouse button is not pressed - the initial state (SELECTING). Selection of several objects with the Shift key pressed.
  2. The left mouse button is pressed - two subcases:
    1. moves group of selected objects (DRAGGING).
    2. a rectangular lasso (LASSO) is drawn.



When implementing the SelectionManager class, I sketched on paper some such state and transition scheme:



You can embody this scheme in the code just as we implement any state machine.

To draw a rectangular lasso, another heir of DiagramObject is used - the class DiagramPanel.Lasso. Unlike other renderers, it does not belong to the diagram and is not drawn with it, but is created by the DiagramPanel class at the moment when it is necessary to draw the selection rectangle. It must “catch up” with the mouse cursor and, therefore, is displayed in the internalDrawSelection method using the XOR mode of the graphics context.



It should be remembered that the rectangle is drawn from its upper left corner, and the starting point of the “lasso” can be in any corner (see animation), therefore, to draw this rectangle, you need to be careful, you must first define the HL:

  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)); 


Group offset of objects. Interaction with Undo



Having finished moving the group of objects, the user releases the left mouse button. What happens in the system? In theory, it is enough to run through the objects that fall into the selection, and "tell" them that the user has moved them. However, not everything is so trivial:
  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(); 

As we remember, the objects we have are "independent" and "semi-independent." If a parent object and a semi-independent object dependent on it (for example, Actor and a signature to it) hit the selection, then only the parent object needs to be moved: the dependent object "will follow it".

That is why in the cycle we exclude dependent objects if they fall into the selection along with the parent objects, and only for suitable objects we call the drop method (dX, dY), passing the object displacement in screen coordinates to the object itself. DiagramObject recalculates the “screen” offsets into the “world” ones and calls its internalDrop virtual method, whose implementation at the level of the DiagramObject heirs should work out the mouse dragging event, changing the internal state of the data model objects.

And why do we need commandManager.beginMacro / endMacro calls?

If the system implements undo / redo functionality, then group dragging of objects must be designated as a macro command. If this is not done and the user wishes to cancel his drag and drop operation, he will have to click on the undo () button as many times as he has moved objects at a time. You can read more about all this in my article about the implementation of undo and redo .

Conclusion


So, our example is ready. With a relatively small amount of code, we got a completely working solution, with all the amenities for the user who wants to view and edit an interactive diagram, and with a full foundation for embedding in this application that solves real problems.

It is a pity that the times of craze for CASE-tools have long passed: with such skills we could create a dangerous alternative for some Rational Rose :-)

Download the full source code of the example in question in the format of the Maven project at https://github.com/inponomarev/graphexample .

With the help of this framework, in different years we built: portfolio matrices, Gantt charts, diagrams of relations between legal entities, azimuth-frequency diagrams for radio equipment, diagrams of relations between installations.

Please note that for use in other projects, this code is available only under the GPL license.

The author is grateful to the creators of the ShareX system, with the help of which the animated GIF illustrations for the article were created.

Source: https://habr.com/ru/post/303800/


All Articles