src/eric7/Graphics/AssociationItem.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2004 - 2022 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing a graphics item for an association between two items.
8 """
9
10 import enum
11
12 from PyQt6.QtCore import QPointF, QRectF, QLineF
13 from PyQt6.QtWidgets import QGraphicsItem
14
15 from EricGraphics.EricArrowItem import EricArrowItem, EricArrowType
16
17 import Utilities
18
19
20 class AssociationType(enum.Enum):
21 """
22 Class defining the association types.
23 """
24 NORMAL = 0
25 GENERALISATION = 1
26 IMPORTS = 2
27
28
29 class AssociationPointRegion(enum.Enum):
30 """
31 Class defining the regions for an association end point.
32 """
33 NO_REGION = 0
34 WEST = 1
35 NORTH = 2
36 EAST = 3
37 SOUTH = 4
38 NORTH_WEST = 5
39 NORTH_EAST = 6
40 SOUTH_EAST = 7
41 SOUTH_WEST = 8
42 CENTER = 9
43
44
45 class AssociationItem(EricArrowItem):
46 """
47 Class implementing a graphics item for an association between two items.
48
49 The association is drawn as an arrow starting at the first items and
50 ending at the second.
51 """
52 def __init__(self, itemA, itemB, assocType=AssociationType.NORMAL,
53 topToBottom=False, colors=None, parent=None):
54 """
55 Constructor
56
57 @param itemA first widget of the association
58 @type UMLItem
59 @param itemB second widget of the association
60 @type UMLItem
61 @param assocType type of the association
62 @type AssociationType
63 @param topToBottom flag indicating to draw the association
64 from item A top to item B bottom
65 @type bool
66 @param colors tuple containing the foreground and background colors
67 @type tuple of (QColor, QColor)
68 @param parent reference to the parent object
69 @type QGraphicsItem
70 """
71 if assocType in (AssociationType.NORMAL, AssociationType.IMPORTS):
72 arrowType = EricArrowType.NORMAL
73 arrowFilled = True
74 elif assocType == AssociationType.GENERALISATION:
75 arrowType = EricArrowType.WIDE
76 arrowFilled = False
77
78 EricArrowItem.__init__(self, QPointF(0, 0), QPointF(100, 100),
79 arrowFilled, arrowType, colors, parent)
80
81 self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
82 self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
83
84 if topToBottom:
85 self.calculateEndingPoints = (
86 self.__calculateEndingPoints_topToBottom
87 )
88 else:
89 #- self.calculateEndingPoints = self.__calculateEndingPoints_center
90 self.calculateEndingPoints = self.__calculateEndingPoints_rectangle
91
92 self.itemA = itemA
93 self.itemB = itemB
94 self.assocType = assocType
95 self.topToBottom = topToBottom
96
97 self.regionA = AssociationPointRegion.NO_REGION
98 self.regionB = AssociationPointRegion.NO_REGION
99
100 self.calculateEndingPoints()
101
102 self.itemA.addAssociation(self)
103 self.itemB.addAssociation(self)
104
105 def __mapRectFromItem(self, item):
106 """
107 Private method to map item's rectangle to this item's coordinate
108 system.
109
110 @param item reference to the item to be mapped
111 @type QGraphicsRectItem
112 @return item's rectangle in local coordinates
113 @rtype QRectF
114 """
115 rect = item.rect()
116 tl = self.mapFromItem(item, rect.topLeft())
117 return QRectF(tl.x(), tl.y(), rect.width(), rect.height())
118
119 def __calculateEndingPoints_topToBottom(self):
120 """
121 Private method to calculate the ending points of the association item.
122
123 The ending points are calculated from the top center of the lower item
124 to the bottom center of the upper item.
125 """
126 if self.itemA is None or self.itemB is None:
127 return
128
129 self.prepareGeometryChange()
130
131 rectA = self.__mapRectFromItem(self.itemA)
132 rectB = self.__mapRectFromItem(self.itemB)
133 midA = QPointF(rectA.x() + rectA.width() / 2.0,
134 rectA.y() + rectA.height() / 2.0)
135 midB = QPointF(rectB.x() + rectB.width() / 2.0,
136 rectB.y() + rectB.height() / 2.0)
137 if midA.y() > midB.y():
138 startP = QPointF(rectA.x() + rectA.width() / 2.0, rectA.y())
139 endP = QPointF(rectB.x() + rectB.width() / 2.0,
140 rectB.y() + rectB.height())
141 else:
142 startP = QPointF(rectA.x() + rectA.width() / 2.0,
143 rectA.y() + rectA.height())
144 endP = QPointF(rectB.x() + rectB.width() / 2.0, rectB.y())
145 self.setPoints(startP.x(), startP.y(), endP.x(), endP.y())
146
147 def __calculateEndingPoints_center(self):
148 """
149 Private method to calculate the ending points of the association item.
150
151 The ending points are calculated from the centers of the
152 two associated items.
153 """
154 if self.itemA is None or self.itemB is None:
155 return
156
157 self.prepareGeometryChange()
158
159 rectA = self.__mapRectFromItem(self.itemA)
160 rectB = self.__mapRectFromItem(self.itemB)
161 midA = QPointF(rectA.x() + rectA.width() / 2.0,
162 rectA.y() + rectA.height() / 2.0)
163 midB = QPointF(rectB.x() + rectB.width() / 2.0,
164 rectB.y() + rectB.height() / 2.0)
165 startP = self.__findRectIntersectionPoint(self.itemA, midA, midB)
166 endP = self.__findRectIntersectionPoint(self.itemB, midB, midA)
167
168 if (
169 startP.x() != -1 and
170 startP.y() != -1 and
171 endP.x() != -1 and
172 endP.y() != -1
173 ):
174 self.setPoints(startP.x(), startP.y(), endP.x(), endP.y())
175
176 def __calculateEndingPoints_rectangle(self):
177 r"""
178 Private method to calculate the ending points of the association item.
179
180 The ending points are calculated by the following method.
181
182 For each item the diagram is divided in four Regions by its diagonals
183 as indicated below
184 <pre>
185 +------------------------------+
186 | \ Region 2 / |
187 | \ / |
188 | |--------| |
189 | | \ / | |
190 | | \ / | |
191 | | \/ | |
192 | Region 1 | /\ | Region 3 |
193 | | / \ | |
194 | | / \ | |
195 | |--------| |
196 | / \ |
197 | / Region 4 \ |
198 +------------------------------+
199 </pre>
200
201 Each diagonal is defined by two corners of the bounding rectangle.
202
203 To calculate the start point we have to find out in which
204 region (defined by itemA's diagonals) is itemB's TopLeft corner
205 (lets call it region M). After that the start point will be
206 the middle point of rectangle's side contained in region M.
207
208 To calculate the end point we repeat the above but in the opposite
209 direction (from itemB to itemA)
210 """
211 if self.itemA is None or self.itemB is None:
212 return
213
214 self.prepareGeometryChange()
215
216 rectA = self.__mapRectFromItem(self.itemA)
217 rectB = self.__mapRectFromItem(self.itemB)
218
219 xA = rectA.x() + rectA.width() / 2.0
220 yA = rectA.y() + rectA.height() / 2.0
221 xB = rectB.x() + rectB.width() / 2.0
222 yB = rectB.y() + rectB.height() / 2.0
223
224 # find itemA region
225 rc = QRectF(xA, yA, rectA.width(), rectA.height())
226 self.regionA = self.__findPointRegion(rc, xB, yB)
227 # move some regions to the standard ones
228 if self.regionA == AssociationPointRegion.NORTH_WEST:
229 self.regionA = AssociationPointRegion.NORTH
230 elif self.regionA == AssociationPointRegion.NORTH_EAST:
231 self.regionA = AssociationPointRegion.EAST
232 elif self.regionA == AssociationPointRegion.SOUTH_EAST:
233 self.regionA = AssociationPointRegion.SOUTH
234 elif self.regionA in (
235 AssociationPointRegion.SOUTH_WEST,
236 AssociationPointRegion.CENTER
237 ):
238 self.regionA = AssociationPointRegion.WEST
239
240 self.__updateEndPoint(self.regionA, True)
241
242 # now do the same for itemB
243 rc = QRectF(xB, yB, rectB.width(), rectB.height())
244 self.regionB = self.__findPointRegion(rc, xA, yA)
245 # move some regions to the standard ones
246 if self.regionB == AssociationPointRegion.NORTH_WEST:
247 self.regionB = AssociationPointRegion.NORTH
248 elif self.regionB == AssociationPointRegion.NORTH_EAST:
249 self.regionB = AssociationPointRegion.EAST
250 elif self.regionB == AssociationPointRegion.SOUTH_EAST:
251 self.regionB = AssociationPointRegion.SOUTH
252 elif self.regionB in (
253 AssociationPointRegion.SOUTH_WEST,
254 AssociationPointRegion.CENTER
255 ):
256 self.regionB = AssociationPointRegion.WEST
257
258 self.__updateEndPoint(self.regionB, False)
259
260 def __findPointRegion(self, rect, posX, posY):
261 """
262 Private method to find out, which region of rectangle rect contains
263 the point (PosX, PosY) and returns the region number.
264
265 @param rect rectangle to calculate the region for
266 @type QRectF
267 @param posX x position of point
268 @type float
269 @param posY y position of point
270 @type float
271 @return the calculated region number<br />
272 West = Region 1<br />
273 North = Region 2<br />
274 East = Region 3<br />
275 South = Region 4<br />
276 NorthWest = On diagonal 2 between Region 1 and 2<br />
277 NorthEast = On diagonal 1 between Region 2 and 3<br />
278 SouthEast = On diagonal 2 between Region 3 and 4<br />
279 SouthWest = On diagonal 1 between Region4 and 1<br />
280 Center = On diagonal 1 and On diagonal 2 (the center)<br />
281 @rtype AssociationPointRegion
282 """
283 w = rect.width()
284 h = rect.height()
285 x = rect.x()
286 y = rect.y()
287 slope2 = w / h
288 slope1 = -slope2
289 b1 = x + w / 2.0 - y * slope1
290 b2 = x + w / 2.0 - y * slope2
291
292 eval1 = slope1 * posY + b1
293 eval2 = slope2 * posY + b2
294
295 result = AssociationPointRegion.NO_REGION
296
297 # inside region 1
298 if eval1 > posX and eval2 > posX:
299 result = AssociationPointRegion.WEST
300
301 #inside region 2
302 elif eval1 > posX and eval2 < posX:
303 result = AssociationPointRegion.NORTH
304
305 # inside region 3
306 elif eval1 < posX and eval2 < posX:
307 result = AssociationPointRegion.EAST
308
309 # inside region 4
310 elif eval1 < posX and eval2 > posX:
311 result = AssociationPointRegion.SOUTH
312
313 # inside region 5
314 elif eval1 == posX and eval2 < posX:
315 result = AssociationPointRegion.NORTH_WEST
316
317 # inside region 6
318 elif eval1 < posX and eval2 == posX:
319 result = AssociationPointRegion.NORTH_EAST
320
321 # inside region 7
322 elif eval1 == posX and eval2 > posX:
323 result = AssociationPointRegion.SOUTH_EAST
324
325 # inside region 8
326 elif eval1 > posX and eval2 == posX:
327 result = AssociationPointRegion.SOUTH_WEST
328
329 # inside region 9
330 elif eval1 == posX and eval2 == posX:
331 result = AssociationPointRegion.CENTER
332
333 return result
334
335 def __updateEndPoint(self, region, isWidgetA):
336 """
337 Private method to update an endpoint.
338
339 @param region the region for the endpoint
340 @type AssociationPointRegion
341 @param isWidgetA flag indicating update for itemA is done
342 @type bool
343 """
344 if region == AssociationPointRegion.NO_REGION:
345 return
346
347 rect = (
348 self.__mapRectFromItem(self.itemA)
349 if isWidgetA else
350 self.__mapRectFromItem(self.itemB)
351 )
352 x = rect.x()
353 y = rect.y()
354 ww = rect.width()
355 wh = rect.height()
356 ch = wh / 2.0
357 cw = ww / 2.0
358
359 if region == AssociationPointRegion.WEST:
360 px = x
361 py = y + ch
362 elif region == AssociationPointRegion.NORTH:
363 px = x + cw
364 py = y
365 elif region == AssociationPointRegion.EAST:
366 px = x + ww
367 py = y + ch
368 elif region in (
369 AssociationPointRegion.SOUTH,
370 AssociationPointRegion.CENTER
371 ):
372 px = x + cw
373 py = y + wh
374
375 if isWidgetA:
376 self.setStartPoint(px, py)
377 else:
378 self.setEndPoint(px, py)
379
380 def __findRectIntersectionPoint(self, item, p1, p2):
381 """
382 Private method to find the intersection point of a line with a
383 rectangle.
384
385 @param item item to check against
386 @type UMLItem
387 @param p1 first point of the line
388 @type QPointF
389 @param p2 second point of the line
390 @type QPointF
391 @return the intersection point
392 @rtype QPointF
393 """
394 rect = self.__mapRectFromItem(item)
395 lines = [
396 QLineF(rect.topLeft(), rect.topRight()),
397 QLineF(rect.topLeft(), rect.bottomLeft()),
398 QLineF(rect.bottomRight(), rect.bottomLeft()),
399 QLineF(rect.bottomRight(), rect.topRight())
400 ]
401 intersectLine = QLineF(p1, p2)
402 intersectPoint = QPointF(0, 0)
403 for line in lines:
404 if (
405 intersectLine.intersect(line, intersectPoint) ==
406 QLineF.IntersectType.BoundedIntersection
407 ):
408 return intersectPoint
409 return QPointF(-1.0, -1.0)
410
411 def __findIntersection(self, p1, p2, p3, p4):
412 """
413 Private method to calculate the intersection point of two lines.
414
415 The first line is determined by the points p1 and p2, the second
416 line by p3 and p4. If the intersection point is not contained in
417 the segment p1p2, then it returns (-1.0, -1.0).
418
419 For the function's internal calculations remember:<br />
420 QT coordinates start with the point (0,0) as the topleft corner
421 and x-values increase from left to right and y-values increase
422 from top to bottom; it means the visible area is quadrant I in
423 the regular XY coordinate system
424
425 <pre>
426 Quadrant II | Quadrant I
427 -----------------|-----------------
428 Quadrant III | Quadrant IV
429 </pre>
430
431 In order for the linear function calculations to work in this method
432 we must switch x and y values (x values become y values and viceversa)
433
434 @param p1 first point of first line
435 @type QPointF
436 @param p2 second point of first line
437 @type QPointF
438 @param p3 first point of second line
439 @type QPointF
440 @param p4 second point of second line
441 @type QPointF
442 @return the intersection point
443 @rtype QPointF
444 """
445 x1 = p1.y()
446 y1 = p1.x()
447 x2 = p2.y()
448 y2 = p2.x()
449 x3 = p3.y()
450 y3 = p3.x()
451 x4 = p4.y()
452 y4 = p4.x()
453
454 # line 1 is the line between (x1, y1) and (x2, y2)
455 # line 2 is the line between (x3, y3) and (x4, y4)
456 no_line1 = True # it is false, if line 1 is a linear function
457 no_line2 = True # it is false, if line 2 is a linear function
458 slope1 = 0.0
459 slope2 = 0.0
460 b1 = 0.0
461 b2 = 0.0
462
463 if x2 != x1:
464 slope1 = (y2 - y1) / (x2 - x1)
465 b1 = y1 - slope1 * x1
466 no_line1 = False
467 if x4 != x3:
468 slope2 = (y4 - y3) / (x4 - x3)
469 b2 = y3 - slope2 * x3
470 no_line2 = False
471
472 pt = QPointF()
473 # if either line is not a function
474 if no_line1 and no_line2:
475 # if the lines are not the same one
476 if x1 != x3:
477 return QPointF(-1.0, -1.0)
478 # if the lines are the same ones
479 if y3 <= y4:
480 if y3 <= y1 and y1 <= y4:
481 return QPointF(y1, x1)
482 else:
483 return QPointF(y2, x2)
484 else:
485 if y4 <= y1 and y1 <= y3:
486 return QPointF(y1, x1)
487 else:
488 return QPointF(y2, x2)
489 elif no_line1:
490 pt.setX(slope2 * x1 + b2)
491 pt.setY(x1)
492 if y1 >= y2:
493 if not (y2 <= pt.x() and pt.x() <= y1):
494 pt.setX(-1.0)
495 pt.setY(-1.0)
496 else:
497 if not (y1 <= pt.x() and pt.x() <= y2):
498 pt.setX(-1.0)
499 pt.setY(-1.0)
500 return pt
501 elif no_line2:
502 pt.setX(slope1 * x3 + b1)
503 pt.setY(x3)
504 if y3 >= y4:
505 if not (y4 <= pt.x() and pt.x() <= y3):
506 pt.setX(-1.0)
507 pt.setY(-1.0)
508 else:
509 if not (y3 <= pt.x() and pt.x() <= y4):
510 pt.setX(-1.0)
511 pt.setY(-1.0)
512 return pt
513
514 if slope1 == slope2:
515 pt.setX(-1.0)
516 pt.setY(-1.0)
517 return pt
518
519 pt.setY((b2 - b1) / (slope1 - slope2))
520 pt.setX(slope1 * pt.y() + b1)
521 # the intersection point must be inside the segment (x1, y1) (x2, y2)
522 if x2 >= x1 and y2 >= y1:
523 if not ((x1 <= pt.y() and pt.y() <= x2) and
524 (y1 <= pt.x() and pt.x() <= y2)):
525 pt.setX(-1.0)
526 pt.setY(-1.0)
527 elif x2 < x1 and y2 >= y1:
528 if not ((x2 <= pt.y() and pt.y() <= x1) and
529 (y1 <= pt.x() and pt.x() <= y2)):
530 pt.setX(-1.0)
531 pt.setY(-1.0)
532 elif x2 >= x1 and y2 < y1:
533 if not ((x1 <= pt.y() and pt.y() <= x2) and
534 (y2 <= pt.x() and pt.x() <= y1)):
535 pt.setX(-1.0)
536 pt.setY(-1.0)
537 else:
538 if not ((x2 <= pt.y() and pt.y() <= x1) and
539 (y2 <= pt.x() and pt.x() <= y1)):
540 pt.setX(-1.0)
541 pt.setY(-1.0)
542
543 return pt
544
545 def widgetMoved(self):
546 """
547 Public method to recalculate the association after a widget was moved.
548 """
549 self.calculateEndingPoints()
550
551 def unassociate(self):
552 """
553 Public method to unassociate from the widgets.
554 """
555 self.itemA.removeAssociation(self)
556 self.itemB.removeAssociation(self)
557
558 @classmethod
559 def parseAssociationItemDataString(cls, data):
560 """
561 Class method to parse the given persistence data.
562
563 @param data persisted data to be parsed
564 @type str
565 @return tuple with the IDs of the source and destination items,
566 the association type and a flag indicating to associate from top
567 to bottom
568 @rtype tuple of (int, int, int, bool)
569 """
570 src = -1
571 dst = -1
572 assocType = AssociationType.NORMAL
573 topToBottom = False
574 for entry in data.split(", "):
575 if "=" in entry:
576 key, value = entry.split("=", 1)
577 if key == "src":
578 src = int(value)
579 elif key == "dst":
580 dst = int(value)
581 elif key == "type":
582 assocType = AssociationType(int(value))
583 elif key == "topToBottom":
584 topToBottom = Utilities.toBool(value)
585
586 return src, dst, assocType, topToBottom
587
588 def toDict(self):
589 """
590 Public method to collect data to be persisted.
591
592 @return dictionary containing data to be persisted
593 @rtype dict
594 """
595 return {
596 "src": self.itemA.getId(),
597 "dst": self.itemB.getId(),
598 "type": self.assocType.value,
599 "topToBottom": self.topToBottom,
600 }
601
602 @classmethod
603 def fromDict(cls, data, umlItems, colors=None):
604 """
605 Class method to create an association item from persisted data.
606
607 @param data dictionary containing the persisted data as generated
608 by toDict()
609 @type dict
610 @param umlItems list of UML items
611 @type list of UMLItem
612 @param colors tuple containing the foreground and background colors
613 @type tuple of (QColor, QColor)
614 @return created association item
615 @rtype AssociationItem
616 """
617 try:
618 return cls(umlItems[data["src"]],
619 umlItems[data["dst"]],
620 assocType=AssociationType(data["type"]),
621 topToBottom=data["topToBottom"],
622 colors=colors)
623 except (KeyError, ValueError):
624 return None

eric ide

mercurial