Implementation of interactive diagrams using OOP using the prototype of the UML diagram editor as an example. Part 1

    Software developers have to deal with the need to create two-dimensional interactive graphic components quite often. Programmers, previously accustomed to working only with data processing algorithms, encounter great difficulties when such tasks arise, unless you can do with some completely primitive solution, such as a static picture with predetermined “active” areas. The non-standard nature of the task frightens away many people and forces them to look for ready-made tools and libraries for drawing graphs. But no matter how multi-functional the library is, there will be something missing in it to solve your particular task.

    In this article, we will examine in detail the creation of “from scratch” components with interactive, “draggable” elements in an object-oriented development environment. As an example, we will build a prototype UML editor.


    Formulation of the problem


    Basic solution requirements


    The list of tasks requiring the implementation of interactive graphics is quite extensive. It can be
    • conditional diagrams of equipment (installations, conveyors, transport systems), on which the status of the nodes is displayed, “at the click of the mouse” the user wants to receive additional information or enter control commands,
    • analytical charts (histograms, bubble charts), "on the click of a mouse" on the elements of which the user wants to receive information, or the user wants to directly "drag" the elements with the mouse, changing the picture in the right direction to find out the necessary numerical indicators,
    • visual analysis of the data presented in the form of graphs (for example: the relationship between legal entities in the database), the user wants to “drag” the graph elements manually, so as to finally insert the resulting picture into the printed report,
    • all means of visual modeling / designing of any of the blocks, in particular, all CASE-tools,

    - and so on and so forth. Although the appearance of all these diagrams is different, in all cases some general requirements need to be implemented. Here they are:
    • The picture should consist of discrete elements of various graphic complexity,
    • The image should be scalable and scrollable, i.e., the user should be able to see more closely any of the fragments of the diagram, using the change of scale and scroll bar,
    • Some of the elements of the picture must be “clickable”, that is, the system at every moment must “understand” exactly which element the mouse pointer is pointing at, be able to show tooltips for them,

    • Some of the “clickable” elements must be “selectable”, that is, the user must be able to “put the selection” on the element with a mouse click, the selection of the group of elements with the Shift key and using the “rectangular lasso” should be available. With selected objects, depending on the task, some actions or change of properties can be performed.
    • Some of the “clickable” elements must be “draggable”, that is, the user must be able to drag one element or a group of selected elements with the mouse:


    The importance of the ability to “drag and drop” elements should be emphasized. If your task includes the need to visualize graphs, you need to remember: none of the many algorithms for automatically arranging graph nodes on a plane can give a solution that is completely satisfactory in all cases, and for the convenience of the user, “manual” rearrangement of graph nodes is simply necessary.
    What “ingredients” are needed to prepare this “dish”? In this article, we will show the general principles that can be applied in any development environment if all four key conditions are met :
    1. Object oriented programming language.
    2. Accessibility of the canvas object (Canvas), with the ability to draw graphic primitives (lines, arcs, polygons, etc.).
    3. Components that implement controlled scrollbars.
    4. Mouse event handling accessibility.

    Our illustrative example is a prototype of the UML Use Case diagram editor; we will use the beautiful diagram from this guide . The source code for our example is available at https://github.com/inponomarev/graphexample and can be compiled using Maven. If you want to better understand the principles set forth in the article, I highly recommend downloading these sources and studying them with the article.

    The example is built on Java 8 with the standard Swing library. However, there is nothing Java-specific in the stated principles. We first implemented the principles outlined here in Delphi (Windows applications), and then in the Google Web Toolkit (web applications with graphics output to HTML Canvas). When the four above conditions are met, the proposed example can be converted into another development environment.

    Difficulties of the “naive” approach


    In general, to draw some kind of diagram on the screen using the methods of displaying graphic primitives is a difficult task. Stick, stick, cucumber (from a similar exercise in the BASIC language, once upon a time I first became acquainted with programming):
    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);
    


    But so far our “little man” has not “come to life”: it cannot be allocated, scaled, moved around the canvas, he does not know how to interact with the society of other “little men”. So, you need to write the code responsible for all these operations. For a simple picture, this seems uncomplicated, however, as the complexity of what we want to get, problems await us.
    • As the picture becomes more complicated, the length of the “drawing procedure” grows. For a complex scheme, the procedure becomes very long and confusing.
    • The code that draws the picture itself “by magic” does not specify a criterion by which it would be possible to determine the object that is currently selected by the mouse cursor. We must write a separate procedure that defines the object above which the mouse cursor is located, and at the same time we must constantly synchronize the code of the rendering procedure and the object recognition procedure.
    • Along with the complexity of the rendering procedure, the complexity of the recognition procedure is growing.

    An attempt to solve the problem “head-on” by complicating the procedures is doomed to failure due to the quick complication of the source code, the amount of which will grow in an avalanche-like manner as the diagram becomes more complicated. However, the use of object-oriented development, the universal principle of “divide and conquer”, as well as design patterns give us powerful tools to gracefully deal with these problems and implement the necessary functionality.

    So, we are starting the decision.

    Decomposition of the task. Class structure


    Let's start by breaking the task down into small parts by constructing a hierarchical list of what we need to draw.
    First, we draw the picture that we want to get on the board or on paper:



    And we will build the following hierarchy:
    • Whole chart
      • Roles (Actors)
        • Role signatures
      • Use Cases
      • Inheritance (generalizations)
      • Associations
      • Dependencies
        • Signatures of dependency stereotypes



    Our example, in reality, is very simple, so the hierarchy turned out to be shallow. The more complex the picture, the wider and deeper the hierarchy will be.

    Note that some items are in italics . These are the objects on the diagram that we want to make selectable and moveable with the mouse cursor.

    Each of the items in this hierarchy will be associated with a drawing class, and the hierarchical relationship between them allows you to apply the Composite pattern - the “layout”, which (I quote the book “Design Patterns” ) “composes objects into tree structures to represent part-whole hierarchies, allows you to ... uniformly interpret individual and composite objects. " That is, it does exactly what we need.

    On the class diagram, our system has the following form:


    At the top of the class diagram there are two classes (DiagramPanel and DiagramObject) that “know nothing” about the specifics of the diagram being drawn and form a framework based on which diagrams of various types can be made. DiagramPanel (in our case, this is the heir to the javax.swing.JPanel class) is a visual interface component responsible for displaying the diagram and its interaction with the user. The DiagramPanel object contains a link to the DiagramObject, the root drawing object corresponding to the highest level of the rendering hierarchy (in our case, this will be an instance of the UseCaseDiagram class).

    DiagramObject is the base class of all drawing objects that implements their hierarchy through the Composite pattern and much more, which will be discussed later.

    At the bottom is an example of using the framework. The Example class (successor to javax.swing.JFrame) is the main application window, which in our example contains an instance of DiagramPanel as one single component. All other classes are descendants of DiagramObject. They correspond to the tasks in the hierarchical list of rendering. Note that the inheritance hierarchy of these classes and the drawing hierarchy are different hierarchies!

    The drawing hierarchy, as described above, looks like this:

    • UseCaseDiagram - the whole diagram,
      • DiagramActor - role,
        • Label - role signature
      • DiagramUseCase - use case,
      • DiagramGeneralization - inheritance,
      • DiagramAssociation - communication,
      • DiagramDependency - dependency,
        • Label is the signature of the dependency stereotype.



    Next, we describe in detail the structure of the DiagramObject and DiagramPanel classes and how they should be used.

    DiagramObject class and its descendants


    Data structure


    The DiagramObject class is designed so that inside each of its instances there is a doubly linked list of subordinate renderers. This is achieved using the variables previous, next, first, and last, which allow you to refer to neighboring elements in lists and hierarchies. When objects are instantiated, you get something like this:



    This data structure, similar to a simple doubly linked list, is good because we can collect the hierarchy we need in O (N) time, and, if necessary, in O (1) time and modify it by deleting the given element or inserting a new one in the list after which any given item. Access to the elements of this structure we are interested in only sequential, corresponding to a tree traversal in depth, which is achieved by clicking on the links. Movement along the red arrows corresponds to going around in a straight line, and along blue arrows - going around in the opposite direction.

    To add a new object to the internal DiagramObject list, use the addToQueue (DiagramObject subObj) method:
    if (last!=null)) {
    	last.next = subObj;
    	subObj.previous = last;
    } else {
    	first = subObj;
    	subObj.previous = null;
    }
    subObj.next = null;
    subObj.parent = this;
    last = subObj;
    


    To collect the desired picture, all that remains is to instantiate the right amount of the right drawers and combine them in the queue in the right order. In our example, most of this work happens in the constructor of the UseCaseDiagram class:
    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));
    


    In real life, you should, of course, not do this: instead of “stitching into code” the process of creating drawing objects, you will need to pass the data model of your system to the constructor of the root class. Bypassing objects of this model already in cycles, you will create drawers. For example, for each instance of the Actor class associated with the current diagram (corresponding to the "role" in your "UML document model"), you need to instantiate the object of the DiagramActor rendering class.

    It’s convenient when drawers store links to the corresponding model objects. It is most convenient to pass these links directly in the form of parameters of the designer of the renderers. In our example, the world coordinates of objects and their parameters, such as the name and stereotype, are transmitted instead of them.

    World and screen coordinates


    As soon as we used the term “world coordinates”, we need to clarify what it is in our case. “World coordinates” in our country are the coordinates of chart objects on “imaginary millimeter paper”, on which the whole chart fits, which has the origin in the upper left corner and is not subject to any scaling. World coordinates coincide with on-screen coordinates if the image scale is 1: 1 and the scroll bars are in their minimum positions. The world coordinate, unlike the screen coordinate, is not an integer type, but takes a floating-point value. This is necessary so that the pixelization of the image does not occur with an increase in its scale. For example, although at a scale of 1: 1 the value of the world coordinate 0.3 is not indistinguishable from zero of screen pixels, at a scale of 100: 1 it turns into 30 screen pixels.

    It is convenient to calculate and store the chart model precisely in world coordinates, since they are not dependent on such momentary user actions as zooming and scrolling.

    To translate world coordinates into screens, the DiagramObject class contains important methods scaleX (...), scaleY (...) and just scale (...). The first two apply a scale factor to the world coordinate and take into account the shift of the horizontal and vertical scrollbars, respectively. The last method, scale (...), applies a scale factor, but does not take into account the shift: it is necessary for calculating not the position, but the size (for example, the width of the rectangle or the radius of the circle).

    Drawing a chart in terms of DiagramObject. Independent, semi-independent and dependent objects



    To draw a chart, the draw (Graphics canvas, double aDX, double aDY, double scale) method of the root DiagramObject is called. Its parameters are:
    • canvas - drawing context
    • aDX, aDY - scrollbar positions
    • scale - scale (1.0 - for a scale of 1: 1, more / less - to increase / decrease).

    This method implements the Template Method design pattern and looks like this:
    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;
    }
    


    That is, the draw (...) method:
    • remembers the parameters in the fields of the object (they are then repeatedly used by different methods),
    • saves with saveCanvasSetup () all settings for the rendering context (color, feathers, font size, etc.),
    • calls the internalDraw () method, which does nothing at the DiagramObject level, and is redefined in its descendants by the object rendering procedure,
    • restores using restoreCanvasSetup () settings that could be violated after internalDraw was executed,
    • runs in turn all its subobjects and calls the draw method for each of them.

    Thus, the invariant part of the algorithm is implemented in the draw (...) method, and the mutable part (the actual drawing) is implemented in the successor classes, which is the essence of the Template Method pattern.

    The purpose of the saveCanvasSetup () and restoreCanvasSetup () methods is to save the state of the drawing context so that each of the drawing objects gets it in a “pristine” form. If these methods are not used, and in one of the drawing heirs, let's say the ink color is changed to red, then everything that will be drawn next will be painted in red. The implementation of these methods depends on your development environment and the capabilities provided by the drawing engine. In Delphi and Java Swing, for example, you need to save many context parameters, and in HTML Canvas2D there are ready-made save () and restore () methods for this purpose that immediately save all the state of the context to a special stack.

    Here's what the internalDraw method looks like in the DiagramActor class (compare with the "naive example" we started with):
    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));
    }
    


    At the point (mX, mY) is the middle of the object. Since the origin of the "naive example" is in the upper left corner, they must be shifted by half the width and half the height of the object. The “naive example” did not take into account the need for scaling and shifting the image, but we take this into account when translating world coordinates to screen using the methods scaleX (...), scaleY (...) and scale (...).

    DiagramActor and DiagramUseCase objects are completely "independent", their positions are entirely determined by the internal state stored in the mX and mY fields. At the same time, all kinds of connecting arrows do not have their own state - their position on the screen is completely determined by the positions of the objects that they connect, they are completely "not independent", they go along a straight line connecting the centers of the objects:



    And separately, you should pay attention to the signature on the objects. In their internal state, they store not the absolute coordinates, but the offset relative to the parent drawing object, so they behave "semi-independently":



    Defining an object under the mouse cursor


    Having dealt with the drawing, we turn to the question of how the chart “understands” what object we clicked on. It turns out that the task of determining the object under the mouse cursor is very similar to the rendering task and, in a sense, is symmetrical to it.

    First, note that for each given chart object, it is not difficult to write a method that determines by the coordinates of the mouse cursor whether the cursor is over this object or not.

    For example, for DiagramActor we are talking about getting into a rectangular area:
    	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;
    	}
    

    For DiagramUseCase, we are talking about getting into an area that looks like an ellipse:
    	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;
    	}
    

    Now, if we want to determine the object over which the cursor is now, we can call internalTestHit for each of the chart objects by sequential search, and the first one that returns true will turn out to be the desired object. You only need to do this in the reverse order of rendering (movement along the blue arrows in the illustration showing the data structure)! If the mouse cursor is located in the area where several objects intersect, it is the search in the reverse order that ensures that the cursor hits the object drawn later than others, that is, visually located “above the others”.



    Here's how it is implemented in another DiagramObject template method:
    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;
    }
    


    The DiagramPanel object calls the testHit method of the root render object. During its execution, a recursion takes place that traverses the rendering tree in depth in the direction opposite to the drawing direction. The first object found is returned: this will be the “top” object from the user's point of view, located under the mouse cursor.

    Defining the current context under the mouse cursor


    An object under the mouse cursor can only be an integral part of a larger object and not have independent significance. If we want to perform some operation on an object and clicked on a part of it, then the operation still needs to be performed on the parent object. You can correctly show the context with the help of delegation - a technique associated with the use of the Composite pattern (see the book Design Patterns on this subject). In our example, we use delegation to get the tooltip of the object: for example, if the user hovers the mouse over the signature for the Actor, he gets the same hint as when the cursor was placed on the Actor itself.

    The idea is very simple: the getHint () method of the DiagramObject class does the following: if its own implementation of the internalGetHint () method is able to return a prompt string, then it returns. If it is not able, then the parent (in the rendering hierarchy) object is referred to whether it can do the work of the getHint () method. In the event that it is not “taken”, the “transfer of responsibility” will continue until the very root object-drawer. In addition to the delegation mechanism, we again apply the Template Method pattern :
    	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;
    	}
    

    Helper Methods DiagramObject


    DiagramObject heirs can override the following methods - their use in the DiagramPanel class will become clear from what follows:

    • 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 () - the same thing, but already translated into screen coordinates (not redefined methods).

    Drawing selection


    And finally, another important functionality implemented at the DiagramObject level, which can be redefined in its descendants, is drawing a selection, i.e. a graphic label, by which the user can understand that the object is in a selected state. By default, these are four blue square dots at the corners of the object:

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

    Pay attention to the integer (and therefore in screen coordinates) parameters dX, dY and the call to setXORMode (), which switches the rendering context to the "XOR mode": in this mode, to erase the previously drawn image, just draw it again . This is necessary in order to implement Drag & Drop for chart objects: for simplicity, we “drag” with the mouse not the image itself, but its selection, and then we transfer the image to a new location, and the object’s offset in screen will be transferred in the dX, dY parameters coordinates relative to the starting position:


    If this behavior of the system does not suit you, you can override the internalDrawSelection method in the heirs of the DiagramObject class to draw something more complicated as a selection (and move it with drag & drop).

    * * *


    That's all for the DrawObject class. The second part of the article will discuss the construction of the DiagramPanel class, which is responsible for processing mouse events and scaling, panning, selecting objects, and drag & drop. The full source code of our example, I recall, is available at https://github.com/inponomarev/graphexample and can be compiled using Maven.

    Also popular now: