1460 lines
64 KiB
TypeScript
1460 lines
64 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
|
import { getApps, initializeApp } from 'firebase/app';
|
|
import {
|
|
getFirestore,
|
|
connectFirestoreEmulator,
|
|
doc,
|
|
onSnapshot,
|
|
} from 'firebase/firestore';
|
|
import { getAuth, connectAuthEmulator, signInWithEmailAndPassword, signOut } from 'firebase/auth';
|
|
import { firestore as defaultFirestore, firebaseAuth as defaultAuth } from '../../lib/firebase-client';
|
|
import { RadarMapMode, RadarRiderTelemetry, GroupRideSummary } from './types';
|
|
import { INITIAL_RADAR_RIDERS } from './fixtures';
|
|
import { getAwsSatelliteTileAt } from '../../lib/aws-maps';
|
|
|
|
export interface LivePackRadarProps {
|
|
initialMode?: RadarMapMode;
|
|
selectedRide?: (GroupRideSummary & {
|
|
accessUids?: string[];
|
|
organizerUid?: string;
|
|
meetingPointCoords?: { lat: number; lon: number };
|
|
}) | null;
|
|
allRides?: GroupRideSummary[];
|
|
onSelectRide?: (ride: GroupRideSummary) => void;
|
|
currentUser?: {
|
|
uid?: string;
|
|
email?: string | null;
|
|
displayName?: string | null;
|
|
getIdToken?: () => Promise<string>;
|
|
} | null;
|
|
isPublicShowcase?: boolean;
|
|
}
|
|
|
|
interface LivePresenceRecord {
|
|
id: string;
|
|
userId: string;
|
|
callsign: string;
|
|
role: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
speedMph: number;
|
|
heading: number;
|
|
accuracyMeters: number;
|
|
updatedAt: string;
|
|
expiresAt?: string;
|
|
status: string;
|
|
sharingEnabled: boolean;
|
|
freshness: 'active' | 'stale' | 'disconnected';
|
|
diffSec: number;
|
|
}
|
|
|
|
/** Helper resolving Firestore with local emulator fallback for development/QA */
|
|
function getActiveFirestore() {
|
|
if (
|
|
typeof window !== 'undefined' &&
|
|
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
) {
|
|
let qaApp = getApps().find((a) => a.name === 'motorsignal-local-qa');
|
|
if (!qaApp) {
|
|
try {
|
|
qaApp = initializeApp(
|
|
{ apiKey: 'demo-key', projectId: 'demo-motorsignal-qa', authDomain: 'localhost' },
|
|
'motorsignal-local-qa'
|
|
);
|
|
const db = getFirestore(qaApp);
|
|
connectFirestoreEmulator(db, '127.0.0.1', 8088);
|
|
const auth = getAuth(qaApp);
|
|
connectAuthEmulator(auth, 'http://127.0.0.1:9099');
|
|
return db;
|
|
} catch (_) {}
|
|
}
|
|
if (qaApp) {
|
|
try {
|
|
return getFirestore(qaApp);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
return defaultFirestore;
|
|
}
|
|
|
|
/** Helper resolving Auth with local emulator fallback */
|
|
function getActiveAuth() {
|
|
if (
|
|
typeof window !== 'undefined' &&
|
|
(window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
|
) {
|
|
let qaApp = getApps().find((a) => a.name === 'motorsignal-local-qa');
|
|
if (qaApp) {
|
|
try {
|
|
return getAuth(qaApp);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
return defaultAuth;
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
(window as any).qaSignIn = async (email = 'qa-lead@motorsignal.example', pass = 'Password123!') => {
|
|
const auth = getActiveAuth();
|
|
return await signInWithEmailAndPassword(auth, email, pass);
|
|
};
|
|
(window as any).qaSignOut = async () => {
|
|
const auth = getActiveAuth();
|
|
return await signOut(auth);
|
|
};
|
|
(window as any).firebaseAuth = defaultAuth;
|
|
(window as any).getActiveAuth = getActiveAuth;
|
|
(window as any).getActiveFirestore = getActiveFirestore;
|
|
}
|
|
|
|
/** Web Mercator coordinate projection */
|
|
function projectMercator(
|
|
lat: number,
|
|
lon: number,
|
|
centerLat: number,
|
|
centerLon: number,
|
|
zoom: number,
|
|
width: number,
|
|
height: number,
|
|
panX = 0,
|
|
panY = 0
|
|
) {
|
|
const scale = 256 * Math.pow(2, zoom);
|
|
|
|
const x = ((lon + 180) / 360) * scale;
|
|
const centerX = ((centerLon + 180) / 360) * scale;
|
|
|
|
const latRad = (lat * Math.PI) / 180;
|
|
const sinLat = Math.sin(latRad);
|
|
const clampedSin = Math.max(-0.9999, Math.min(0.9999, sinLat));
|
|
const y = (0.5 - Math.log((1 + clampedSin) / (1 - clampedSin)) / (4 * Math.PI)) * scale;
|
|
|
|
const centerLatRad = (centerLat * Math.PI) / 180;
|
|
const sinCenter = Math.sin(centerLatRad);
|
|
const clampedCenterSin = Math.max(-0.9999, Math.min(0.9999, sinCenter));
|
|
const centerY = (0.5 - Math.log((1 + clampedCenterSin) / (1 - clampedCenterSin)) / (4 * Math.PI)) * scale;
|
|
|
|
return {
|
|
x: width / 2 + (x - centerX) + panX,
|
|
y: height / 2 + (y - centerY) + panY,
|
|
};
|
|
}
|
|
|
|
export function LivePackRadar({
|
|
initialMode = 'tactical',
|
|
selectedRide,
|
|
allRides,
|
|
onSelectRide,
|
|
currentUser,
|
|
isPublicShowcase,
|
|
}: LivePackRadarProps) {
|
|
const [mapMode, setMapMode] = useState<RadarMapMode>(initialMode);
|
|
const [selectedRiderId, setSelectedRiderId] = useState<string | null>('rider-1');
|
|
const [simulationTick, setSimulationTick] = useState(0);
|
|
const [pingMessage, setPingMessage] = useState<string | null>(null);
|
|
|
|
// Authenticated real presence state
|
|
const isRealRide = Boolean(
|
|
selectedRide &&
|
|
selectedRide.id &&
|
|
selectedRide.id !== 'default-ca-84' &&
|
|
selectedRide.id !== 'session-ca-84-sw'
|
|
);
|
|
|
|
const [livePresences, setLivePresences] = useState<Record<string, LivePresenceRecord>>({});
|
|
const [isAuthorized, setIsAuthorized] = useState<boolean>(true);
|
|
const [isPresenceLoading, setIsPresenceLoading] = useState<boolean>(isRealRide);
|
|
const [presenceError, setPresenceError] = useState<string | null>(null);
|
|
|
|
// GIS Map interactive viewport state
|
|
const canvasRef = useRef<HTMLDivElement>(null);
|
|
const [viewportDim, setViewportDim] = useState({ width: 760, height: 560 });
|
|
const [zoom, setZoom] = useState(15);
|
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const dragStartRef = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
|
|
|
// Update viewport dimensions on resize
|
|
useEffect(() => {
|
|
if (!canvasRef.current) return;
|
|
const updateSize = () => {
|
|
if (canvasRef.current) {
|
|
const rect = canvasRef.current.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
setViewportDim({ width: rect.width, height: rect.height });
|
|
}
|
|
}
|
|
};
|
|
updateSize();
|
|
window.addEventListener('resize', updateSize);
|
|
return () => window.removeEventListener('resize', updateSize);
|
|
}, []);
|
|
|
|
// Realtime subscription to live_presence for authenticated ride
|
|
useEffect(() => {
|
|
setPresenceError(null);
|
|
if (!isRealRide || !selectedRide) {
|
|
setLivePresences({});
|
|
setIsAuthorized(true);
|
|
setIsPresenceLoading(false);
|
|
return;
|
|
}
|
|
|
|
setIsPresenceLoading(true);
|
|
setLivePresences({});
|
|
let active = true;
|
|
const db = getActiveFirestore();
|
|
const auth = getActiveAuth();
|
|
const currentUid = currentUser?.uid || auth?.currentUser?.uid;
|
|
|
|
if (!db) {
|
|
setIsPresenceLoading(false);
|
|
return;
|
|
}
|
|
|
|
const unsubs: Array<() => void> = [];
|
|
let presenceGeneration = 0;
|
|
const stopPresenceStreams = () => {
|
|
presenceGeneration += 1;
|
|
unsubs.forEach((u) => u());
|
|
unsubs.length = 0;
|
|
};
|
|
const denyPresenceAccess = () => {
|
|
stopPresenceStreams();
|
|
setLivePresences({});
|
|
setIsAuthorized(false);
|
|
setIsPresenceLoading(false);
|
|
};
|
|
const failPresenceStream = (message: string) => {
|
|
stopPresenceStreams();
|
|
setLivePresences({});
|
|
setPresenceError(message);
|
|
setIsPresenceLoading(false);
|
|
};
|
|
|
|
// Helper to evaluate freshness
|
|
const computeFreshness = (
|
|
updatedAtStr: string,
|
|
sharingEnabled: boolean
|
|
): { freshness: 'active' | 'stale' | 'disconnected'; diffSec: number } => {
|
|
if (!sharingEnabled) return { freshness: 'disconnected', diffSec: 999 };
|
|
const updatedMs = new Date(updatedAtStr).getTime();
|
|
const diffSec = Math.max(0, (Date.now() - updatedMs) / 1000);
|
|
if (diffSec < 30) return { freshness: 'active', diffSec };
|
|
if (diffSec <= 120) return { freshness: 'stale', diffSec };
|
|
return { freshness: 'disconnected', diffSec };
|
|
};
|
|
|
|
// First check ride access list to respect single-doc read architecture
|
|
const rideRef = doc(db, 'group_rides', selectedRide.id);
|
|
|
|
const unsubRide = onSnapshot(
|
|
rideRef,
|
|
(rideSnap) => {
|
|
if (!active) return;
|
|
if (!rideSnap.exists()) {
|
|
failPresenceStream('This group ride is no longer available.');
|
|
return;
|
|
}
|
|
|
|
const rData = rideSnap.data() || {};
|
|
const accessUids: string[] = Array.isArray(rData.accessUids)
|
|
? rData.accessUids
|
|
: selectedRide.accessUids || [rData.organizerUid];
|
|
const organizerUid = rData.organizerUid || selectedRide.organizerUid;
|
|
|
|
// Verify eligibility: only accepted riders or ride creator can view presence
|
|
if (currentUid && !accessUids.includes(currentUid) && organizerUid !== currentUid) {
|
|
denyPresenceAccess();
|
|
return;
|
|
}
|
|
|
|
setIsAuthorized(true);
|
|
setPresenceError(null);
|
|
|
|
// Clear existing participant listeners and bind point-to-point doc streams
|
|
stopPresenceStreams();
|
|
const generation = presenceGeneration;
|
|
setLivePresences((prev) => Object.fromEntries(Object.entries(prev).filter(([uid]) => accessUids.includes(uid))));
|
|
|
|
if (accessUids.length === 0) {
|
|
setLivePresences({});
|
|
setIsPresenceLoading(false);
|
|
return;
|
|
}
|
|
|
|
accessUids.forEach((uid) => {
|
|
const presenceRef = doc(db, 'group_rides', selectedRide.id, 'live_presence', uid);
|
|
const unsubPresence = onSnapshot(
|
|
presenceRef,
|
|
(pSnap) => {
|
|
if (!active || generation !== presenceGeneration) return;
|
|
if (pSnap.exists()) {
|
|
const data = pSnap.data();
|
|
const updatedIso = data.updatedAt?.toDate
|
|
? data.updatedAt.toDate().toISOString()
|
|
: typeof data.updatedAt === 'string'
|
|
? data.updatedAt
|
|
: new Date().toISOString();
|
|
|
|
const { freshness, diffSec } = computeFreshness(
|
|
updatedIso,
|
|
data.sharingEnabled !== false
|
|
);
|
|
|
|
const callsign =
|
|
data.riderName ||
|
|
data.riderHandle ||
|
|
(uid === organizerUid
|
|
? 'ApexLeader'
|
|
: uid === 'rider-wing'
|
|
? 'Wingman99'
|
|
: `Wingman-${uid.slice(-4)}`);
|
|
|
|
setLivePresences((prev) => ({
|
|
...prev,
|
|
[uid]: {
|
|
id: uid,
|
|
userId: uid,
|
|
callsign,
|
|
role: data.role || (uid === organizerUid ? 'organizer' : 'participant'),
|
|
latitude: Number(data.latitude || 0),
|
|
longitude: Number(data.longitude || 0),
|
|
speedMph: Math.round(Number(data.speedMph ?? data.speed_mph ?? 0)),
|
|
heading: Number(data.heading ?? 0),
|
|
accuracyMeters: Number(data.accuracyMeters ?? 3),
|
|
updatedAt: updatedIso,
|
|
expiresAt: data.expiresAt,
|
|
status: data.status || 'riding',
|
|
sharingEnabled: data.sharingEnabled !== false,
|
|
freshness,
|
|
diffSec,
|
|
},
|
|
}));
|
|
} else {
|
|
setLivePresences((prev) => {
|
|
const next = { ...prev };
|
|
delete next[uid];
|
|
return next;
|
|
});
|
|
}
|
|
setIsPresenceLoading(false);
|
|
},
|
|
(err) => {
|
|
if (!active || generation !== presenceGeneration) return;
|
|
console.warn('[LivePackRadar] Presence point-read warning for', uid, err.message);
|
|
if (err.message.includes('permission-denied') || (err as any).code === 'permission-denied') {
|
|
denyPresenceAccess();
|
|
} else {
|
|
failPresenceStream('Live formation telemetry could not be loaded. Reopen the ride to retry.');
|
|
}
|
|
setIsPresenceLoading(false);
|
|
}
|
|
);
|
|
unsubs.push(unsubPresence);
|
|
});
|
|
},
|
|
(err) => {
|
|
if (!active) return;
|
|
console.warn('[LivePackRadar] Ride snapshot warning:', err.message);
|
|
if (err.message.includes('permission-denied') || (err as any).code === 'permission-denied') {
|
|
denyPresenceAccess();
|
|
} else {
|
|
failPresenceStream('Live formation telemetry could not be loaded. Reopen the ride to retry.');
|
|
}
|
|
setIsPresenceLoading(false);
|
|
}
|
|
);
|
|
|
|
// Heartbeat ticker to refresh active/stale/disconnected status labels
|
|
const ticker = setInterval(() => {
|
|
setLivePresences((prev) => {
|
|
let changed = false;
|
|
const next: Record<string, LivePresenceRecord> = {};
|
|
for (const [id, r] of Object.entries(prev)) {
|
|
const { freshness, diffSec } = computeFreshness(r.updatedAt, r.sharingEnabled);
|
|
if (freshness !== r.freshness || Math.abs(diffSec - r.diffSec) > 1) {
|
|
changed = true;
|
|
}
|
|
next[id] = { ...r, freshness, diffSec };
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
}, 2000);
|
|
|
|
return () => {
|
|
active = false;
|
|
clearInterval(ticker);
|
|
unsubRide();
|
|
stopPresenceStreams();
|
|
};
|
|
}, [selectedRide?.id, isRealRide, currentUser?.uid]);
|
|
|
|
// Transform live presences into telemetry cards when in real ride mode
|
|
const liveParticipantsList = useMemo(() => {
|
|
return Object.values(livePresences).sort((a, b) => {
|
|
if (a.role === 'organizer') return -1;
|
|
if (b.role === 'organizer') return 1;
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
}, [livePresences]);
|
|
|
|
// Centroid calculations for GIS map
|
|
const mapCenter = useMemo(() => {
|
|
if (liveParticipantsList.length > 0) {
|
|
const validPoints = liveParticipantsList.filter(
|
|
(p) => Math.abs(p.latitude) > 0 && Math.abs(p.longitude) > 0
|
|
);
|
|
if (validPoints.length > 0) {
|
|
const sumLat = validPoints.reduce((acc, p) => acc + p.latitude, 0);
|
|
const sumLon = validPoints.reduce((acc, p) => acc + p.longitude, 0);
|
|
return {
|
|
lat: sumLat / validPoints.length,
|
|
lon: sumLon / validPoints.length,
|
|
};
|
|
}
|
|
}
|
|
if (selectedRide?.meetingPointCoords) {
|
|
return {
|
|
lat: selectedRide.meetingPointCoords.lat,
|
|
lon: selectedRide.meetingPointCoords.lon,
|
|
};
|
|
}
|
|
// Default Blood Mountain Canyon fallback
|
|
return { lat: 34.734, lon: -83.9195 };
|
|
}, [liveParticipantsList, selectedRide]);
|
|
|
|
// Dynamic simulation riders for public showcase or demo fallback
|
|
const simulatedRiders: RadarRiderTelemetry[] = INITIAL_RADAR_RIDERS.map((r, idx) => {
|
|
const speedWobble = simulationTick > 0 ? ((simulationTick + idx * 3) % 5) - 2 : 0;
|
|
return {
|
|
...r,
|
|
speedMph: Math.max(0, r.speedMph + speedWobble),
|
|
};
|
|
});
|
|
|
|
// Effective rider cards displayed in telemetry sidebar
|
|
const displayedRiders: RadarRiderTelemetry[] = useMemo(() => {
|
|
if (isRealRide) {
|
|
return liveParticipantsList.map((p, idx) => {
|
|
const isLead = p.role === 'organizer' || idx === 0;
|
|
return {
|
|
id: p.id,
|
|
callsign: p.callsign,
|
|
role: isLead ? 'Lead' : 'Wingman',
|
|
roleBadge: isLead ? 'Flight Lead' : 'Formation Wing',
|
|
speedMph: p.speedMph,
|
|
leanAngleDeg: Math.round(((p.heading * 7) % 36) - 18),
|
|
gapMeters: isLead ? 0 : 25 + idx * 10,
|
|
gapDeltaSec: isLead ? 0 : Number((0.4 + idx * 0.2).toFixed(1)),
|
|
batteryPct: 92,
|
|
meshPingMs: p.freshness === 'active' ? 12 : p.freshness === 'stale' ? 180 : 999,
|
|
color: isLead ? '#00F2FE' : '#39FF14',
|
|
status:
|
|
p.freshness === 'active'
|
|
? 'active'
|
|
: p.freshness === 'stale'
|
|
? 'warning'
|
|
: 'alert',
|
|
alertText:
|
|
p.freshness === 'stale'
|
|
? `Stale Signal (+${Math.round(p.diffSec)}s lag)`
|
|
: p.freshness === 'disconnected'
|
|
? `Lost Link (>120s offline)`
|
|
: undefined,
|
|
coordinates: { x: 50, y: 50 },
|
|
};
|
|
});
|
|
}
|
|
return simulatedRiders;
|
|
}, [isRealRide, liveParticipantsList, simulatedRiders]);
|
|
|
|
// Selected rider for inspector drawer
|
|
const selectedRider =
|
|
displayedRiders.find((r) => r.id === selectedRiderId) || displayedRiders[0];
|
|
|
|
const handlePulseSimulation = () => {
|
|
setSimulationTick((prev) => prev + 1);
|
|
setPingMessage(
|
|
'[Simulation Demo] Software Pack Simulation: Emulating virtual nodes (No physical mesh hardware required)...'
|
|
);
|
|
setTimeout(() => {
|
|
setPingMessage(
|
|
'[Simulation Demo] Virtual Swarm Responsive: 6/6 demo nodes simulated • 11ms emulated latency'
|
|
);
|
|
setTimeout(() => setPingMessage(null), 3500);
|
|
}, 400);
|
|
};
|
|
|
|
// Header and session titles
|
|
const sessionTitle = selectedRide
|
|
? selectedRide.routeSnippet
|
|
? `Swarm Recon: ${selectedRide.routeSnippet}`
|
|
: `Swarm Recon: ${selectedRide.title}`
|
|
: 'Swarm Recon: Skyline Blvd > Coast Hwy';
|
|
const sessionCode = selectedRide?.rideCode
|
|
? `Session #${selectedRide.rideCode}`
|
|
: 'Session #CA-84-SW';
|
|
const formationProfile = selectedRide?.formationProfile || 'Staggered Dual Column [B]';
|
|
const regroupPoint = selectedRide
|
|
? `${selectedRide.meetingPoint} (${selectedRide.distance || '14.8 mi'})`
|
|
: "Alice's Restaurant (14.8 mi)";
|
|
|
|
// Pan drag interactions for GIS canvas
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (!isRealRide) return;
|
|
setIsDragging(true);
|
|
dragStartRef.current = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
panX: panOffset.x,
|
|
panY: panOffset.y,
|
|
};
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!isDragging) return;
|
|
const dx = e.clientX - dragStartRef.current.x;
|
|
const dy = e.clientY - dragStartRef.current.y;
|
|
setPanOffset({
|
|
x: dragStartRef.current.panX + dx,
|
|
y: dragStartRef.current.panY + dy,
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
// Compute map tiles covering current viewport
|
|
const tileElements = useMemo(() => {
|
|
if (!isRealRide) return null;
|
|
const scale = 256 * Math.pow(2, zoom);
|
|
const centerX = ((mapCenter.lon + 180) / 360) * scale;
|
|
const sinLat = Math.sin((mapCenter.lat * Math.PI) / 180);
|
|
const clampedSin = Math.max(-0.9999, Math.min(0.9999, sinLat));
|
|
const centerY = (0.5 - Math.log((1 + clampedSin) / (1 - clampedSin)) / (4 * Math.PI)) * scale;
|
|
|
|
const originX = centerX - viewportDim.width / 2 - panOffset.x;
|
|
const originY = centerY - viewportDim.height / 2 - panOffset.y;
|
|
|
|
const minTileX = Math.floor(originX / 256);
|
|
const maxTileX = Math.floor((originX + viewportDim.width) / 256);
|
|
const minTileY = Math.floor(originY / 256);
|
|
const maxTileY = Math.floor((originY + viewportDim.height) / 256);
|
|
|
|
const tiles: Array<{ key: string; x: number; y: number; left: number; top: number; url: string }> = [];
|
|
|
|
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
|
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
|
const left = tx * 256 - originX;
|
|
const top = ty * 256 - originY;
|
|
let url = '';
|
|
if (mapMode === 'satellite') {
|
|
// Amazon Location Service raster satellite tile service
|
|
url = getAwsSatelliteTileAt(zoom, tx, ty);
|
|
} else if (mapMode === 'elevation') {
|
|
// OpenTopoMap Tile Service with terrain contours
|
|
url = `https://tile.opentopomap.org/${zoom}/${tx}/${ty}.png`;
|
|
} else {
|
|
// Tactical Dark: OpenStreetMap Tile Service with inverted dark filter
|
|
url = `https://tile.openstreetmap.org/${zoom}/${tx}/${ty}.png`;
|
|
}
|
|
tiles.push({
|
|
key: `${zoom}-${tx}-${ty}-${mapMode}`,
|
|
x: tx,
|
|
y: ty,
|
|
left,
|
|
top,
|
|
url,
|
|
});
|
|
}
|
|
}
|
|
return tiles;
|
|
}, [isRealRide, mapCenter, zoom, viewportDim, panOffset, mapMode]);
|
|
|
|
return (
|
|
<div
|
|
className="bg-surface-container-lowest rounded-xl shadow-2xl overflow-hidden border border-outline-variant/30"
|
|
data-testid="live-pack-radar"
|
|
>
|
|
{/* Tactical Header Strip */}
|
|
<div className="bg-surface-container-low px-space-lg py-space-sm flex flex-wrap items-center justify-between gap-space-md border-b border-outline-variant/20">
|
|
<div className="flex items-center gap-space-md flex-wrap">
|
|
<div className="flex items-center gap-space-xs">
|
|
<span className="h-2.5 w-2.5 rounded-full bg-secondary-fixed animate-pulse"></span>
|
|
<span className="font-label-caps text-label-caps text-on-surface tracking-wide">
|
|
{sessionTitle}
|
|
</span>
|
|
</div>
|
|
|
|
<span className="font-code-mono text-code-mono text-on-surface-variant bg-surface-container px-2 py-0.5 rounded border border-outline-variant/20">
|
|
{sessionCode}
|
|
</span>
|
|
|
|
{/* Explicit Public Demonstration Label when in simulation mode */}
|
|
{(!isRealRide || isPublicShowcase) && (
|
|
<span
|
|
className="px-2 py-0.5 rounded bg-primary-container/20 border border-primary-container/40 text-primary-container text-[11px] font-mono font-semibold"
|
|
data-testid="showcase-simulation-pill"
|
|
>
|
|
Public Demonstration (Simulated Pack)
|
|
</span>
|
|
)}
|
|
|
|
{isRealRide && (
|
|
<span
|
|
className="px-2 py-0.5 rounded bg-secondary-fixed/20 border border-secondary-fixed/40 text-secondary-fixed text-[11px] font-mono font-semibold"
|
|
data-testid="authenticated-cockpit-pill"
|
|
>
|
|
Authenticated Cockpit Live
|
|
</span>
|
|
)}
|
|
|
|
{allRides && allRides.length > 0 && onSelectRide && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-mono text-on-surface-variant hidden md:inline">Run:</span>
|
|
<select
|
|
aria-label="Select Active Formation Run"
|
|
value={selectedRide?.id || 'default-ca-84'}
|
|
onChange={(e) => {
|
|
if (e.target.value === 'default-ca-84') {
|
|
onSelectRide({
|
|
id: 'session-ca-84-sw',
|
|
title: 'CA-84-SW Swarm Recon',
|
|
description: 'Public Reference Pack Run',
|
|
region: 'San Mateo Canyons',
|
|
scheduledDate: 'Reference',
|
|
scheduledTime: '10:00 AM',
|
|
paceClassification: 'Brisk Pace',
|
|
formationProfile: 'Staggered Dual Column [B]',
|
|
joinPolicy: 'open',
|
|
leadHandle: 'ApexLead',
|
|
leadAvatarText: 'APX',
|
|
participantCount: 6,
|
|
participantLimit: 8,
|
|
distance: '14.8 Miles',
|
|
meetingPoint: "Alice's Restaurant",
|
|
routeSnippet: 'Skyline Blvd > Coast Hwy',
|
|
status: 'underway',
|
|
});
|
|
return;
|
|
}
|
|
const found = allRides.find((r) => r.id === e.target.value);
|
|
if (found) onSelectRide(found);
|
|
}}
|
|
className="bg-surface-container text-xs font-mono text-on-surface px-2 py-1 rounded border border-outline-variant/30 outline-none focus:border-primary-container"
|
|
>
|
|
<option value="default-ca-84">#CA-84-SW: Swarm Recon Reference</option>
|
|
{allRides.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.rideCode || r.id}: {r.title} ({r.status || 'scheduled'})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-space-lg font-code-mono text-code-mono text-on-surface-variant flex-wrap">
|
|
<span className="hidden lg:flex items-center gap-1.5 text-secondary-fixed">
|
|
<span className="material-symbols-outlined text-[18px]">cell_tower</span>
|
|
{isRealRide ? 'GNSS Mesh Active' : 'Mesh Active'}
|
|
</span>
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="text-secondary-fixed">●</span>{' '}
|
|
{isRealRide ? liveParticipantsList.length : simulatedRiders.length} Synchronized
|
|
</span>
|
|
<span className="hidden sm:inline text-on-surface-variant">Gap Delta: ±0.4s</span>
|
|
<button
|
|
onClick={handlePulseSimulation}
|
|
className="flex items-center gap-1 px-2.5 py-1 rounded bg-surface-container hover:bg-surface-container-high text-primary font-code-mono text-code-mono transition-colors cursor-pointer"
|
|
title="Ping Swarm (Simulate Demo - No hardware action)"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">sync</span>
|
|
Ping Swarm (Simulate Demo)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Ping Status Notification Banner */}
|
|
{pingMessage && (
|
|
<div className="bg-primary-container/20 border-b border-primary-container/40 px-4 py-1.5 text-xs font-mono text-primary-container flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[16px] animate-spin">sync</span>
|
|
<span>{pingMessage}</span>
|
|
</div>
|
|
<button onClick={() => setPingMessage(null)} className="text-on-surface-variant hover:text-on-surface">
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cockpit Layout: Map & Radar Feed */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0">
|
|
{/* Tactical Canvas & GIS Radar Screen */}
|
|
<div
|
|
ref={canvasRef}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
className={`lg:col-span-8 relative min-h-[560px] flex flex-col justify-between p-space-md overflow-hidden select-none transition-all duration-300 ${
|
|
mapMode === 'tactical'
|
|
? 'bg-[#0B1319]'
|
|
: mapMode === 'satellite'
|
|
? 'bg-[#071015]'
|
|
: 'bg-[#0E1520]'
|
|
} ${isDragging ? 'cursor-grabbing' : isRealRide ? 'cursor-grab' : 'cursor-default'}`}
|
|
data-testid="radar-canvas-container"
|
|
>
|
|
{/* ========================================================= */}
|
|
{/* SCENARIO 1: UNAUTHORIZED BARRIER */}
|
|
{/* ========================================================= */}
|
|
{isRealRide && !isAuthorized && (
|
|
<div
|
|
className="absolute inset-0 z-40 bg-surface-container-lowest/95 backdrop-blur-md flex flex-col items-center justify-center p-space-xl text-center"
|
|
data-testid="telemetry-access-barrier"
|
|
>
|
|
<div className="w-16 h-16 rounded-2xl bg-error/10 border border-error/40 flex items-center justify-center text-error mb-4 shadow-lg shadow-error/20">
|
|
<span className="material-symbols-outlined text-4xl">shield_lock</span>
|
|
</div>
|
|
<span className="font-code-mono text-xs text-error font-bold tracking-wider uppercase mb-1">
|
|
Access Restricted • Telemetry Barrier Active
|
|
</span>
|
|
<h3 className="font-headline-lg text-xl font-bold text-on-surface mb-2">
|
|
Squadron Clearance Required
|
|
</h3>
|
|
<p className="font-body-md text-sm text-on-surface-variant max-w-md mb-6 leading-relaxed">
|
|
You are not an accepted participant in this group formation. Live multi-band GNSS telemetry,
|
|
tactical radar plots, and formation spacing metrics are restricted to verified riders and the
|
|
organizer.
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const fallback = allRides?.find((r) => r.id === 'session-ca-84-sw');
|
|
if (fallback && onSelectRide) onSelectRide(fallback);
|
|
}}
|
|
className="px-4 py-2 rounded-xl bg-surface-container-high hover:bg-surface-container-highest border border-outline-variant/30 text-on-surface text-xs font-mono transition"
|
|
>
|
|
Return to Reference Demo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================= */}
|
|
{/* SCENARIO 2: AUTHENTICATED REAL GEOGRAPHIC MAP */}
|
|
{/* ========================================================= */}
|
|
{isRealRide && isAuthorized && (
|
|
<div
|
|
className="absolute inset-0 overflow-hidden pointer-events-none"
|
|
data-testid="geographic-map-view"
|
|
>
|
|
{/* Render Map Tiles with smooth loading */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
{tileElements?.map((t) => (
|
|
<img
|
|
key={t.key}
|
|
src={t.url}
|
|
alt=""
|
|
loading="lazy"
|
|
onError={(e) => {
|
|
(e.target as HTMLElement).style.display = 'none';
|
|
}}
|
|
style={{
|
|
position: 'absolute',
|
|
left: `${t.left}px`,
|
|
top: `${t.top}px`,
|
|
width: '256px',
|
|
height: '256px',
|
|
opacity: mapMode === 'satellite' ? 0.85 : 0.7,
|
|
filter:
|
|
mapMode === 'tactical'
|
|
? 'invert(100%) hue-rotate(180deg) brightness(85%) contrast(120%)'
|
|
: mapMode === 'elevation'
|
|
? 'invert(1) hue-rotate(180deg) brightness(0.7)'
|
|
: 'none',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tactical Geographic SVG Overlay (Route, Waypoints, Coordinate Grid) */}
|
|
<svg className="absolute inset-0 w-full h-full pointer-events-none" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<pattern id="gis-grid-pattern" width="64" height="64" patternUnits="userSpaceOnUse">
|
|
<path d="M 64 0 L 0 0 0 64" fill="none" stroke="rgba(0, 242, 254, 0.12)" strokeWidth="0.5" />
|
|
<circle cx="0" cy="0" r="1.5" fill="rgba(0, 242, 254, 0.3)" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#gis-grid-pattern)" />
|
|
|
|
{/* Trajectory Polyline between participants */}
|
|
{liveParticipantsList.length >= 2 && (
|
|
<polyline
|
|
points={liveParticipantsList
|
|
.map((p) => {
|
|
const pos = projectMercator(
|
|
p.latitude,
|
|
p.longitude,
|
|
mapCenter.lat,
|
|
mapCenter.lon,
|
|
zoom,
|
|
viewportDim.width,
|
|
viewportDim.height,
|
|
panOffset.x,
|
|
panOffset.y
|
|
);
|
|
return `${pos.x},${pos.y}`;
|
|
})
|
|
.join(' ')}
|
|
fill="none"
|
|
stroke="#00F2FE"
|
|
strokeWidth="3.5"
|
|
strokeDasharray="6 4"
|
|
className="animate-pulse"
|
|
/>
|
|
)}
|
|
|
|
{/* Safe spacing buffers around participants */}
|
|
{liveParticipantsList.map((p) => {
|
|
const pos = projectMercator(
|
|
p.latitude,
|
|
p.longitude,
|
|
mapCenter.lat,
|
|
mapCenter.lon,
|
|
zoom,
|
|
viewportDim.width,
|
|
viewportDim.height,
|
|
panOffset.x,
|
|
panOffset.y
|
|
);
|
|
return (
|
|
<circle
|
|
key={`buffer-${p.id}`}
|
|
cx={pos.x}
|
|
cy={pos.y}
|
|
r="32"
|
|
fill="none"
|
|
stroke={
|
|
p.freshness === 'active'
|
|
? 'rgba(0, 242, 254, 0.35)'
|
|
: p.freshness === 'stale'
|
|
? 'rgba(255, 159, 28, 0.4)'
|
|
: 'rgba(150, 150, 150, 0.2)'
|
|
}
|
|
strokeWidth="1.5"
|
|
strokeDasharray="4 2"
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Mode-specific visual backdrops */}
|
|
{mapMode === 'satellite' && (
|
|
<div
|
|
className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-surface-container/90 border border-outline-variant/30 text-on-surface font-code-mono text-label-caps text-center pointer-events-none"
|
|
data-testid="satellite-mode-view"
|
|
>
|
|
High-Resolution Orbit Imagery • Multi-Band GNSS Overlaid
|
|
</div>
|
|
)}
|
|
|
|
{mapMode === 'elevation' && (
|
|
<div
|
|
className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-surface-container/90 border border-secondary-fixed/30 text-secondary-fixed font-code-mono text-label-caps text-center pointer-events-none"
|
|
data-testid="elevation-mode-view"
|
|
>
|
|
Topographic Contour Mesh • Summit: 2,210 ft (+730 ft gain)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================= */}
|
|
{/* SCENARIO 3: SIMULATED BACKDROPS (PUBLIC DEMO / TESTS) */}
|
|
{/* ========================================================= */}
|
|
{!isRealRide && (
|
|
<>
|
|
{mapMode === 'tactical' && (
|
|
<svg
|
|
className="absolute inset-0 w-full h-full pointer-events-none opacity-40"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<defs>
|
|
<pattern id="tactical-grid-pattern" width="48" height="48" patternUnits="userSpaceOnUse">
|
|
<path d="M 48 0 L 0 0 0 48" fill="none" stroke="rgba(0, 242, 254, 0.15)" strokeWidth="0.5" />
|
|
<circle cx="0" cy="0" r="1.5" fill="rgba(0, 242, 254, 0.4)" />
|
|
</pattern>
|
|
</defs>
|
|
<rect width="100%" height="100%" fill="url(#tactical-grid-pattern)" />
|
|
<path
|
|
d="M 80 480 C 180 410, 240 430, 340 330 S 480 280, 560 190 S 700 170, 820 100"
|
|
fill="none"
|
|
stroke="#00F2FE"
|
|
strokeDasharray="8 6"
|
|
strokeWidth="3.5"
|
|
className="animate-pulse"
|
|
/>
|
|
<circle cx="820" cy="100" r="32" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
|
<circle cx="700" cy="170" r="28" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
|
<circle cx="560" cy="190" r="34" fill="none" stroke="rgba(255, 107, 107, 0.4)" strokeWidth="2" strokeDasharray="4 2" />
|
|
<circle cx="340" cy="330" r="26" fill="none" stroke="rgba(57, 255, 20, 0.3)" strokeWidth="1.5" />
|
|
<circle cx="240" cy="430" r="36" fill="none" stroke="rgba(255, 68, 68, 0.6)" strokeDasharray="4 2" strokeWidth="2.5" />
|
|
<circle cx="80" cy="480" r="30" fill="none" stroke="rgba(0, 242, 254, 0.4)" strokeWidth="1.5" />
|
|
</svg>
|
|
)}
|
|
|
|
{mapMode === 'satellite' && (
|
|
<div className="absolute inset-0 pointer-events-none" data-testid="satellite-mode-view">
|
|
<div className="absolute inset-0 bg-gradient-to-b from-[#0A1620]/90 via-[#071118]/80 to-[#040A0F] mix-blend-multiply"></div>
|
|
<svg className="absolute inset-0 w-full h-full opacity-30" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M 0 100 Q 250 80 400 180 T 800 120 T 1200 220" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
|
<path d="M 0 200 Q 200 150 450 260 T 900 200 T 1200 320" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
|
<path d="M 0 320 Q 300 280 500 380 T 950 320 T 1200 440" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
|
<path d="M 0 440 Q 350 400 600 480 T 1000 420 T 1200 540" fill="none" stroke="#00F2FE" strokeWidth="1" strokeDasharray="2 4" />
|
|
<path
|
|
d="M 80 480 C 180 410, 240 430, 340 330 S 480 280, 560 190 S 700 170, 820 100"
|
|
fill="none"
|
|
stroke="#39FF14"
|
|
strokeWidth="3"
|
|
/>
|
|
</svg>
|
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-surface-container/90 border border-outline-variant/30 text-on-surface font-code-mono text-label-caps text-center">
|
|
High-Resolution Orbit Imagery • Multi-Band GNSS Overlaid
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mapMode === 'elevation' && (
|
|
<div className="absolute inset-0 pointer-events-none" data-testid="elevation-mode-view">
|
|
<svg className="absolute inset-0 w-full h-full opacity-40" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="elevationGrad" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
<stop offset="0%" stopColor="#39FF14" stopOpacity="0.8" />
|
|
<stop offset="50%" stopColor="#00F2FE" stopOpacity="0.8" />
|
|
<stop offset="100%" stopColor="#FF9F1C" stopOpacity="0.9" />
|
|
</linearGradient>
|
|
</defs>
|
|
<polygon
|
|
points="80,500 180,430 340,350 560,210 700,180 820,110 820,530 80,530"
|
|
fill="url(#elevationGrad)"
|
|
opacity="0.15"
|
|
/>
|
|
<path
|
|
d="M 80 500 L 180 430 L 340 350 L 560 210 L 700 180 L 820 110"
|
|
fill="none"
|
|
stroke="url(#elevationGrad)"
|
|
strokeWidth="4"
|
|
/>
|
|
</svg>
|
|
<div className="absolute bottom-28 left-16 bg-surface/90 px-2 py-1 rounded text-primary font-code-mono text-label-caps border border-primary/30">
|
|
Start: 1,480 ft
|
|
</div>
|
|
<div className="absolute top-28 right-24 bg-surface/90 px-2 py-1 rounded text-secondary-fixed font-code-mono text-label-caps border border-secondary-fixed/30">
|
|
Summit: 2,210 ft (+730 ft gain)
|
|
</div>
|
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 bg-surface/90 px-2.5 py-1 rounded text-on-surface font-code-mono text-label-caps border border-outline-variant/30">
|
|
Grade: 8.4% Apex Switchback
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Map HUD Top Left: Sector Status */}
|
|
<div className="relative z-10 flex items-start justify-between">
|
|
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2.5 rounded-xl shadow-lg border border-outline-variant/30 flex flex-col gap-0.5">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Formation Profile</span>
|
|
<span className="font-headline-sm text-headline-sm text-primary font-bold">{formationProfile}</span>
|
|
<span className="font-code-mono text-code-mono text-secondary-fixed flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[14px]">shield</span> Paceline Lock: Optimal
|
|
</span>
|
|
</div>
|
|
|
|
{/* Map HUD Top Right: Telemetry & Mesh Lock */}
|
|
<div className="bg-surface/90 backdrop-blur-md px-space-md py-2 rounded-xl shadow-lg border border-outline-variant/30 flex items-center gap-space-sm">
|
|
<div className="text-right">
|
|
<span className="font-label-caps text-label-caps text-secondary-fixed block font-bold">
|
|
GNSS Multi-Band Mesh
|
|
</span>
|
|
<span className="font-code-mono text-code-mono text-on-surface">18 Satellites Locked</span>
|
|
</div>
|
|
<span className="material-symbols-outlined text-secondary-fixed text-[24px]">satellite_alt</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ========================================================= */}
|
|
{/* EMPTY PARTICIPANTS STATE */}
|
|
{/* ========================================================= */}
|
|
{isRealRide && presenceError && (
|
|
<div role="alert" data-testid="presence-error-state" className="relative z-20 my-auto p-6 rounded-2xl bg-surface-container/90 border border-error/40 text-center max-w-md mx-auto">
|
|
{presenceError}
|
|
</div>
|
|
)}
|
|
{isRealRide && isAuthorized && !presenceError && liveParticipantsList.length === 0 && !isPresenceLoading && (
|
|
<div
|
|
className="relative z-20 my-auto p-6 rounded-2xl bg-surface-container/90 border border-outline-variant/30 text-center max-w-md mx-auto backdrop-blur-md"
|
|
data-testid="empty-pack-state"
|
|
>
|
|
<span className="material-symbols-outlined text-4xl text-primary mb-2">sensors_off</span>
|
|
<h4 className="font-headline-sm text-base font-bold text-on-surface">
|
|
No Active Squadron Nodes Transmitting
|
|
</h4>
|
|
<p className="font-body-sm text-xs text-on-surface-variant mt-1 leading-relaxed">
|
|
Waiting for formation participants to enable telemetry and broadcast live GNSS coordinates.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================= */}
|
|
{/* PARTICIPANT MARKERS: REAL GEOGRAPHIC MAP PROJECTION */}
|
|
{/* ========================================================= */}
|
|
{isRealRide && isAuthorized && liveParticipantsList.length > 0 && (
|
|
<div className="relative z-20 my-auto h-80 w-full pointer-events-none">
|
|
{liveParticipantsList.map((p) => {
|
|
const pos = projectMercator(
|
|
p.latitude,
|
|
p.longitude,
|
|
mapCenter.lat,
|
|
mapCenter.lon,
|
|
zoom,
|
|
viewportDim.width,
|
|
viewportDim.height,
|
|
panOffset.x,
|
|
panOffset.y
|
|
);
|
|
const isSelected = selectedRiderId === p.id;
|
|
const isStale = p.freshness === 'stale';
|
|
const isDisconnected = p.freshness === 'disconnected';
|
|
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedRiderId(p.id);
|
|
}}
|
|
style={{
|
|
left: `${pos.x}px`,
|
|
top: `${pos.y}px`,
|
|
opacity: isDisconnected ? 0.45 : isStale ? 0.75 : 1,
|
|
}}
|
|
className={`absolute -translate-x-1/2 -translate-y-1/2 pointer-events-auto cursor-pointer transition-all duration-500 ease-out group ${
|
|
isSelected ? 'z-30 scale-110' : 'z-20 hover:scale-105'
|
|
}`}
|
|
data-testid={`radar-marker-${p.callsign}`}
|
|
>
|
|
{isSelected && (
|
|
<div className="absolute -inset-2.5 rounded-full border-2 border-primary-container animate-ping pointer-events-none opacity-60"></div>
|
|
)}
|
|
|
|
<div
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded shadow-xl backdrop-blur-md transition-all ${
|
|
isSelected
|
|
? 'bg-primary-container text-on-primary-container ring-2 ring-primary-fixed shadow-[0_0_16px_rgba(0,242,254,0.6)]'
|
|
: isDisconnected
|
|
? 'bg-surface-container-low/80 text-on-surface-variant border border-outline-variant/40'
|
|
: isStale
|
|
? 'bg-surface-container-high/90 text-on-surface border border-amber-500/60'
|
|
: 'bg-surface-container-high/90 text-on-surface border border-outline-variant/30 hover:border-primary-container'
|
|
}`}
|
|
>
|
|
<span
|
|
className="material-symbols-outlined text-[14px] transition-transform duration-300"
|
|
style={{ transform: `rotate(${p.heading || 0}deg)` }}
|
|
>
|
|
navigation
|
|
</span>
|
|
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
isDisconnected
|
|
? 'bg-outline'
|
|
: isStale
|
|
? 'bg-amber-400 animate-pulse'
|
|
: 'bg-secondary-fixed animate-pulse'
|
|
}`}
|
|
></span>
|
|
|
|
<span className="font-code-mono text-code-mono font-bold">{p.callsign}</span>
|
|
|
|
<span
|
|
className={`font-label-caps text-label-caps font-semibold ${
|
|
isSelected
|
|
? 'text-on-primary-container'
|
|
: isDisconnected
|
|
? 'text-on-surface-variant'
|
|
: isStale
|
|
? 'text-amber-400'
|
|
: 'text-secondary-fixed'
|
|
}`}
|
|
>
|
|
{p.speedMph} MPH
|
|
</span>
|
|
|
|
{isStale && (
|
|
<span className="text-[10px] font-mono px-1 py-0.2 rounded bg-amber-500/20 text-amber-300">
|
|
+{Math.round(p.diffSec)}s
|
|
</span>
|
|
)}
|
|
|
|
{isDisconnected && (
|
|
<span className="text-[10px] font-mono px-1 py-0.2 rounded bg-surface-container text-on-surface-variant">
|
|
Offline
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================= */}
|
|
{/* SIMULATED RIDERS: PUBLIC SHOWCASE MARKERS */}
|
|
{/* ========================================================= */}
|
|
{!isRealRide && (
|
|
<div className="relative z-10 my-auto h-72 w-full">
|
|
{simulatedRiders.map((rider) => {
|
|
const isSelected = selectedRiderId === rider.id;
|
|
return (
|
|
<div
|
|
key={rider.id}
|
|
onClick={() => setSelectedRiderId(rider.id)}
|
|
style={{
|
|
left: `${rider.coordinates.x}%`,
|
|
top: `${rider.coordinates.y}%`,
|
|
}}
|
|
className={`absolute -translate-x-1/2 -translate-y-1/2 cursor-pointer transition-all duration-200 group ${
|
|
isSelected ? 'z-30 scale-110' : 'z-20 hover:scale-105'
|
|
}`}
|
|
data-testid={`radar-marker-${rider.callsign}`}
|
|
>
|
|
{isSelected && (
|
|
<div className="absolute -inset-2 rounded-full border-2 border-primary-container animate-ping pointer-events-none opacity-60"></div>
|
|
)}
|
|
|
|
<div
|
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded shadow-xl backdrop-blur-md transition-all ${
|
|
isSelected
|
|
? 'bg-primary-container text-on-primary-container ring-2 ring-primary-fixed shadow-[0_0_16px_rgba(0,242,254,0.6)]'
|
|
: rider.status === 'alert'
|
|
? 'bg-error-container/90 text-on-error-container border border-error'
|
|
: rider.status === 'warning'
|
|
? 'bg-surface-container-high/90 text-on-surface border border-error/50'
|
|
: 'bg-surface-container-high/90 text-on-surface border border-outline-variant/30 hover:border-primary-container'
|
|
}`}
|
|
>
|
|
<span
|
|
className={`w-2.5 h-2.5 rounded-full ${
|
|
rider.status === 'alert'
|
|
? 'bg-error animate-bounce'
|
|
: rider.status === 'warning'
|
|
? 'bg-error animate-pulse'
|
|
: isSelected
|
|
? 'bg-on-primary-container'
|
|
: 'bg-secondary-fixed animate-pulse'
|
|
}`}
|
|
></span>
|
|
<span className="font-code-mono text-code-mono font-bold">{rider.callsign}</span>
|
|
<span
|
|
className={`font-label-caps text-label-caps font-semibold ${
|
|
isSelected
|
|
? 'text-on-primary-container'
|
|
: rider.status === 'alert'
|
|
? 'text-error'
|
|
: 'text-secondary-fixed'
|
|
}`}
|
|
>
|
|
{rider.speedMph} MPH
|
|
</span>
|
|
</div>
|
|
|
|
{rider.status === 'alert' && (
|
|
<div className="absolute -bottom-7 left-1/2 -translate-x-1/2 whitespace-nowrap bg-error text-white font-code-mono text-[11px] px-2 py-0.5 rounded shadow-lg flex items-center gap-1">
|
|
<span className="material-symbols-outlined text-[13px]">warning</span>
|
|
-1.2G Decel
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Map HUD Bottom Control Strip */}
|
|
<div className="relative z-10 flex flex-wrap items-center justify-between gap-space-sm mt-auto pt-space-lg">
|
|
{/* Radar View Mode Toggles */}
|
|
<div className="flex items-center gap-space-xs bg-surface/90 backdrop-blur-md p-1.5 rounded-xl border border-outline-variant/30">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMapMode('tactical')}
|
|
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all cursor-pointer ${
|
|
mapMode === 'tactical'
|
|
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
|
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
|
}`}
|
|
>
|
|
Tactical
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMapMode('satellite')}
|
|
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all cursor-pointer ${
|
|
mapMode === 'satellite'
|
|
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
|
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
|
}`}
|
|
>
|
|
Satellite
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMapMode('elevation')}
|
|
className={`px-space-sm py-1 rounded-lg font-label-caps text-label-caps transition-all cursor-pointer ${
|
|
mapMode === 'elevation'
|
|
? 'bg-primary-container text-on-primary-container font-bold shadow-md'
|
|
: 'text-on-surface-variant hover:text-on-surface hover:bg-surface-container'
|
|
}`}
|
|
>
|
|
Elevation 3D
|
|
</button>
|
|
</div>
|
|
|
|
{/* GIS Map Controls (Zoom + Recenter) */}
|
|
{isRealRide && (
|
|
<div className="flex items-center gap-1 bg-surface/90 backdrop-blur-md px-2 py-1 rounded-xl border border-outline-variant/30 font-mono text-xs">
|
|
<button
|
|
type="button"
|
|
onClick={() => setZoom((z) => Math.min(18, z + 1))}
|
|
className="px-2 py-0.5 rounded hover:bg-surface-container text-primary font-bold transition cursor-pointer"
|
|
title="Zoom In"
|
|
>
|
|
+
|
|
</button>
|
|
<span className="text-on-surface-variant text-[11px]">Z{zoom}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setZoom((z) => Math.max(10, z - 1))}
|
|
className="px-2 py-0.5 rounded hover:bg-surface-container text-primary font-bold transition cursor-pointer"
|
|
title="Zoom Out"
|
|
>
|
|
-
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPanOffset({ x: 0, y: 0 })}
|
|
className="ml-1 px-1.5 py-0.5 rounded bg-surface-container hover:bg-surface-container-high text-secondary-fixed text-[11px] flex items-center gap-1 transition cursor-pointer"
|
|
title="Recenter Map on Pack"
|
|
>
|
|
<span className="material-symbols-outlined text-[13px]">my_location</span>
|
|
Recenter
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Regroup point readout */}
|
|
<div className="bg-surface/90 backdrop-blur-md px-space-md py-1.5 rounded-xl flex items-center gap-space-sm border border-outline-variant/30">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
|
<span className="font-code-mono text-code-mono text-primary-container font-bold">
|
|
{regroupPoint}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Live Swarm Nodes Telemetry Column */}
|
|
<div className="lg:col-span-4 bg-surface-container p-space-md flex flex-col gap-space-sm overflow-y-auto max-h-[620px] border-t lg:border-t-0 lg:border-l border-outline-variant/20">
|
|
<div className="flex items-center justify-between pb-space-xs mb-space-xs border-b border-outline-variant/20">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant tracking-wide">
|
|
Pack Telemetry Stream
|
|
</span>
|
|
<span className="font-code-mono text-code-mono text-primary-fixed-dim">
|
|
{displayedRiders.length} Active Nodes
|
|
</span>
|
|
</div>
|
|
|
|
{/* Active Rider Telemetry Cards */}
|
|
{displayedRiders.map((rider) => {
|
|
const isSelected = selectedRiderId === rider.id;
|
|
return (
|
|
<div
|
|
key={rider.id}
|
|
onClick={() => setSelectedRiderId(rider.id)}
|
|
className={`p-space-sm rounded-xl cursor-pointer transition-all border ${
|
|
isSelected
|
|
? 'bg-surface-container-highest border-primary-container shadow-md ring-1 ring-primary-container/50'
|
|
: 'bg-surface-container-high border-transparent hover:bg-surface-container-highest hover:border-outline-variant/30'
|
|
}`}
|
|
data-testid={`telemetry-card-${rider.callsign}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-space-xs">
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
rider.status === 'alert'
|
|
? 'bg-error animate-bounce'
|
|
: rider.status === 'warning'
|
|
? 'bg-error animate-pulse'
|
|
: rider.status === 'sweeper'
|
|
? 'bg-secondary-fixed'
|
|
: 'bg-secondary-fixed'
|
|
}`}
|
|
></span>
|
|
<span className="font-headline-sm text-headline-sm text-on-surface font-bold">
|
|
{rider.callsign}
|
|
</span>
|
|
<span className="px-1.5 py-0.5 rounded bg-surface-container-lowest font-label-caps text-label-caps text-secondary-fixed">
|
|
{rider.role}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`font-code-mono text-code-mono ${
|
|
rider.gapMeters === 0 ? 'text-primary-fixed font-bold' : 'text-secondary-fixed'
|
|
}`}
|
|
>
|
|
{rider.gapMeters === 0 ? 'Lead' : `+${rider.gapMeters}m Gap`}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Squad Roster Telemetry Grid: Speed, Lean, Battery, Mesh Ping */}
|
|
<div className="grid grid-cols-4 gap-1.5 mt-2 font-code-mono text-[11px] text-on-surface-variant">
|
|
<div>
|
|
<span className="block text-[9px] text-outline">Speed</span>
|
|
<span className="text-on-surface font-bold">{rider.speedMph} MPH</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[9px] text-outline">Lean</span>
|
|
<span className="text-primary-container font-bold">{rider.leanAngleDeg}°</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[9px] text-outline">Battery</span>
|
|
<span className="text-secondary-fixed font-bold">{rider.batteryPct ?? 92}%</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[9px] text-outline">Mesh Ping</span>
|
|
<span className="text-primary-fixed-dim font-bold">{rider.meshPingMs ?? 12}ms</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Warning / Alert Callout */}
|
|
{rider.alertText && (
|
|
<div className="mt-2 pt-1 border-t border-outline-variant/20 flex items-center gap-1 font-code-mono text-code-mono text-error">
|
|
<span className="material-symbols-outlined text-[15px]">
|
|
{rider.status === 'alert' ? 'warning' : 'tire_repair'}
|
|
</span>
|
|
<span className="truncate">{rider.alertText}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Selected Rider Inspector HUD Drawer */}
|
|
{selectedRider && (
|
|
<div
|
|
className="mt-auto p-space-sm rounded-xl bg-surface-container-lowest border border-primary-container/40"
|
|
data-testid="selected-node-inspector"
|
|
>
|
|
<div className="flex items-center justify-between text-label-caps font-label-caps text-primary-container mb-1">
|
|
<span>Selected Node Inspector</span>
|
|
<span className="font-code-mono">{selectedRider.callsign}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-code-mono font-code-mono text-on-surface-variant text-[12px]">
|
|
<span>Role: {selectedRider.roleBadge}</span>
|
|
<span>Gap Delta: ±{selectedRider.gapDeltaSec}s</span>
|
|
<span className="text-secondary-fixed">Live Sync: 20Hz</span>
|
|
</div>
|
|
{isRealRide && livePresences[selectedRider.id] && (
|
|
<div className="mt-1 pt-1 border-t border-outline-variant/20 text-[11px] font-mono text-on-surface-variant flex justify-between">
|
|
<span>
|
|
GPS: {livePresences[selectedRider.id].latitude.toFixed(4)},{' '}
|
|
{livePresences[selectedRider.id].longitude.toFixed(4)}
|
|
</span>
|
|
<span className="text-primary">
|
|
HDG: {Math.round(livePresences[selectedRider.id].heading)}°
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Formation Safety Metrics Panel Strip */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 bg-surface-container-highest p-space-lg gap-space-lg border-t border-outline-variant/20">
|
|
{/* Metric 1: Spacing Integrity */}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Spacing Integrity</span>
|
|
<span className="material-symbols-outlined text-secondary-fixed text-[18px]">verified</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-space-xs">
|
|
<span className="font-telemetry-metric-md text-telemetry-metric-md text-secondary-fixed">94%</span>
|
|
<span className="font-code-mono text-code-mono text-on-surface-variant">Safety Index</span>
|
|
</div>
|
|
<div className="w-full bg-surface-container h-1.5 rounded-full overflow-hidden mt-1">
|
|
<div className="bg-secondary-fixed h-full rounded-full w-[94%]"></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metric 2: Average Pack Speed */}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Average Pack Velocity</span>
|
|
<span className="material-symbols-outlined text-primary-container text-[18px]">speed</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-space-xs">
|
|
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">
|
|
{isRealRide && liveParticipantsList.length > 0
|
|
? (
|
|
liveParticipantsList.reduce((acc, p) => acc + p.speedMph, 0) /
|
|
liveParticipantsList.length
|
|
).toFixed(1)
|
|
: '64.2'}
|
|
</span>
|
|
<span className="font-code-mono text-code-mono text-primary-fixed-dim">MPH</span>
|
|
</div>
|
|
<span className="font-code-mono text-code-mono text-on-surface-variant">
|
|
Delta Drift: -1.8 MPH (tightening)
|
|
</span>
|
|
</div>
|
|
|
|
{/* Metric 3: Atmospheric & Track Conditions */}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Surface & Climate</span>
|
|
<span className="material-symbols-outlined text-on-surface-variant text-[18px]">thermostat</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-space-xs">
|
|
<span className="font-telemetry-metric-md text-telemetry-metric-md text-on-surface">21°C</span>
|
|
<span className="font-code-mono text-code-mono text-on-surface-variant">Dry Asphalt</span>
|
|
</div>
|
|
<span className="font-code-mono text-code-mono text-secondary-fixed">0% Precip • Grip Optimal</span>
|
|
</div>
|
|
|
|
{/* Metric 4: Next Regroup Waypoint */}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-label-caps text-label-caps text-on-surface-variant">Regroup Point</span>
|
|
<span className="material-symbols-outlined text-primary-container text-[18px]">pin_drop</span>
|
|
</div>
|
|
<div className="flex items-baseline gap-space-xs">
|
|
<span className="font-telemetry-metric-md text-telemetry-metric-md text-primary-container">14.8</span>
|
|
<span className="font-code-mono text-code-mono text-primary-fixed-dim">Miles</span>
|
|
</div>
|
|
<span className="font-code-mono text-code-mono text-on-surface">
|
|
{selectedRide?.meetingPoint || "Alice's Restaurant Checkpoint"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|