""" Compass rose widget. The card rotates; the lubber line (red) is fixed at top. Current heading always appears at the top of the rose. """ import math from PyQt5.QtWidgets import QWidget from PyQt5.QtCore import Qt, QPointF, QRectF from PyQt5.QtGui import (QPainter, QPen, QBrush, QPainterPath, QRadialGradient, QConicalGradient, QColor, QFont) import ui.styles as S class CompassWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(300, 300) self._hdg = 0.0 self._roll = 0.0 self._pitch = 0.0 self._night = False self._hdg_mode = 'M' # 'M' = magnetic, 'T' = true # ── Public setters ────────────────────────────────────────────────────── def set_heading(self, h: float): self._hdg = h % 360 self.update() def set_attitude(self, pitch: float, roll: float): self._pitch = pitch self._roll = roll self.update() def set_hdg_mode(self, mode: str): self._hdg_mode = mode self.update() def set_night(self, on: bool): self._night = on self.update() # ── Paint ─────────────────────────────────────────────────────────────── def paintEvent(self, event): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) p.setRenderHint(QPainter.TextAntialiasing) w, h = self.width(), self.height() cx, cy = w / 2.0, h / 2.0 R = min(cx, cy) * 0.94 # outer bezel radius C = S.is_night(self._night) p.fillRect(self.rect(), C['bg']) self._bezel(p, cx, cy, R, C) # ── Rotating card ────────────────────────────────────────────────── p.save() p.translate(cx, cy) p.rotate(-self._hdg) # card rotates opposite to heading self._card_bg(p, R * 0.87, C) self._tick_ring(p, R * 0.71, R * 0.87, C) self._star(p, R * 0.50, C) self._cardinal_labels(p, R * 0.62, C) p.restore() # ── Fixed elements ───────────────────────────────────────────────── p.save() p.translate(cx, cy) self._lubber_line(p, R, C) self._center_ball(p, R * 0.10, C) p.restore() self._heading_readout(p, cx, cy, R, C) # ── Drawing primitives ────────────────────────────────────────────────── def _bezel(self, p, cx, cy, R, C): p.save() p.translate(cx, cy) g = QRadialGradient(QPointF(0, -R * 0.3), R * 1.2) g.setColorAt(0.0, S.BEZEL_MID) g.setColorAt(0.82, S.BEZEL_DARK) g.setColorAt(0.88, C['gold']) g.setColorAt(0.93, C['bright']) g.setColorAt(1.0, C['gold'].darker(160)) p.setBrush(QBrush(g)) p.setPen(Qt.NoPen) p.drawEllipse(QPointF(0, 0), R * 1.01, R * 1.01) p.restore() def _card_bg(self, p, r, C): g = QRadialGradient(QPointF(0, -r * 0.2), r) g.setColorAt(0.0, QColor('#121A2E')) g.setColorAt(0.7, QColor('#090D1A')) g.setColorAt(1.0, QColor('#060810')) p.setBrush(QBrush(g)) p.setPen(Qt.NoPen) p.drawEllipse(QPointF(0, 0), r, r) def _tick_ring(self, p, r_in, r_out, C): span = r_out - r_in for deg in range(0, 360, 5): p.save() p.rotate(deg) if deg % 90 == 0: length = span * 0.88 pen = QPen(C['bright'], max(1.8, r_out * 0.006)) elif deg % 45 == 0: length = span * 0.72 pen = QPen(C['gold'], max(1.4, r_out * 0.005)) elif deg % 10 == 0: length = span * 0.55 pen = QPen(C['gold'].darker(130), max(1.1, r_out * 0.004)) else: length = span * 0.32 pen = QPen(S.GOLD_DIM, max(0.8, r_out * 0.003)) p.setPen(pen) p.drawLine(QPointF(0, -r_out), QPointF(0, -r_out + length)) # Degree numbers every 10°, skip cardinal positions if deg % 10 == 0 and deg % 45 != 0: fs = max(7, int(r_out * 0.052)) f = QFont('Arial', fs) p.setFont(f) p.setPen(QPen(C['dim'])) text_y = -(r_out - span * 0.98) - fs * 1.6 rect = QRectF(-20, text_y - 12, 40, 24) p.drawText(rect, Qt.AlignCenter, str(deg // 10)) p.restore() def _star(self, p, r, C): """16-point compass star. N=red, cardinals=white, intercardinals=gold.""" for i in range(16): p.save() p.rotate(i * 22.5) cardinal = (i % 4 == 0) intercardinal = (i % 2 == 0 and not cardinal) is_north = (i == 0) if cardinal: tip = -r shld_y = -r * 0.28 width = r * 0.17 color = C['red'] if is_north else C['white'] elif intercardinal: tip = -r * 0.68 shld_y = -r * 0.20 width = r * 0.11 color = C['gold'] else: tip = -r * 0.42 shld_y = -r * 0.12 width = r * 0.07 color = S.GOLD_DIM # Main point (upper half — bright) path = QPainterPath() path.moveTo(0, tip) path.lineTo(-width / 2, shld_y) path.lineTo(0, 0) path.lineTo( width / 2, shld_y) path.closeSubpath() p.setBrush(QBrush(color)) p.setPen(QPen(color.darker(200), 0.5)) p.drawPath(path) # Lower shadow half shadow = QPainterPath() shadow.moveTo(0, 0) shadow.lineTo(-width / 2, shld_y) shadow.lineTo(0, abs(tip) * 0.22) shadow.lineTo( width / 2, shld_y) shadow.closeSubpath() p.setBrush(QBrush(color.darker(220))) p.setPen(Qt.NoPen) p.drawPath(shadow) p.restore() # Center ring cr = r * 0.09 p.setPen(QPen(C['gold'], max(1.5, r * 0.025))) p.setBrush(QBrush(S.BEZEL_DARK)) p.drawEllipse(QPointF(0, 0), cr, cr) def _cardinal_labels(self, p, r, C): entries = [ ( 0, 'N', C['bright'], True, True), ( 90, 'E', C['white'], True, False), (180, 'S', C['white'], True, False), (270, 'W', C['white'], True, False), ( 45, 'NE', C['gold'], False, False), (135, 'SE', C['gold'], False, False), (225, 'SW', C['gold'], False, False), (315, 'NW', C['gold'], False, False), ] for angle, label, color, big, bold in entries: p.save() p.rotate(angle) fs = max(11, int(r * 0.17)) if big else max(7, int(r * 0.10)) f = QFont('Arial', fs) f.setBold(bold) p.setFont(f) p.setPen(QPen(color)) rect = QRectF(-r * 0.20, -r * 1.12, r * 0.40, r * 0.24) p.drawText(rect, Qt.AlignCenter, label) p.restore() def _lubber_line(self, p, R, C): """Fixed red lubber line — always at top = ship's heading.""" pen = QPen(C['red'], max(2.5, R * 0.013)) pen.setCapStyle(Qt.RoundCap) p.setPen(pen) p.drawLine(QPointF(0, -R * 0.97), QPointF(0, -R * 0.70)) # Arrow tip path = QPainterPath() path.moveTo(0, -R * 0.70) path.lineTo(-R * 0.028, -R * 0.62) path.lineTo( R * 0.028, -R * 0.62) path.closeSubpath() p.setBrush(QBrush(C['red'])) p.setPen(Qt.NoPen) p.drawPath(path) def _center_ball(self, p, r, C): """Ball in center that shows roll/pitch deviation.""" p.setPen(QPen(C['gold'], max(1.5, r * 0.10))) p.setBrush(QBrush(QColor('#080C18'))) p.drawEllipse(QPointF(0, 0), r, r) max_d = r * 0.58 ball_x = math.sin(math.radians(self._roll)) * max_d ball_y = -math.sin(math.radians(self._pitch)) * max_d dist = math.hypot(ball_x, ball_y) if dist > max_d: ball_x = ball_x / dist * max_d ball_y = ball_y / dist * max_d br = r * 0.38 p.setPen(Qt.NoPen) p.setBrush(QBrush(C['gold'])) p.drawEllipse(QPointF(ball_x, ball_y), br, br) def _heading_readout(self, p, cx, cy, R, C): """Digital heading displayed at the top of the compass, just below the lubber arrow.""" fs = max(13, int(R * 0.115)) p.setFont(QFont('Courier New', fs, QFont.Bold)) # Semi-transparent backing so text reads cleanly over the rotating card box_w = R * 0.68 box_h = R * 0.21 box_x = cx - box_w / 2 box_y = cy - R * 0.59 p.setBrush(QBrush(QColor(6, 9, 20, 200))) p.setPen(Qt.NoPen) p.drawRoundedRect(QRectF(box_x, box_y, box_w, box_h), max(3, box_h * 0.20), max(3, box_h * 0.20)) p.setPen(QPen(C['bright'])) p.drawText(QRectF(box_x, box_y, box_w, box_h), Qt.AlignCenter, f"{self._hdg:05.1f}°{self._hdg_mode}")