Complete multiroom audio + navigation dashboard: - Docker stack: SignalK, Snapcast (4 zones), librespot, shairport-sync, Mopidy, Jellyfin, Portainer - React 18 + Vite dashboard with nautical dark theme - Full mock system (SignalK NMEA simulation, Snapcast zones, Mopidy player) - Real API clients for all services with reconnect logic - SVG instruments: Compass, WindRose, Gauge, DepthSounder, SpeedLog - Pages: Overview, Navigation, Audio (zones/radio/library), Systems - Dev mode runs fully without hardware (make dev) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
95 lines
2.9 KiB
JavaScript
95 lines
2.9 KiB
JavaScript
// Animated SVG compass rose.
|
|
|
|
const CX = 96, CY = 96, R = 80
|
|
|
|
const CARDINALS = [
|
|
{ label: 'N', angle: 0 },
|
|
{ label: 'E', angle: 90 },
|
|
{ label: 'S', angle: 180 },
|
|
{ label: 'W', angle: 270 },
|
|
]
|
|
|
|
function polarXY(cx, cy, r, angleDeg) {
|
|
const rad = (angleDeg - 90) * Math.PI / 180
|
|
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
|
}
|
|
|
|
export default function Compass({ heading = 0, cog }) {
|
|
const hdg = heading ?? 0
|
|
const hasCog = cog != null
|
|
|
|
return (
|
|
<svg width={192} height={192} viewBox="0 0 192 192">
|
|
{/* Outer ring */}
|
|
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={2} />
|
|
<circle cx={CX} cy={CY} r={R - 12} fill="none" stroke="var(--border)" strokeWidth={1} />
|
|
|
|
{/* Rotating rose */}
|
|
<g style={{
|
|
transformOrigin: `${CX}px ${CY}px`,
|
|
transform: `rotate(${-hdg}deg)`,
|
|
transition: 'transform 0.8s ease',
|
|
}}>
|
|
{/* 36 tick marks */}
|
|
{Array.from({ length: 36 }, (_, i) => {
|
|
const angle = i * 10
|
|
const outer = polarXY(CX, CY, R, angle)
|
|
const inner = polarXY(CX, CY, R - (i % 3 === 0 ? 10 : 5), angle)
|
|
return (
|
|
<line key={i}
|
|
x1={outer.x} y1={outer.y}
|
|
x2={inner.x} y2={inner.y}
|
|
stroke={i % 9 === 0 ? 'var(--accent)' : 'var(--muted)'}
|
|
strokeWidth={i % 9 === 0 ? 2 : 1}
|
|
/>
|
|
)
|
|
})}
|
|
|
|
{/* Cardinal labels */}
|
|
{CARDINALS.map(c => {
|
|
const p = polarXY(CX, CY, R - 22, c.angle)
|
|
return (
|
|
<text key={c.label}
|
|
x={p.x} y={p.y + 4}
|
|
textAnchor="middle"
|
|
fontFamily="var(--font-mono)" fontSize={12} fontWeight={700}
|
|
fill={c.label === 'N' ? 'var(--danger)' : 'var(--text)'}
|
|
>
|
|
{c.label}
|
|
</text>
|
|
)
|
|
})}
|
|
</g>
|
|
|
|
{/* Fixed lubber line (ship's bow = top) */}
|
|
<line x1={CX} y1={CY - R + 2} x2={CX} y2={CY - R + 16}
|
|
stroke="var(--accent)" strokeWidth={3} strokeLinecap="round" />
|
|
|
|
{/* COG indicator */}
|
|
{hasCog && (() => {
|
|
const cogAngle = cog - hdg
|
|
const tip = polarXY(CX, CY, R - 6, cogAngle)
|
|
return (
|
|
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
|
stroke="var(--warning)" strokeWidth={2} strokeDasharray="4,3"
|
|
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.8s ease' }}
|
|
/>
|
|
)
|
|
})()}
|
|
|
|
{/* Center */}
|
|
<circle cx={CX} cy={CY} r={4} fill="var(--accent)" />
|
|
|
|
{/* Heading value */}
|
|
<text x={CX} y={CY + 26} textAnchor="middle"
|
|
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
|
{Math.round(hdg).toString().padStart(3, '0')}°
|
|
</text>
|
|
<text x={CX} y={186} textAnchor="middle"
|
|
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
|
HEADING
|
|
</text>
|
|
</svg>
|
|
)
|
|
}
|