234 lines
7.6 KiB
TypeScript
234 lines
7.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import dynamic from 'next/dynamic';
|
|
import type { Participant, MapViewport, Waypoint } from '@/types';
|
|
import { isInC3NavArea } from '@/lib/c3nav';
|
|
import { useRoomStore } from '@/stores/room';
|
|
import NavigationPanel from './NavigationPanel';
|
|
|
|
// Dynamic imports to avoid SSR issues
|
|
const MapView = dynamic(() => import('./MapView'), {
|
|
ssr: false,
|
|
loading: () => <MapLoading />,
|
|
});
|
|
|
|
const IndoorMapView = dynamic(() => import('./IndoorMapView'), {
|
|
ssr: false,
|
|
loading: () => <MapLoading />,
|
|
});
|
|
|
|
function MapLoading() {
|
|
return (
|
|
<div className="w-full h-full bg-rmaps-dark flex items-center justify-center">
|
|
<div className="text-white/60">Loading map...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type MapMode = 'outdoor' | 'indoor' | 'auto';
|
|
|
|
interface DualMapViewProps {
|
|
participants: Participant[];
|
|
waypoints?: Waypoint[];
|
|
currentUserId?: string;
|
|
currentLocation?: { latitude: number; longitude: number } | null;
|
|
eventId?: string;
|
|
initialMode?: MapMode;
|
|
onParticipantClick?: (participant: Participant) => void;
|
|
onWaypointClick?: (waypoint: Waypoint) => void;
|
|
onIndoorPositionSet?: (position: { level: number; x: number; y: number }) => void;
|
|
/** Whether location sharing is active */
|
|
isSharing?: boolean;
|
|
/** Callback to toggle location sharing */
|
|
onToggleSharing?: () => void;
|
|
}
|
|
|
|
// CCC venue bounds (Hamburg Congress Center)
|
|
const CCC_BOUNDS = {
|
|
north: 53.558,
|
|
south: 53.552,
|
|
east: 9.995,
|
|
west: 9.985,
|
|
};
|
|
|
|
export default function DualMapView({
|
|
participants,
|
|
waypoints = [],
|
|
currentUserId,
|
|
currentLocation,
|
|
eventId = '38c3',
|
|
initialMode = 'auto',
|
|
onParticipantClick,
|
|
onWaypointClick,
|
|
onIndoorPositionSet,
|
|
isSharing = false,
|
|
onToggleSharing,
|
|
}: DualMapViewProps) {
|
|
const [mode, setMode] = useState<MapMode>(initialMode);
|
|
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
|
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
|
|
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(null);
|
|
|
|
// Get route state from store
|
|
const { activeRoute, clearRoute } = useRoomStore();
|
|
|
|
// Auto-detect indoor/outdoor based on location
|
|
useEffect(() => {
|
|
if (mode !== 'auto' || !currentLocation) return;
|
|
|
|
const isIndoor = isInC3NavArea(currentLocation.latitude, currentLocation.longitude);
|
|
setActiveView(isIndoor ? 'indoor' : 'outdoor');
|
|
}, [mode, currentLocation]);
|
|
|
|
// Manual toggle
|
|
const toggleView = useCallback(() => {
|
|
setMode('outdoor'); // Switch to manual mode
|
|
setActiveView((prev) => (prev === 'outdoor' ? 'indoor' : 'outdoor'));
|
|
}, []);
|
|
|
|
// Force outdoor
|
|
const goOutdoor = useCallback(() => {
|
|
setMode('outdoor');
|
|
setActiveView('outdoor');
|
|
}, []);
|
|
|
|
// Force indoor
|
|
const goIndoor = useCallback(() => {
|
|
setMode('indoor');
|
|
setActiveView('indoor');
|
|
}, []);
|
|
|
|
// Handle participant click - show navigation panel
|
|
const handleParticipantClick = useCallback((participant: Participant) => {
|
|
setSelectedParticipant(participant);
|
|
setSelectedWaypoint(null);
|
|
onParticipantClick?.(participant);
|
|
}, [onParticipantClick]);
|
|
|
|
// Handle waypoint click - show navigation panel
|
|
const handleWaypointClick = useCallback((waypoint: Waypoint) => {
|
|
setSelectedWaypoint(waypoint);
|
|
setSelectedParticipant(null);
|
|
onWaypointClick?.(waypoint);
|
|
}, [onWaypointClick]);
|
|
|
|
// Close navigation panel
|
|
const closeNavigationPanel = useCallback(() => {
|
|
setSelectedParticipant(null);
|
|
setSelectedWaypoint(null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative w-full h-full">
|
|
{/* Map view */}
|
|
{activeView === 'outdoor' ? (
|
|
<MapView
|
|
participants={participants}
|
|
waypoints={waypoints}
|
|
currentUserId={currentUserId}
|
|
onParticipantClick={handleParticipantClick}
|
|
onWaypointClick={handleWaypointClick}
|
|
routeSegments={activeRoute?.segments}
|
|
routeLoading={activeRoute?.isLoading}
|
|
routeError={activeRoute?.error}
|
|
routeSummary={activeRoute?.summary}
|
|
routeDistance={activeRoute?.totalDistance}
|
|
routeTime={activeRoute?.estimatedTime}
|
|
routeDestination={activeRoute?.to.name}
|
|
onClearRoute={clearRoute}
|
|
/>
|
|
) : (
|
|
<IndoorMapView
|
|
eventId={eventId}
|
|
participants={participants}
|
|
currentUserId={currentUserId}
|
|
onParticipantClick={handleParticipantClick}
|
|
onSwitchToOutdoor={goOutdoor}
|
|
onPositionSet={onIndoorPositionSet}
|
|
/>
|
|
)}
|
|
|
|
{/* Navigation panel for selected participant/waypoint */}
|
|
{(selectedParticipant || selectedWaypoint) && (
|
|
<NavigationPanel
|
|
selectedParticipant={selectedParticipant}
|
|
selectedWaypoint={selectedWaypoint}
|
|
onClose={closeNavigationPanel}
|
|
/>
|
|
)}
|
|
|
|
{/* Location sharing button - floating inside map at bottom right for mobile visibility */}
|
|
{onToggleSharing && (
|
|
<button
|
|
onClick={onToggleSharing}
|
|
className={`absolute bottom-20 right-4 z-30 flex items-center gap-2 px-4 py-3 rounded-full shadow-lg transition-all ${
|
|
isSharing
|
|
? 'bg-rmaps-primary text-white'
|
|
: 'bg-white text-gray-800 hover:bg-gray-100'
|
|
}`}
|
|
title={isSharing ? 'Stop sharing location' : 'Share my location'}
|
|
>
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill={isSharing ? 'currentColor' : 'none'}
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm font-medium hidden sm:inline">
|
|
{isSharing ? 'Sharing' : 'Share Location'}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Indoor Map button - switch to indoor view */}
|
|
{activeView === 'outdoor' && (
|
|
<button
|
|
onClick={goIndoor}
|
|
className="absolute bottom-4 left-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors flex items-center gap-2 z-30"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
|
|
/>
|
|
</svg>
|
|
Indoor Map
|
|
</button>
|
|
)}
|
|
|
|
{/* Auto-mode indicator */}
|
|
{mode === 'auto' && (
|
|
<div className="absolute top-4 left-4 bg-rmaps-primary/20 text-rmaps-primary text-xs px-2 py-1 rounded-full">
|
|
Auto-detecting location
|
|
</div>
|
|
)}
|
|
|
|
{/* Venue proximity indicator */}
|
|
{currentLocation && isInC3NavArea(currentLocation.latitude, currentLocation.longitude) && activeView === 'outdoor' && (
|
|
<div className="absolute top-4 left-1/2 -translate-x-1/2 bg-rmaps-secondary/90 text-white text-sm px-3 py-1.5 rounded-full flex items-center gap-2 z-30">
|
|
<span>You're at the venue!</span>
|
|
<button onClick={goIndoor} className="underline">
|
|
Switch to indoor
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|