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