- Fixed ChartPlaceholder canvas rendering by replacing CSS variables with hardcoded colors * Canvas context cannot use CSS variables; must use RGB/hex colors directly * Now renders ship track, waypoints, heading indicator, and legends correctly - Enhanced ZoneCard with connection indicators * Added visual source badges (Spotify 🎵, AirPlay 🎙️, Mopidy 📻) * Color-coded left border matching source (green for Spotify, blue for AirPlay, amber for Mopidy) * Added source dropdown selector for switching audio sources * Shows grouped zone info when zones are merged - Implemented zone grouping system in ZoneGrid * Left sidebar panel to view and manage active zone groups * Click 🔗 button to create/remove zone groups * When zones are grouped, changing source updates all members * Each zone can show which other zones it's grouped with - All 64 modules build successfully - Mock data properly flows through Snapcast with Spotify/AirPlay/Mopidy sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
225 lines
6.6 KiB
JavaScript
225 lines
6.6 KiB
JavaScript
import { useEffect, useRef } from 'react'
|
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
|
import { getApi } from '../../mock/index.js'
|
|
|
|
export default function ChartPlaceholder() {
|
|
const { lat, lon, heading, sog } = useNMEA()
|
|
const canvasRef = useRef(null)
|
|
const trackRef = useRef([])
|
|
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
|
|
|
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
|
|
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
|
|
|
// Colors from CSS (hardcoded for canvas compatibility)
|
|
const colors = {
|
|
bg: '#07111f',
|
|
surface: '#0a1928',
|
|
border: '#1e2a3a',
|
|
text: '#e2eaf2',
|
|
muted: '#4a6080',
|
|
accent: '#38bdf8',
|
|
warning: '#f59e0b',
|
|
}
|
|
|
|
// Draw track and waypoints
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas || !isMock) return
|
|
|
|
// Add current position to track
|
|
if (lat != null && lon != null) {
|
|
trackRef.current.push({ lat, lon, time: Date.now() })
|
|
// Keep last 500 points for performance
|
|
if (trackRef.current.length > 500) {
|
|
trackRef.current.shift()
|
|
}
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
|
|
// Get API for waypoints
|
|
const api = getApi()
|
|
const snapshot = api.signalk.getSnapshot?.()
|
|
const waypoints = api.signalk.getWaypoints?.() || []
|
|
|
|
// Calculate bounds
|
|
const allPoints = [...trackRef.current, ...waypoints]
|
|
if (allPoints.length === 0) return
|
|
|
|
let minLat = allPoints[0].lat
|
|
let maxLat = allPoints[0].lat
|
|
let minLon = allPoints[0].lon
|
|
let maxLon = allPoints[0].lon
|
|
|
|
allPoints.forEach(p => {
|
|
minLat = Math.min(minLat, p.lat)
|
|
maxLat = Math.max(maxLat, p.lat)
|
|
minLon = Math.min(minLon, p.lon)
|
|
maxLon = Math.max(maxLon, p.lon)
|
|
})
|
|
|
|
const padding = Math.max(maxLat - minLat, maxLon - minLon) * 0.1
|
|
minLat -= padding
|
|
maxLat += padding
|
|
minLon -= padding
|
|
maxLon += padding
|
|
|
|
const width = canvas.width
|
|
const height = canvas.height
|
|
|
|
const latRange = maxLat - minLat
|
|
const lonRange = maxLon - minLon
|
|
const scale = Math.min(width / lonRange, height / latRange)
|
|
|
|
const project = (lat, lon) => ({
|
|
x: (lon - minLon) * scale,
|
|
y: height - (lat - minLat) * scale
|
|
})
|
|
|
|
// Clear and draw background
|
|
ctx.fillStyle = colors.surface
|
|
ctx.fillRect(0, 0, width, height)
|
|
ctx.strokeStyle = colors.border
|
|
ctx.lineWidth = 1
|
|
ctx.strokeRect(0, 0, width, height)
|
|
|
|
// Draw waypoint markers
|
|
waypoints.forEach((wp, idx) => {
|
|
const p = project(wp.lat, wp.lon)
|
|
|
|
// Waypoint circle
|
|
ctx.fillStyle = idx === snapshot?.currentWaypoint ? colors.accent : colors.warning
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
|
|
ctx.fill()
|
|
|
|
// Waypoint number
|
|
ctx.fillStyle = colors.bg
|
|
ctx.font = 'bold 10px monospace'
|
|
ctx.textAlign = 'center'
|
|
ctx.textBaseline = 'middle'
|
|
ctx.fillText(idx + 1, p.x, p.y)
|
|
})
|
|
|
|
// Draw track line
|
|
if (trackRef.current.length > 1) {
|
|
ctx.strokeStyle = colors.accent
|
|
ctx.lineWidth = 2
|
|
ctx.beginPath()
|
|
trackRef.current.forEach((p, idx) => {
|
|
const proj = project(p.lat, p.lon)
|
|
if (idx === 0) ctx.moveTo(proj.x, proj.y)
|
|
else ctx.lineTo(proj.x, proj.y)
|
|
})
|
|
ctx.stroke()
|
|
}
|
|
|
|
// Draw ship marker
|
|
if (lat != null && lon != null) {
|
|
const p = project(lat, lon)
|
|
|
|
// Ship heading indicator (triangle)
|
|
const headRad = (heading ?? 0) * Math.PI / 180
|
|
const size = 12
|
|
|
|
ctx.save()
|
|
ctx.translate(p.x, p.y)
|
|
ctx.rotate(headRad)
|
|
ctx.fillStyle = colors.accent
|
|
ctx.beginPath()
|
|
ctx.moveTo(0, -size)
|
|
ctx.lineTo(-size / 2, size / 2)
|
|
ctx.lineTo(size / 2, size / 2)
|
|
ctx.closePath()
|
|
ctx.fill()
|
|
ctx.restore()
|
|
|
|
// Ship circle
|
|
ctx.strokeStyle = colors.accent
|
|
ctx.lineWidth = 2
|
|
ctx.beginPath()
|
|
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2)
|
|
ctx.stroke()
|
|
}
|
|
}, [lat, lon, heading, isMock])
|
|
|
|
if (!isMock) {
|
|
// Show iframe for real SignalK server
|
|
return (
|
|
<div style={styles.container}>
|
|
<iframe
|
|
src={chartUrl}
|
|
style={styles.iframe}
|
|
title="Chart"
|
|
onError={() => {}}
|
|
/>
|
|
{lat != null && (
|
|
<div style={styles.coords}>
|
|
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
|
|
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Show canvas-based map for mock mode
|
|
return (
|
|
<div style={styles.container}>
|
|
<canvas
|
|
ref={canvasRef}
|
|
width={800}
|
|
height={600}
|
|
style={styles.canvas}
|
|
/>
|
|
<div style={styles.legend}>
|
|
<div style={styles.legendItem}>
|
|
<div style={{ ...styles.legendColor, background: 'var(--accent)' }} />
|
|
<span>Ship Track</span>
|
|
</div>
|
|
<div style={styles.legendItem}>
|
|
<div style={{ ...styles.legendColor, background: 'var(--accent)', width: 12, height: 12, borderRadius: 2 }} />
|
|
<span>Current Waypoint</span>
|
|
</div>
|
|
<div style={styles.legendItem}>
|
|
<div style={{ ...styles.legendColor, background: 'var(--warning)', width: 12, height: 12, borderRadius: 2 }} />
|
|
<span>Next Waypoints</span>
|
|
</div>
|
|
</div>
|
|
{lat != null && (
|
|
<div style={styles.coords}>
|
|
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
|
|
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
|
|
{sog != null && <span style={styles.coord}>{(sog * 1.943844).toFixed(1)} kn</span>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const styles = {
|
|
container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)', background: 'var(--surface)' },
|
|
canvas: { width: '100%', height: '100%', display: 'block' },
|
|
iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface)' },
|
|
coords: {
|
|
position: 'absolute', bottom: 12, left: 12,
|
|
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
|
|
display: 'flex', gap: 12,
|
|
},
|
|
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
|
|
legend: {
|
|
position: 'absolute', top: 12, left: 12,
|
|
background: '#07111fcc', borderRadius: 6, padding: '8px 12px',
|
|
display: 'flex', flexDirection: 'column', gap: 6,
|
|
},
|
|
legendItem: {
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
fontSize: 12, color: 'var(--text)',
|
|
},
|
|
legendColor: {
|
|
width: 8, height: 8, borderRadius: '50%',
|
|
},
|
|
}
|