eric6/Graphics/AssociationItem.py

changeset 6942
2602857055c5
parent 6645
ad476851d7e0
child 7229
53054eb5b15a
equal deleted inserted replaced
6941:f99d60d6b59b 6942:2602857055c5
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

eric ide

mercurial