feat: complete dashboard redesign, proxy unification, and Windows compatibility fixes
This commit is contained in:
@@ -7,6 +7,42 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# SignalK Proxy (incl. WebSocket)
|
||||
location /signalk {
|
||||
proxy_pass http://signalk:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Snapcast Proxy (incl. WebSocket)
|
||||
location /snapcast-ws {
|
||||
proxy_pass http://snapserver:1780;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Mopidy Proxy (incl. WebSocket)
|
||||
location /mopidy {
|
||||
proxy_pass http://mopidy:6680;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Jellyfin Proxy
|
||||
location /jellyfin {
|
||||
proxy_pass http://jellyfin:8096;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
|
||||
@@ -11,6 +11,8 @@ export default function NavigationMap() {
|
||||
const shipMarkerRef = useRef(null)
|
||||
const trackSourceRef = useRef(false)
|
||||
const [zoom, setZoom] = useState(11)
|
||||
const [mapError, setMapError] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const api = getApi()
|
||||
const snapshot = api.signalk.getSnapshot?.()
|
||||
@@ -22,7 +24,9 @@ export default function NavigationMap() {
|
||||
useEffect(() => {
|
||||
if (map.current) return
|
||||
|
||||
map.current = new maplibregl.Map({
|
||||
try {
|
||||
setIsLoading(true)
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: {
|
||||
version: 8,
|
||||
@@ -177,9 +181,28 @@ export default function NavigationMap() {
|
||||
})
|
||||
|
||||
map.current.on('zoom', () => setZoom(map.current.getZoom()))
|
||||
|
||||
map.current.on('load', () => {
|
||||
setIsLoading(false)
|
||||
setMapError(null)
|
||||
})
|
||||
|
||||
map.current.on('error', (e) => {
|
||||
console.error('Map error:', e)
|
||||
setMapError('Map failed to load. Check your internet connection.')
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Cleanup is handled by React
|
||||
if (map.current) {
|
||||
map.current.remove()
|
||||
map.current = null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize map:', error)
|
||||
setMapError(`Map initialization failed: ${error.message}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -191,11 +214,14 @@ export default function NavigationMap() {
|
||||
const trackSource = map.current.getSource('track')
|
||||
if (trackSource && trackSourceRef.current) {
|
||||
const currentData = trackSource._data
|
||||
if (currentData.geometry.coordinates.length > 500) {
|
||||
currentData.geometry.coordinates.shift() // Keep last 500 points
|
||||
// Check if geometry exists and has coordinates array
|
||||
if (currentData?.geometry?.coordinates) {
|
||||
if (currentData.geometry.coordinates.length > 500) {
|
||||
currentData.geometry.coordinates.shift() // Keep last 500 points
|
||||
}
|
||||
currentData.geometry.coordinates.push([lon, lat])
|
||||
trackSource.setData(currentData)
|
||||
}
|
||||
currentData.geometry.coordinates.push([lon, lat])
|
||||
trackSource.setData(currentData)
|
||||
}
|
||||
|
||||
map.current.getSource('ship').setData({
|
||||
@@ -214,8 +240,34 @@ export default function NavigationMap() {
|
||||
}
|
||||
}, [lat, lon, heading, zoom])
|
||||
|
||||
// Show error state
|
||||
if (mapError) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.errorState}>
|
||||
<div style={styles.errorIcon}>⚠️</div>
|
||||
<div style={styles.errorTitle}>Karte konnte nicht geladen werden</div>
|
||||
<div style={styles.errorMessage}>{mapError}</div>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{isLoading && (
|
||||
<div style={styles.loadingOverlay}>
|
||||
<div style={styles.spinner}></div>
|
||||
<div style={{ marginTop: 12, color: 'var(--muted)' }}>Karte wird geladen...</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={mapContainer} style={styles.mapBox} />
|
||||
|
||||
{/* Map Controls */}
|
||||
@@ -384,4 +436,51 @@ const styles = {
|
||||
padding: '4px 8px',
|
||||
zIndex: 500,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'var(--surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
spinner: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '4px solid var(--border)',
|
||||
borderTop: '4px solid var(--accent)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
errorState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
color: 'var(--muted)',
|
||||
maxWidth: 400,
|
||||
},
|
||||
}
|
||||
|
||||
204
dashboard/src/components/nav/SimpleNavigationMap.jsx
Normal file
204
dashboard/src/components/nav/SimpleNavigationMap.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
|
||||
// Simple fallback map component without MapLibre (no external dependencies)
|
||||
export default function SimpleNavigationMap() {
|
||||
const { lat, lon, heading, sog, cog } = useNMEA()
|
||||
|
||||
const mapLat = lat ?? 54.32
|
||||
const mapLon = lon ?? 10.22
|
||||
|
||||
// Simple marker rotation based on heading
|
||||
const rotation = heading ?? cog ?? 0
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Info Panel */}
|
||||
<div style={styles.infoPanel}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Position</span>
|
||||
<span style={styles.value}>
|
||||
{lat != null ? lat.toFixed(5) : '--'}° N
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}></span>
|
||||
<span style={styles.value}>
|
||||
{lon != null ? lon.toFixed(5) : '--'}° E
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{heading != null && (
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Heading</span>
|
||||
<span style={styles.value}>{Math.round(heading)}°</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sog != null && (
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Speed</span>
|
||||
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simple visual representation */}
|
||||
<div style={styles.mapView}>
|
||||
<div style={styles.compassRose}>
|
||||
<div style={styles.northMarker}>N</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.shipMarker,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.coords}>
|
||||
{lat != null && lon != null ? (
|
||||
<>
|
||||
<div>{lat.toFixed(5)}°</div>
|
||||
<div>{lon.toFixed(5)}°</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)' }}>No GPS signal</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map notice */}
|
||||
<div style={styles.notice}>
|
||||
<div style={styles.noticeTitle}>🗺️ Karten-Modus</div>
|
||||
<div style={styles.noticeText}>
|
||||
Interaktive Seekarte ist im Production Mode verfügbar.
|
||||
<br />
|
||||
Hier siehst du Position, Kurs und Geschwindigkeit.
|
||||
</div>
|
||||
<a
|
||||
href="http://localhost:3000"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
→ SignalK Karte öffnen (Port 3000)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
minHeight: 400,
|
||||
},
|
||||
mapView: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 32,
|
||||
padding: 32,
|
||||
},
|
||||
compassRose: {
|
||||
position: 'relative',
|
||||
width: 180,
|
||||
height: 180,
|
||||
border: '2px solid var(--accent)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'radial-gradient(circle, rgba(14, 165, 233, 0.05) 0%, transparent 70%)',
|
||||
},
|
||||
northMarker: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
shipMarker: {
|
||||
fontSize: 48,
|
||||
color: 'var(--accent)',
|
||||
transition: 'transform 0.5s ease-out',
|
||||
},
|
||||
coords: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
infoPanel: {
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '12px 16px',
|
||||
margin: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
},
|
||||
infoRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
},
|
||||
label: {
|
||||
color: 'var(--muted)',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
value: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
fontSize: 13,
|
||||
},
|
||||
notice: {
|
||||
background: 'var(--surface2)',
|
||||
padding: 20,
|
||||
borderTop: '1px solid var(--border)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
noticeTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: 12,
|
||||
color: 'var(--muted)',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 12,
|
||||
},
|
||||
link: {
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
}
|
||||
222
dashboard/src/components/systems/BoatControl.jsx
Normal file
222
dashboard/src/components/systems/BoatControl.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { useNMEA } from '../../hooks/useNMEA';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'lights', icon: '💡', label: 'Lights', pos: { top: '15%', left: '10%' } },
|
||||
{ id: 'climate', icon: '❄️', label: 'Climate', pos: { top: '35%', left: '5%' } },
|
||||
{ id: 'nav', icon: '📍', label: 'Navigation', pos: { top: '55%', left: '5%' } },
|
||||
{ id: 'audio', icon: '🎵', label: 'Audio', pos: { top: '75%', left: '10%' } },
|
||||
{ id: 'battery', icon: '🔋', label: 'Energy', pos: { bottom: '15%', left: '15%' } },
|
||||
|
||||
{ id: 'tanks', icon: '🌊', label: 'Tanks', pos: { top: '15%', right: '10%' } },
|
||||
{ id: 'power', icon: '🔌', label: 'Shore Power', pos: { top: '35%', right: '5%' } },
|
||||
{ id: 'wind', icon: '🌬️', label: 'Wind', pos: { top: '55%', right: '5%' } },
|
||||
{ id: 'engine', icon: '⚙️', label: 'Engine', pos: { top: '75%', right: '10%' } },
|
||||
];
|
||||
|
||||
export default function BoatControl({ activeCategory, onCategoryChange }) {
|
||||
const { sog, heading } = useNMEA();
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.shipControlLabel}>SHIP CONTROL</div>
|
||||
|
||||
<div style={styles.mainArea}>
|
||||
{/* Category Icons Left */}
|
||||
{CATEGORIES.slice(0, 5).map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
style={{
|
||||
...styles.catBtn,
|
||||
...cat.pos,
|
||||
...(activeCategory === cat.id ? styles.catBtnActive : {})
|
||||
}}
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span style={styles.catIcon}>{cat.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Central Boat Graphic */}
|
||||
<div style={styles.boatContainer}>
|
||||
<img
|
||||
src="https://www.prestige-yachts.com/fichiers/Prestige_F4.9_Profile_600.png"
|
||||
alt="Prestige Yacht"
|
||||
style={styles.boatImg}
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div style={{...styles.boatFallback, display: 'none'}}>
|
||||
<div style={styles.boatHull}></div>
|
||||
</div>
|
||||
|
||||
{/* Central Instrument Over Boat */}
|
||||
<div style={styles.centralGauge}>
|
||||
<div style={styles.gaugeValue}>{sog?.toFixed(1) || '0.0'}</div>
|
||||
<div style={styles.gaugeUnit}>knots</div>
|
||||
<div style={styles.gaugeHeading}>{Math.round(heading || 0)}°</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Icons Right */}
|
||||
{CATEGORIES.slice(5).map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
style={{
|
||||
...styles.catBtn,
|
||||
...cat.pos,
|
||||
...(activeCategory === cat.id ? styles.catBtnActive : {})
|
||||
}}
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span style={styles.catIcon}>{cat.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={styles.bottomBar}>
|
||||
<div style={styles.bottomIcon}>⚙️</div>
|
||||
<div style={styles.time}>{new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div style={styles.bottomIcon}>📖</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shipControlLabel: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontSize: 48,
|
||||
fontWeight: 300,
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
mainArea: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
catBtn: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||
zIndex: 10,
|
||||
},
|
||||
catBtnActive: {
|
||||
background: 'rgba(14, 165, 233, 0.2)',
|
||||
borderColor: '#0ea5e9',
|
||||
boxShadow: '0 0 20px rgba(14, 165, 233, 0.4)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
catIcon: {
|
||||
fontSize: 24,
|
||||
opacity: 0.8,
|
||||
},
|
||||
boatContainer: {
|
||||
position: 'relative',
|
||||
width: '70%',
|
||||
maxWidth: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'fadeIn 1s ease-out',
|
||||
},
|
||||
boatImg: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
filter: 'drop-shadow(0 20px 30px rgba(0,0,0,0.5)) brightness(1.1)',
|
||||
},
|
||||
boatFallback: {
|
||||
width: 400,
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
boatHull: {
|
||||
width: '100%',
|
||||
height: 40,
|
||||
background: '#fff',
|
||||
borderRadius: '50% 50% 10% 10%',
|
||||
},
|
||||
centralGauge: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(7, 17, 31, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '2px solid rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 0 40px rgba(0,0,0,0.5)',
|
||||
},
|
||||
gaugeValue: {
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: '#fff',
|
||||
},
|
||||
gaugeUnit: {
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted)',
|
||||
letterSpacing: '0.1em',
|
||||
marginTop: -4,
|
||||
},
|
||||
gaugeHeading: {
|
||||
marginTop: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
bottomBar: {
|
||||
height: 60,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 40,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
bottomIcon: {
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
time: {
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}
|
||||
};
|
||||
125
dashboard/src/components/systems/FloorPlan.jsx
Normal file
125
dashboard/src/components/systems/FloorPlan.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
|
||||
const ZONES = [
|
||||
{ id: 'salon', label: 'Salon', top: '40%', left: '30%', width: '20%', height: '20%' },
|
||||
{ id: 'cockpit', label: 'Cockpit', top: '40%', left: '55%', width: '15%', height: '20%' },
|
||||
{ id: 'bug', label: 'Owner Cabin', top: '40%', left: '10%', width: '15%', height: '20%' },
|
||||
{ id: 'heck', label: 'VIP Cabin', top: '40%', left: '75%', width: '15%', height: '20%' },
|
||||
];
|
||||
|
||||
export default function FloorPlan({ type, onZoneClick }) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.floorPlanWrapper}>
|
||||
{/* Simple SVG/CSS representation of a boat deck */}
|
||||
<div style={styles.deckOutline}>
|
||||
{ZONES.map(zone => (
|
||||
<div
|
||||
key={zone.id}
|
||||
style={{
|
||||
...styles.zone,
|
||||
top: zone.top,
|
||||
left: zone.left,
|
||||
width: zone.width,
|
||||
height: zone.height,
|
||||
}}
|
||||
onClick={() => onZoneClick(zone.id)}
|
||||
>
|
||||
<div style={styles.zoneLabel}>{zone.label}</div>
|
||||
<div style={styles.zoneStatus}>
|
||||
{type === 'lights' ? '💡 80%' : '🌡️ 22°C'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.controls}>
|
||||
<div style={styles.controlHeader}>{type === 'lights' ? 'Lighting Control' : 'Climate Control'}</div>
|
||||
<div style={styles.controlRow}>
|
||||
<span>Master Switch</span>
|
||||
<button style={styles.toggle}>ON</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 30,
|
||||
animation: 'slideInUp 0.4s ease-out',
|
||||
},
|
||||
floorPlanWrapper: {
|
||||
width: '100%',
|
||||
maxWidth: 900,
|
||||
height: 300,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
},
|
||||
deckOutline: {
|
||||
width: '90%',
|
||||
height: '60%',
|
||||
border: '2px solid rgba(255,255,255,0.2)',
|
||||
borderRadius: '80px 80px 40px 40px',
|
||||
position: 'relative',
|
||||
},
|
||||
zone: {
|
||||
position: 'absolute',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
zoneLabel: {
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
zoneStatus: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
controls: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: 'var(--glass-bg)',
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
},
|
||||
controlHeader: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
marginBottom: 15,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
controlRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
toggle: {
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}
|
||||
};
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const SERVICES = [
|
||||
{ id: 'signalk', name: 'SignalK', host: import.meta.env.VITE_SIGNALK_HOST || 'localhost', port: 3000, path: '/signalk' },
|
||||
{ id: 'snapserver', name: 'Snapcast', host: import.meta.env.VITE_SNAPCAST_HOST || 'localhost', port: 1780, path: '/' },
|
||||
{ id: 'mopidy', name: 'Mopidy', host: import.meta.env.VITE_MOPIDY_HOST || 'localhost', port: 6680, path: '/' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin', host: import.meta.env.VITE_JELLYFIN_HOST || 'localhost', port: 8096, path: '/' },
|
||||
{ id: 'portainer', name: 'Portainer', host: import.meta.env.VITE_PORTAINER_HOST || 'localhost', port: 9000, path: '/' },
|
||||
{ id: 'signalk', name: 'SignalK', path: '/signalk' },
|
||||
{ id: 'snapserver', name: 'Snapcast', path: '/snapcast-ws' },
|
||||
{ id: 'mopidy', name: 'Mopidy', path: '/mopidy' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin', path: '/jellyfin/' },
|
||||
{ id: 'portainer', name: 'Portainer', path: '/portainer' },
|
||||
{ id: 'spotify', name: 'Spotify', path: '/snapcast-ws' }, // Use snapcast as proxy for its status
|
||||
{ id: 'airplay', name: 'AirPlay', path: '/snapcast-ws' },
|
||||
]
|
||||
|
||||
async function ping(host, port, path) {
|
||||
async function ping(path) {
|
||||
try {
|
||||
const host = window.location.host
|
||||
// mode: 'no-cors' bypasses CORS blocks; any response (opaque) = server is up
|
||||
await fetch(`http://${host}:${port}${path}`, {
|
||||
await fetch(`http://${host}${path}`, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
@@ -24,16 +27,26 @@ async function ping(host, port, path) {
|
||||
|
||||
export function useDocker() {
|
||||
const [services, setServices] = useState(
|
||||
SERVICES.map(s => ({ ...s, url: `http://${s.host}:${s.port}`, status: 'unknown' }))
|
||||
SERVICES.map(s => ({ ...s, url: `http://${window.location.host}${s.path}`, status: 'unknown' }))
|
||||
)
|
||||
|
||||
async function checkAll() {
|
||||
const results = await Promise.all(
|
||||
SERVICES.map(async s => ({
|
||||
...s,
|
||||
url: `http://${s.host}:${s.port}`,
|
||||
status: await ping(s.host, s.port, s.path) ? 'online' : 'offline',
|
||||
}))
|
||||
SERVICES.map(async s => {
|
||||
const url = `http://${window.location.host}${s.path}`
|
||||
let status = 'offline'
|
||||
|
||||
if (s.id === 'spotify' || s.id === 'airplay') {
|
||||
// These are special, they don't have their own UI
|
||||
// We assume they are up if snapserver is up (for now)
|
||||
// Ideally we check snapcast streams status
|
||||
status = await ping('/snapcast-ws') ? 'online' : 'offline'
|
||||
} else {
|
||||
status = await ping(s.path) ? 'online' : 'offline'
|
||||
}
|
||||
|
||||
return { ...s, url, status }
|
||||
})
|
||||
)
|
||||
setServices(results)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ function parseStatus(status) {
|
||||
|
||||
export function useZones() {
|
||||
const [zones, setZones] = useState([])
|
||||
const [streams, setStreams] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const { snapcast } = getApi()
|
||||
|
||||
@@ -28,6 +29,7 @@ export function useZones() {
|
||||
const status = await snapcast.call('Server.GetStatus')
|
||||
if (alive) {
|
||||
setZones(parseStatus(status))
|
||||
setStreams(status?.server?.streams || [])
|
||||
setConnected(true)
|
||||
}
|
||||
} catch {
|
||||
@@ -36,7 +38,10 @@ export function useZones() {
|
||||
}
|
||||
|
||||
const onUpdate = (msg) => {
|
||||
if (msg?.result?.server) setZones(parseStatus(msg.result))
|
||||
if (msg?.result?.server) {
|
||||
setZones(parseStatus(msg.result))
|
||||
setStreams(msg.result.server.streams || [])
|
||||
}
|
||||
}
|
||||
|
||||
snapcast.on('update', onUpdate)
|
||||
@@ -65,5 +70,5 @@ export function useZones() {
|
||||
if (zone) await setMuted(zoneId, !zone.muted)
|
||||
}, [zones, setMuted])
|
||||
|
||||
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
|
||||
return { zones, streams, connected, setVolume, setMuted, setSource, toggleZone }
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ html, body, #root {
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ─── Animations ───────────────────────────────────────────────────────────── */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// API router – uses real services by default; set VITE_USE_MOCK=true to force mocks.
|
||||
// API router – uses real services with automatic mock fallback if connection fails.
|
||||
import { createSignalKMock } from './signalk.mock.js'
|
||||
import { createSnapcastMock } from './snapcast.mock.js'
|
||||
import { createMopidyMock } from './mopidy.mock.js'
|
||||
import { createSignalKClient } from '../api/signalk.js'
|
||||
import { createSnapcastClient } from '../api/snapcast.js'
|
||||
import { createMopidyClient } from '../api/mopidy.js'
|
||||
import { createJellyfinClient } from '../api/jellyfin.js'
|
||||
|
||||
const forceMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
@@ -14,18 +15,82 @@ export function createApi() {
|
||||
signalk: createSignalKMock(),
|
||||
snapcast: createSnapcastMock(),
|
||||
mopidy: createMopidyMock(),
|
||||
jellyfin: createJellyfinClient('http://localhost:8090/jellyfin', 'fake-key'),
|
||||
isMock: true,
|
||||
}
|
||||
}
|
||||
|
||||
const snapcastHost = import.meta.env.VITE_SNAPCAST_HOST || 'localhost'
|
||||
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||
const mopidyHost = import.meta.env.VITE_MOPIDY_HOST || 'localhost'
|
||||
// Real clients - use proxy-friendly URLs (going through port 8090)
|
||||
const host = window.location.host
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const httpProtocol = window.location.protocol
|
||||
|
||||
// SignalK: baseUrl becomes ws://host/signalk (v1/stream appended in client)
|
||||
const signalkReal = createSignalKClient(`${protocol}//${host}`)
|
||||
|
||||
// Snapcast: ws://host/snapcast-ws/jsonrpc
|
||||
const snapcastUrl = `${protocol}//${host}/snapcast-ws/jsonrpc`
|
||||
const snapcastReal = createSnapcastClient(snapcastUrl)
|
||||
|
||||
// Mopidy: ws://host/mopidy (ws appended in client)
|
||||
const mopidyReal = createMopidyClient(`${protocol}//${host}`)
|
||||
|
||||
// Jellyfin: http://host/jellyfin
|
||||
const jellyfinReal = createJellyfinClient(`${httpProtocol}//${host}/jellyfin`, 'YOUR_JELLYFIN_API_KEY')
|
||||
|
||||
const signalkMock = createSignalKMock()
|
||||
|
||||
// Proxy that switches between real and mock
|
||||
const signalkProxy = {
|
||||
listeners: {},
|
||||
on: (event, handler) => {
|
||||
if (!signalkProxy.listeners[event]) signalkProxy.listeners[event] = []
|
||||
signalkProxy.listeners[event].push(handler)
|
||||
signalkReal.on(event, handler)
|
||||
signalkMock.on(event, handler)
|
||||
},
|
||||
off: (event, handler) => {
|
||||
if (signalkProxy.listeners[event]) {
|
||||
signalkProxy.listeners[event] = signalkProxy.listeners[event].filter(h => h !== handler)
|
||||
}
|
||||
signalkReal.off(event, handler)
|
||||
signalkMock.off(event, handler)
|
||||
},
|
||||
emit: (event, data) => {
|
||||
if (signalkProxy.listeners[event]) {
|
||||
signalkProxy.listeners[event].forEach(h => h(data))
|
||||
}
|
||||
},
|
||||
start: () => signalkMock.start(),
|
||||
stop: () => signalkMock.stop(),
|
||||
getSnapshot: () => signalkMock.getSnapshot(),
|
||||
}
|
||||
|
||||
let usingSignalKMock = false
|
||||
let signalkTimeout = setTimeout(() => {
|
||||
// If no real data after 5 seconds, switch to mock
|
||||
if (!usingSignalKMock) {
|
||||
console.log('⚠️ SignalK not connected - using mock data (EYC Lingen → Ems route)')
|
||||
usingSignalKMock = true
|
||||
signalkMock.start()
|
||||
signalkProxy.emit('connected', null)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// If real connection succeeds, cancel mock
|
||||
signalkReal.on('connected', () => {
|
||||
clearTimeout(signalkTimeout)
|
||||
if (usingSignalKMock) {
|
||||
signalkMock.stop()
|
||||
usingSignalKMock = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
|
||||
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
|
||||
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
|
||||
signalk: signalkProxy,
|
||||
snapcast: snapcastReal,
|
||||
mopidy: mopidyReal,
|
||||
jellyfin: jellyfinReal,
|
||||
isMock: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
|
||||
// The ship navigates a realistic route around Bornholm Island, Baltic Sea.
|
||||
// Simulates a SignalK WebSocket delta stream with realistic boat data.
|
||||
// Route: Lokale Fahrt in Lingen über DEK und Ems (bleibt in der Region)
|
||||
|
||||
const INTERVAL_MS = 1000
|
||||
|
||||
function degToRad(d) { return d * Math.PI / 180 }
|
||||
function radToDeg(r) { return r * 180 / Math.PI }
|
||||
|
||||
// Realistic sailing route around Bornholm Island, Baltic Sea
|
||||
// Lokale Route: Lingen Bereich - DEK und Ems (bleibt in der Region)
|
||||
const WAYPOINTS = [
|
||||
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Start)' },
|
||||
{ lat: 55.0500, lon: 13.5500, name: 'Bornholm North' },
|
||||
{ lat: 55.1200, lon: 14.8000, name: 'Rønne Harbor' },
|
||||
{ lat: 54.9500, lon: 15.2000, name: 'Bornholm East' },
|
||||
{ lat: 54.5800, lon: 14.9000, name: 'Bornholm South' },
|
||||
{ lat: 54.1500, lon: 13.2000, name: 'Gdansk Approach' },
|
||||
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Loop)' },
|
||||
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub Lingen' },
|
||||
{ lat: 52.5280, lon: 7.3150, name: 'Kanal Ausfahrt' },
|
||||
{ lat: 52.5400, lon: 7.3000, name: 'DEK Westlich' },
|
||||
{ lat: 52.5500, lon: 7.2800, name: 'DEK Schleife West' },
|
||||
{ lat: 52.5600, lon: 7.2700, name: 'DEK Nord' },
|
||||
{ lat: 52.5700, lon: 7.2800, name: 'Brücke Nord' },
|
||||
{ lat: 52.5750, lon: 7.3000, name: 'Ems Einfahrt' },
|
||||
{ lat: 52.5800, lon: 7.3200, name: 'Ems Fluss Ost' },
|
||||
{ lat: 52.5750, lon: 7.3400, name: 'Ems Kurve' },
|
||||
{ lat: 52.5650, lon: 7.3500, name: 'Ems Süd' },
|
||||
{ lat: 52.5500, lon: 7.3450, name: 'Ems Rückkehr' },
|
||||
{ lat: 52.5400, lon: 7.3350, name: 'Kanal Rückkehr' },
|
||||
{ lat: 52.5300, lon: 7.3250, name: 'Hafen Approach' },
|
||||
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub (Ziel)' },
|
||||
]
|
||||
|
||||
// Calculate distance between two coordinates in nautical miles
|
||||
|
||||
@@ -1,42 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useNMEA } from '../hooks/useNMEA.js'
|
||||
import Compass from '../components/instruments/Compass.jsx'
|
||||
import SpeedLog from '../components/instruments/SpeedLog.jsx'
|
||||
import DepthSounder from '../components/instruments/DepthSounder.jsx'
|
||||
import WindRose from '../components/instruments/WindRose.jsx'
|
||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||
import BoatControl from '../components/systems/BoatControl.jsx'
|
||||
import FloorPlan from '../components/systems/FloorPlan.jsx'
|
||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||
import EngineData from '../components/systems/EngineData.jsx'
|
||||
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
|
||||
|
||||
export default function Overview() {
|
||||
const nmea = useNMEA()
|
||||
const [activeCategory, setActiveCategory] = useState(null)
|
||||
|
||||
const handleBack = () => setActiveCategory(null)
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
{/* Instruments row */}
|
||||
<section style={styles.instruments}>
|
||||
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||
<SpeedLog sog={nmea.sog} />
|
||||
<DepthSounder depth={nmea.depth} />
|
||||
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||
</section>
|
||||
|
||||
{/* Now Playing */}
|
||||
<NowPlaying compact />
|
||||
|
||||
{/* Zone quick overview */}
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Audio Zones</div>
|
||||
<ZoneGrid />
|
||||
</section>
|
||||
{!activeCategory ? (
|
||||
<BoatControl
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.detailView}>
|
||||
<div style={styles.detailHeader}>
|
||||
<button onClick={handleBack} style={styles.backBtn}>← Back</button>
|
||||
<h2 style={styles.detailTitle}>{activeCategory.toUpperCase()}</h2>
|
||||
</div>
|
||||
|
||||
<div style={styles.detailContent}>
|
||||
{activeCategory === 'lights' && <FloorPlan type="lights" />}
|
||||
{activeCategory === 'climate' && <FloorPlan type="climate" />}
|
||||
{activeCategory === 'audio' && (
|
||||
<div style={styles.audioWrapper}>
|
||||
<ZoneGrid />
|
||||
</div>
|
||||
)}
|
||||
{activeCategory === 'nav' && <InstrumentPanel />}
|
||||
{activeCategory === 'engine' && <EngineData />}
|
||||
{activeCategory === 'battery' && <BatteryStatus />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'auto', flex: 1 },
|
||||
instruments: {
|
||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16, background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||
page: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#07111f',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||
detailView: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'fadeIn 0.4s ease-out',
|
||||
padding: 20,
|
||||
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
|
||||
},
|
||||
detailHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
backBtn: {
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'var(--text)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
detailTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 300,
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
margin: 0,
|
||||
},
|
||||
detailContent: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 10px',
|
||||
},
|
||||
audioWrapper: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user