"""EdgeItem: orthogonal connector with styles, arc hops and flow animation.""" from __future__ import annotations import math from PyQt6.QtCore import QLineF, QPointF, QRectF, Qt from PyQt6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF from PyQt6.QtWidgets import QGraphicsObject, QGraphicsItem, QStyle from ..model.edge import EdgeModel, EndpointDecoration, LineStyle from . import routing _PEN_STYLES = { LineStyle.SOLID: Qt.PenStyle.SolidLine, LineStyle.DASHED: Qt.PenStyle.DashLine, LineStyle.DOTTED: Qt.PenStyle.DotLine, LineStyle.DASH_DOT: Qt.PenStyle.DashDotLine, } class EdgeItem(QGraphicsObject): """Graphics item bound to an :class:`EdgeModel`.""" def __init__(self, scene_ref, edge: EdgeModel) -> None: super().__init__() self._scene = scene_ref self.edge = edge self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) self.setAcceptHoverEvents(True) self.setZValue(0) self._points: list[routing.Point] = [] self._path = QPainterPath() self._anim_phase = 0.0 self.reroute() # -- data ------------------------------------------------------------- def relation_color(self) -> QColor: if self.edge.style.color: return QColor(self.edge.style.color) doc = getattr(self._scene, "document", None) if doc is not None and self.edge.relation: return QColor(doc.relations.color(self.edge.relation)) return QColor("#607d8b") def endpoints_scene(self): """Return ((sx,sy),(snx,sny)),((ex,ey),(enx,eny)) in scene coords.""" return self._scene.edge_endpoints(self.edge) # -- routing ---------------------------------------------------------- def reroute(self) -> None: data = self.endpoints_scene() if data is None: return (start, snorm), (end, enorm) = data cfg = getattr(self._scene, "config", None) grid = cfg.grid_size if cfg else 20 stub = cfg.route_stub if cfg else 20 self.prepareGeometryChange() self._points = routing.orthogonal_route( start, snorm, end, enorm, grid=grid, stub=stub) self._rebuild_path() self.update() def _crossings(self) -> list[routing.Point]: cfg = getattr(self._scene, "config", None) if not (cfg and cfg.arc_on_crossing): return [] my_segs = routing.polyline_segments(self._points) others = self._scene.other_edge_segments(self.edge.edge_id) return routing.segment_crossings(my_segs, others) def _rebuild_path(self) -> None: pts = [QPointF(*p) for p in self._points] path = QPainterPath() if len(pts) < 2: self._path = path return cfg = getattr(self._scene, "config", None) radius = cfg.arc_radius if cfg else 5.0 crossings = self._crossings() path.moveTo(pts[0]) for i in range(len(pts) - 1): a, b = pts[i], pts[i + 1] hops = self._hops_on_segment(a, b, crossings, radius) for cx, cy in hops: self._draw_hop(path, a, b, QPointF(cx, cy), radius) path.lineTo(b) self._path = path @staticmethod def _hops_on_segment(a: QPointF, b: QPointF, crossings, radius): """Crossings lying on horizontal segment a->b, ordered along it.""" eps = 1e-6 if abs(a.y() - b.y()) > eps: # only horizontal segments hop return [] y = a.y() lo, hi = sorted((a.x(), b.x())) on = [(cx, cy) for (cx, cy) in crossings if abs(cy - y) < 1e-3 and lo + radius < cx < hi - radius] on.sort(key=lambda c: c[0] if b.x() >= a.x() else -c[0]) return on @staticmethod def _draw_hop(path: QPainterPath, a: QPointF, b: QPointF, c: QPointF, radius: float): sign = 1.0 if b.x() >= a.x() else -1.0 path.lineTo(c.x() - sign * radius, c.y()) rect = QRectF(c.x() - radius, c.y() - radius, 2 * radius, 2 * radius) # semicircular hop above the line start_angle = 180 if sign > 0 else 0 sweep = -180 if sign > 0 else 180 path.arcTo(rect, start_angle, sweep) # -- geometry for the scene ------------------------------------------ def path_points(self) -> list[routing.Point]: return list(self._points) def boundingRect(self) -> QRectF: if self._path.isEmpty(): return QRectF() extra = self.edge.style.width + 12 return self._path.boundingRect().adjusted(-extra, -extra, extra, extra) def shape(self) -> QPainterPath: stroker = QPen() from PyQt6.QtGui import QPainterPathStroker s = QPainterPathStroker() s.setWidth(max(10.0, self.edge.style.width + 8)) return s.createStroke(self._path) # -- animation -------------------------------------------------------- def set_phase(self, phase: float) -> None: self._anim_phase = phase if self.edge.flow_direction != 0: self.update() # -- painting --------------------------------------------------------- def paint(self, painter, option, widget=None): if self._path.isEmpty(): return painter.setRenderHint(painter.RenderHint.Antialiasing, True) color = self.relation_color() style = self.edge.style selected = bool(option.state & QStyle.StateFlag.State_Selected) # base line pen = QPen(color, style.width, _PEN_STYLES.get(style.line_style, Qt.PenStyle.SolidLine)) pen.setCapStyle(Qt.PenCapStyle.RoundCap) pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin) if selected: halo = QPen(QColor(25, 118, 210, 120), style.width + 6) halo.setCapStyle(Qt.PenCapStyle.RoundCap) painter.setPen(halo) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(self._path) painter.setPen(pen) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(self._path) # flow animation: marching dashes overlaid, moving with flow direction if self.edge.flow_direction != 0: self._paint_flow(painter, color) # endpoint decorations if len(self._points) >= 2: self._paint_decoration(painter, color, style.tail, self._points[1], self._points[0]) self._paint_decoration(painter, color, style.head, self._points[-2], self._points[-1]) def _paint_flow(self, painter, color: QColor): width = max(2.0, min(8.0, 1.5 + self.edge.flow / 25.0)) dash = QPen(color.lighter(150), width, Qt.PenStyle.CustomDashLine) dash.setCapStyle(Qt.PenCapStyle.RoundCap) dash.setDashPattern([2.0, 4.0]) offset = self._anim_phase * (2.0 + self.edge.flow / 20.0) # dashes travel source->target for +1, reverse for -1 dash.setDashOffset(-offset if self.edge.flow_direction > 0 else offset) painter.setPen(dash) painter.setBrush(Qt.BrushStyle.NoBrush) painter.drawPath(self._path) def _paint_decoration(self, painter, color: QColor, deco: EndpointDecoration, from_pt, to_pt): if deco == EndpointDecoration.NONE: return p_from = QPointF(*from_pt) p_to = QPointF(*to_pt) line = QLineF(p_from, p_to) angle = math.radians(line.angle()) # QLineF angle is CCW from +x size = 9.0 painter.setPen(QPen(color, 1.5)) painter.setBrush(QBrush(color)) if deco == EndpointDecoration.ARROW: a1 = angle + math.radians(150) a2 = angle - math.radians(150) p1 = QPointF(p_to.x() + size * math.cos(a1), p_to.y() - size * math.sin(a1)) p2 = QPointF(p_to.x() + size * math.cos(a2), p_to.y() - size * math.sin(a2)) painter.drawPolygon(QPolygonF([p_to, p1, p2])) elif deco == EndpointDecoration.CIRCLE: painter.drawEllipse(p_to, size * 0.55, size * 0.55) elif deco == EndpointDecoration.DIAMOND: d = size * 0.7 perp = angle + math.radians(90) fx, fy = math.cos(angle), -math.sin(angle) px, py = math.cos(perp), -math.sin(perp) c = QPointF(p_to.x() - d * fx, p_to.y() - d * fy) poly = QPolygonF([ QPointF(c.x() + d * fx, c.y() + d * fy), QPointF(c.x() + d * px, c.y() + d * py), QPointF(c.x() - d * fx, c.y() - d * fy), QPointF(c.x() - d * px, c.y() - d * py), ]) painter.drawPolygon(poly) elif deco == EndpointDecoration.BAR: perp = angle + math.radians(90) px, py = math.cos(perp), -math.sin(perp) painter.setPen(QPen(color, 2.5)) painter.drawLine( QPointF(p_to.x() + size * 0.7 * px, p_to.y() + size * 0.7 * py), QPointF(p_to.x() - size * 0.7 * px, p_to.y() - size * 0.7 * py), )