277 lines
9.7 KiB
Python
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}")
|