Initial implementation: Bordanlage boat onboard system

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>
This commit is contained in:
2026-03-26 11:47:33 +01:00
commit 946c0a5377
57 changed files with 3450 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
// 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>
)
}