Fix navigation chart rendering and add Spotify/AirPlay zone indicators with grouping

- 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>
This commit is contained in:
2026-03-27 14:45:22 +01:00
parent 19b2c30a0a
commit 99a1aa6460
3 changed files with 247 additions and 27 deletions

View File

@@ -1,22 +1,67 @@
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
const { id, name, active, volume, muted, source } = zone
// Map source to emoji and connection info
const sourceInfo = {
'Spotify': { emoji: '🎵', color: '#1DB954', label: 'Spotify' },
'AirPlay': { emoji: '🎙️', color: '#60a5fa', label: 'AirPlay' },
'Mopidy': { emoji: '📻', color: '#f59e0b', label: 'Mopidy' },
}
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
return (
<div style={{
...styles.card,
opacity: active ? 1 : 0.45,
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
borderColor: active && !muted ? info.color : 'var(--border)',
borderLeftWidth: 4,
borderLeftColor: info.color,
}}>
<div style={styles.header}>
<span style={styles.name}>{name}</span>
<div style={styles.titleArea}>
<span style={styles.name}>{name}</span>
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
{info.emoji} {info.label}
</span>
</div>
<div style={styles.badges}>
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
{active ? 'ON' : 'OFF'}
{active ? '' : ''}
</span>
</div>
</div>
<div style={styles.source}>{source}</div>
{groupedWith && groupedWith.length > 0 && (
<div style={styles.groupInfo}>
<span style={styles.groupLabel}>Grouped with:</span>
<div style={styles.groupZones}>
{groupedWith.map(zoneName => (
<span key={zoneName} style={styles.groupZone}>{zoneName}</span>
))}
</div>
</div>
)}
<div style={styles.sourceControl}>
<select
value={source}
onChange={e => onSource(id, e.target.value)}
style={styles.sourceSelect}
>
<option value="Spotify">🎵 Spotify</option>
<option value="AirPlay">🎙 AirPlay</option>
<option value="Mopidy">📻 Mopidy</option>
</select>
{onGroup && (
<button
style={styles.groupBtn}
onClick={() => onGroup(id)}
title="Group/ungroup zones"
>
🔗
</button>
)}
</div>
<div style={styles.volumeRow}>
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
@@ -42,12 +87,43 @@ const styles = {
display: 'flex', flexDirection: 'column', gap: 10,
transition: 'border-color 0.2s, opacity 0.2s',
},
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
name: { fontWeight: 600, fontSize: 14 },
sourceTag: {
fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600,
display: 'inline-block', width: 'fit-content',
},
badges: { display: 'flex', gap: 4 },
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
source: { fontSize: 11, color: 'var(--muted)' },
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' },
groupInfo: {
fontSize: 11, color: 'var(--muted)',
background: '#38bdf811', padding: 8, borderRadius: 4,
borderLeft: '2px solid var(--accent)',
},
groupLabel: { fontWeight: 600, marginBottom: 4 },
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
groupZone: {
fontSize: 10, padding: '2px 6px', background: 'var(--accent)',
color: 'var(--bg)', borderRadius: 3, fontWeight: 600,
},
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
sourceSelect: {
flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)',
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
},
groupBtn: {
minWidth: 44, minHeight: 44, padding: 0,
background: 'var(--border)', color: 'var(--text)',
border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
fontSize: 18, transition: 'background 0.2s',
'&:hover': { background: 'var(--accent)' },
},
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' },
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
}

View File

