Move project from bordanlage/ to repo root
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
94
dashboard/src/components/instruments/Compass.jsx
Normal file
94
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>
|
||||
)
|
||||
}
|
||||
16
dashboard/src/components/instruments/DepthSounder.jsx
Normal file
16
dashboard/src/components/instruments/DepthSounder.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
// Depth sounder with alarm threshold.
|
||||
import Gauge from './Gauge.jsx'
|
||||
|
||||
export default function DepthSounder({ depth }) {
|
||||
return (
|
||||
<Gauge
|
||||
value={depth}
|
||||
min={0}
|
||||
max={30}
|
||||
label="Depth"
|
||||
unit="m"
|
||||
warning={3}
|
||||
danger={1.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
118
dashboard/src/components/instruments/Gauge.jsx
Normal file
118
dashboard/src/components/instruments/Gauge.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// Analog round gauge with animated needle.
|
||||
|
||||
const R = 80
|
||||
const CX = 96
|
||||
const CY = 96
|
||||
const START_ANGLE = -225
|
||||
const SWEEP = 270
|
||||
|
||||
function polarToXY(cx, cy, r, angleDeg) {
|
||||
const rad = (angleDeg - 90) * Math.PI / 180
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
|
||||
}
|
||||
|
||||
function arcPath(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToXY(cx, cy, r, startDeg)
|
||||
const end = polarToXY(cx, cy, r, endDeg)
|
||||
const large = endDeg - startDeg > 180 ? 1 : 0
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`
|
||||
}
|
||||
|
||||
function buildTicks(startDeg, sweep, count) {
|
||||
return Array.from({ length: count + 1 }, (_, i) => {
|
||||
const angle = startDeg + (sweep / count) * i
|
||||
const outer = polarToXY(CX, CY, R, angle)
|
||||
const inner = polarToXY(CX, CY, R - (i % 2 === 0 ? 10 : 6), angle)
|
||||
return { outer, inner, major: i % 2 === 0 }
|
||||
})
|
||||
}
|
||||
|
||||
export default function Gauge({ value, min = 0, max = 10, label = '', unit = '', danger, warning }) {
|
||||
const clampedVal = Math.min(max, Math.max(min, value ?? min))
|
||||
const ratio = (clampedVal - min) / (max - min)
|
||||
const needleAngle = START_ANGLE + ratio * SWEEP
|
||||
|
||||
const warnRatio = warning != null ? (warning - min) / (max - min) : null
|
||||
const dangRatio = danger != null ? (danger - min) / (max - min) : null
|
||||
|
||||
const ticks = buildTicks(START_ANGLE, SWEEP, 10)
|
||||
|
||||
const needleTip = polarToXY(CX, CY, R - 12, needleAngle)
|
||||
const needleBase1 = polarToXY(CX, CY, 6, needleAngle + 90)
|
||||
const needleBase2 = polarToXY(CX, CY, 6, needleAngle - 90)
|
||||
|
||||
const isWarning = warning != null && clampedVal >= warning
|
||||
const isDanger = danger != null && clampedVal >= danger
|
||||
const needleColor = isDanger ? 'var(--danger)' : isWarning ? 'var(--warning)' : 'var(--accent)'
|
||||
|
||||
return (
|
||||
<svg width={192} height={192} viewBox="0 0 192 192" style={{ overflow: 'visible' }}>
|
||||
{/* Background arc */}
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE, START_ANGLE + SWEEP)}
|
||||
fill="none" stroke="var(--border)" strokeWidth={8} strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Warning zone */}
|
||||
{warnRatio != null && (
|
||||
<path
|
||||
d={arcPath(CX, CY, R,
|
||||
START_ANGLE + warnRatio * SWEEP,
|
||||
START_ANGLE + (dangRatio ?? 1) * SWEEP)}
|
||||
fill="none" stroke="#f59e0b33" strokeWidth={8}
|
||||
/>
|
||||
)}
|
||||
{/* Danger zone */}
|
||||
{dangRatio != null && (
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE + dangRatio * SWEEP, START_ANGLE + SWEEP)}
|
||||
fill="none" stroke="#ef444433" strokeWidth={8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Progress arc */}
|
||||
<path
|
||||
d={arcPath(CX, CY, R, START_ANGLE, needleAngle)}
|
||||
fill="none" stroke={needleColor} strokeWidth={4} strokeLinecap="round"
|
||||
style={{ transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
|
||||
{/* Ticks */}
|
||||
{ticks.map((t, i) => (
|
||||
<line key={i}
|
||||
x1={t.outer.x} y1={t.outer.y}
|
||||
x2={t.inner.x} y2={t.inner.y}
|
||||
stroke={t.major ? 'var(--muted)' : 'var(--border)'}
|
||||
strokeWidth={t.major ? 1.5 : 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Needle */}
|
||||
<polygon
|
||||
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
|
||||
fill={needleColor}
|
||||
style={{ transition: 'all 0.6s ease', transformOrigin: `${CX}px ${CY}px` }}
|
||||
/>
|
||||
|
||||
{/* Center cap */}
|
||||
<circle cx={CX} cy={CY} r={6} fill="var(--surface2)" stroke={needleColor} strokeWidth={2} />
|
||||
|
||||
{/* Value text */}
|
||||
<text x={CX} y={CY + 28} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)"
|
||||
style={{ transition: 'all 0.3s' }}>
|
||||
{value != null ? (Number.isInteger(max - min) && max - min <= 20
|
||||
? Math.round(clampedVal)
|
||||
: clampedVal.toFixed(1)) : '--'}
|
||||
</text>
|
||||
<text x={CX} y={CY + 42} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
{unit}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
<text x={CX} y={186} textAnchor="middle" fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||
{label.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
14
dashboard/src/components/instruments/SpeedLog.jsx
Normal file
14
dashboard/src/components/instruments/SpeedLog.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// Speed through water / SOG gauge.
|
||||
import Gauge from './Gauge.jsx'
|
||||
|
||||
export default function SpeedLog({ sog }) {
|
||||
return (
|
||||
<Gauge
|
||||
value={sog}
|
||||
min={0}
|
||||
max={12}
|
||||
label="Speed"
|
||||
unit="kn"
|
||||
/>
|
||||
)
|
||||
}
|
||||
76
dashboard/src/components/instruments/WindRose.jsx
Normal file
76
dashboard/src/components/instruments/WindRose.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// Wind rose showing apparent wind angle and speed.
|
||||
|
||||
const CX = 96, CY = 96, R = 70
|
||||
|
||||
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 WindRose({ windAngle = 0, windSpeed = 0 }) {
|
||||
const angle = windAngle ?? 0
|
||||
const speed = windSpeed ?? 0
|
||||
const tipLen = Math.min(R - 10, 20 + speed * 2.5)
|
||||
|
||||
const tip = polarXY(CX, CY, tipLen, angle)
|
||||
const left = polarXY(CX, CY, 10, angle + 120)
|
||||
const right = polarXY(CX, CY, 10, angle - 120)
|
||||
|
||||
const color = speed > 18 ? 'var(--danger)' : speed > 12 ? 'var(--warning)' : 'var(--accent)'
|
||||
|
||||
return (
|
||||
<svg width={192} height={192} viewBox="0 0 192 192">
|
||||
{/* Background rings */}
|
||||
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={1} />
|
||||
<circle cx={CX} cy={CY} r={R * 0.6} fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray="3,4" />
|
||||
|
||||
{/* Dividers every 45° */}
|
||||
{Array.from({ length: 8 }, (_, i) => {
|
||||
const p = polarXY(CX, CY, R, i * 45)
|
||||
return <line key={i} x1={CX} y1={CY} x2={p.x} y2={p.y}
|
||||
stroke="var(--border)" strokeWidth={1} />
|
||||
})}
|
||||
|
||||
{/* Wind arrow */}
|
||||
<g style={{ transition: 'all 0.6s ease' }}>
|
||||
<polygon
|
||||
points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`}
|
||||
fill={color} opacity={0.85}
|
||||
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
|
||||
stroke={color} strokeWidth={2}
|
||||
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<circle cx={CX} cy={CY} r={5} fill="var(--surface2)" stroke={color} strokeWidth={2} />
|
||||
|
||||
{/* Speed value */}
|
||||
<text x={CX} y={CY + 26} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
|
||||
{speed.toFixed(1)}
|
||||
</text>
|
||||
<text x={CX} y={CY + 40} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
kn
|
||||
</text>
|
||||
|
||||
{/* Labels */}
|
||||
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => {
|
||||
const p = polarXY(CX, CY, R + 12, a)
|
||||
return (
|
||||
<text key={l} x={p.x} y={p.y + 4} textAnchor="middle"
|
||||
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
|
||||
{l}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
|
||||
<text x={CX} y={186} textAnchor="middle"
|
||||
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
|
||||
WIND
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user