Files
Compass/ui/compass_widget.py
2026-07-03 12:23:41 -04:00

277 lines
9.7 KiB
Python

"""
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}")