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:
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal file
94
bordanlage/dashboard/src/components/instruments/Compass.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user