@@ -1,32 +1,165 @@
import { useState } from 'react'
import { useZones } from '../../hooks/useZones.js'
import ZoneCard from './ZoneCard.jsx'
export default function ZoneGrid() {
const { zones, setVolume, setMuted, setSource } = useZones()
const [grouping, setGrouping] = useState({}) // { zoneId: [groupedZoneIds] }
if (!zones.length) {
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones</div>
}
const handleGroupToggle = (zoneId) => {
setGrouping(prev => {
const newGrouping = { ...prev }
if (newGrouping[zoneId]) {
delete newGrouping[zoneId]
} else {
// Start a new group with just this zone
newGrouping[zoneId] = []
}
return newGrouping
})
}
const handleSourceChange = (zoneId, newSource) => {
// Get all zones in this zone's group
const groupMembers = grouping[zoneId] || []
// Update main zone
setSource(zoneId, newSource)
// Update all grouped zones with the same source
groupMembers.forEach(memberId => {
setSource(memberId, newSource)
})
}
const getGroupedWith = (zoneId) => {
// Find which group this zone belongs to
for (const [groupId, members] of Object.entries(grouping)) {
if (members.includes(zoneId)) {
return [groupId, ...members.filter(m => m !== zoneId)]
.map(id => zones.find(z => z.id === id)?.name)
.filter(Boolean)
}
}
return []
}
return (
<div style={styles.grid}>
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
onVolume={setVolume}
onMute={setMuted}
onSource={setSource}
/>
))}
<div style={styles.container}>
<div style={styles.groupingPanel}>
<h3 style={styles.panelTitle}>Zone Groups</h3>
{Object.keys(grouping).length === 0 ? (
<div style={styles.emptyText}>No active groups. Click 🔗 to create a group.</div>
) : (
<div style={styles.groupList}>
{Object.entries(grouping).map(([groupId, members]) => {
const groupZones = [groupId, ...members].map(id => zones.find(z => z.id === id))
return (
<div key={groupId} style={styles.groupListItem}>
<div style={styles.groupName}>
{groupZones.map(z => z?.name).join(' + ')}
</div>
<button
onClick={() => handleGroupToggle(groupId)}
style={styles.ungroupBtn}
title="Remove group"
>
</button>
</div>
)
})}
</div>
)}
</div>
<div style={styles.grid}>
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
onVolume={setVolume}
onMute={setMuted}
onSource={(id, source) => handleSourceChange(id, source)}
onGroup={handleGroupToggle}
groupedWith={getGroupedWith(zone.id)}
/>
))}
</div>
</div>
)
}
const styles = {
container: {
display: 'grid',
gridTemplateColumns: '240px 1fr',
gap: 16,
height: '100%',
},
groupingPanel: {
background: 'var(--surface)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 10,
maxHeight: '100vh',
overflowY: 'auto',
},
panelTitle: {
fontSize: 12,
fontWeight: 700,
color: 'var(--muted)',
margin: '0 0 8px 0',
textTransform: 'uppercase',
letterSpacing: 0.5,
},
emptyText: {
fontSize: 11,
color: 'var(--muted)',
textAlign: 'center',
padding: '12px 0',
},
groupList: {
display: 'flex',
flexDirection: 'column',
gap: 6,
},
groupListItem: {
background: '#38bdf811',
border: '1px solid var(--accent)',
borderRadius: 4,
padding: '8px 10px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
groupName: {
fontSize: 11,
fontWeight: 600,
color: 'var(--accent)',
flex: 1,
},
ungroupBtn: {
background: 'none',
border: 'none',
color: 'var(--accent)',
cursor: 'pointer',
fontSize: 14,
padding: '0 4px',
minWidth: 24,
minHeight: 24,
},
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 12,
overflowY: 'auto',
}
}

View File

@@ -11,6 +11,17 @@ export default function ChartPlaceholder() {
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
@@ -68,9 +79,9 @@ export default function ChartPlaceholder() {
})
// Clear and draw background
ctx.fillStyle = 'var(--surface)'
ctx.fillStyle = colors.surface
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = 'var(--border)'
ctx.strokeStyle = colors.border
ctx.lineWidth = 1
ctx.strokeRect(0, 0, width, height)
@@ -79,14 +90,14 @@ export default function ChartPlaceholder() {
const p = project(wp.lat, wp.lon)
// Waypoint circle
ctx.fillStyle = idx === snapshot?.currentWaypoint ? 'var(--accent)' : 'var(--warning)'
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 = 'var(--bg)'
ctx.font = 'bold 10px var(--font-mono)'
ctx.fillStyle = colors.bg
ctx.font = 'bold 10px monospace'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(idx + 1, p.x, p.y)
@@ -94,7 +105,7 @@ export default function ChartPlaceholder() {
// Draw track line
if (trackRef.current.length > 1) {
ctx.strokeStyle = 'var(--accent)'
ctx.strokeStyle = colors.accent
ctx.lineWidth = 2
ctx.beginPath()
trackRef.current.forEach((p, idx) => {
@@ -116,7 +127,7 @@ export default function ChartPlaceholder() {
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(headRad)
ctx.fillStyle = 'var(--accent)'
ctx.fillStyle = colors.accent
ctx.beginPath()
ctx.moveTo(0, -size)
ctx.lineTo(-size / 2, size / 2)
@@ -126,7 +137,7 @@ export default function ChartPlaceholder() {
ctx.restore()
// Ship circle
ctx.strokeStyle = 'var(--accent)'
ctx.strokeStyle = colors.accent
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2)