feat: Add c3nav indoor map integration with floor selector
- Add c3nav tile proxy API route with session handling - Add c3nav data API proxy for locations/bounds - Create IndoorMapView component with MapLibre GL - Add floor level selector (Level 0-4) - Tap-to-set-position on indoor map - Sync indoor positions between participants - Easter egg: Triple-click Level 0 for "The Underground of the Underground" - Fix race condition in useRoom when no user data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
50e1feb62c
commit
760e27564c
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Proxy c3nav API calls
|
||||||
|
// URL pattern: /api/c3nav/{event}?endpoint=map/locations
|
||||||
|
// Proxies to: https://{event}.c3nav.de/api/v2/{endpoint}
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: {
|
||||||
|
event: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid c3nav events
|
||||||
|
const VALID_EVENTS = ['38c3', '37c3', 'eh22', 'eh2025', 'camp2023'];
|
||||||
|
|
||||||
|
// Allowed API endpoints (whitelist for security)
|
||||||
|
const ALLOWED_ENDPOINTS = [
|
||||||
|
'map/settings',
|
||||||
|
'map/bounds',
|
||||||
|
'map/locations',
|
||||||
|
'map/locations/full',
|
||||||
|
'map/projection',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cache for session cookies per event
|
||||||
|
const sessionCache = new Map<string, { cookie: string; expires: number }>();
|
||||||
|
|
||||||
|
// Get a valid session cookie for an event
|
||||||
|
async function getSessionCookie(event: string): Promise<string | null> {
|
||||||
|
const cached = sessionCache.get(event);
|
||||||
|
if (cached && cached.expires > Date.now()) {
|
||||||
|
return cached.cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get session by visiting the main page
|
||||||
|
const response = await fetch(`https://${event}.c3nav.de/`, {
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
|
||||||
|
const setCookie = response.headers.get('set-cookie');
|
||||||
|
if (setCookie) {
|
||||||
|
// Extract tile_access cookie
|
||||||
|
const match = setCookie.match(/c3nav_tile_access="([^"]+)"/);
|
||||||
|
if (match) {
|
||||||
|
const cookie = `c3nav_tile_access="${match[1]}"`;
|
||||||
|
// Cache for 50 seconds (cookie lasts 60s)
|
||||||
|
sessionCache.set(event, {
|
||||||
|
cookie,
|
||||||
|
expires: Date.now() + 50000
|
||||||
|
});
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to get c3nav session:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { event } = params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'map/bounds';
|
||||||
|
|
||||||
|
// Validate event
|
||||||
|
if (!VALID_EVENTS.includes(event)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid event' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if endpoint is allowed (basic path check)
|
||||||
|
const isAllowed = ALLOWED_ENDPOINTS.some(
|
||||||
|
(allowed) => endpoint === allowed || endpoint.startsWith(allowed + '/')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAllowed && !endpoint.startsWith('map/locations/')) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Endpoint not allowed' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build c3nav API URL (trailing slash required to avoid redirect)
|
||||||
|
const apiUrl = `https://${event}.c3nav.de/api/v2/${endpoint}/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get session cookie
|
||||||
|
const sessionCookie = await getSessionCookie(event);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'X-API-Key': 'anonymous',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'rMaps.online/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionCookie) {
|
||||||
|
headers['Cookie'] = sessionCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create AbortController for timeout
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
return NextResponse.json(errorData, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return NextResponse.json(data, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('c3nav API proxy error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch from c3nav' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Proxy c3nav tiles to add CORS headers
|
||||||
|
// URL pattern: /api/c3nav/tiles/{event}/{level}/{z}/{x}/{y}
|
||||||
|
// Proxies to: https://tiles.{event}.c3nav.de/{level}/{z}/{x}/{y}.png
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
params: {
|
||||||
|
event: string;
|
||||||
|
level: string;
|
||||||
|
z: string;
|
||||||
|
x: string;
|
||||||
|
y: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid c3nav events
|
||||||
|
const VALID_EVENTS = ['38c3', '37c3', 'eh22', 'eh2025', 'camp2023'];
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
const { event, level, z, x, y } = params;
|
||||||
|
|
||||||
|
// Validate event
|
||||||
|
if (!VALID_EVENTS.includes(event)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid event' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate numeric params
|
||||||
|
const levelNum = parseInt(level, 10);
|
||||||
|
const zNum = parseInt(z, 10);
|
||||||
|
const xNum = parseInt(x, 10);
|
||||||
|
const yNum = parseInt(y, 10);
|
||||||
|
|
||||||
|
if ([levelNum, zNum, xNum, yNum].some(isNaN)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid tile coordinates' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build c3nav tile URL
|
||||||
|
const tileUrl = `https://tiles.${event}.c3nav.de/${level}/${z}/${x}/${y}.png`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(tileUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'rMaps.online/1.0 (c3nav tile proxy)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Pass through error responses
|
||||||
|
const errorText = await response.text();
|
||||||
|
return new NextResponse(errorText, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the tile image
|
||||||
|
const imageBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
|
// Return with CORS headers
|
||||||
|
return new NextResponse(imageBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/png',
|
||||||
|
'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Tile proxy error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch tile' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new NextResponse(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ export default function RoomPage() {
|
||||||
currentParticipantId,
|
currentParticipantId,
|
||||||
roomName,
|
roomName,
|
||||||
updateLocation,
|
updateLocation,
|
||||||
|
updateIndoorPosition,
|
||||||
clearLocation,
|
clearLocation,
|
||||||
setStatus,
|
setStatus,
|
||||||
addWaypoint,
|
addWaypoint,
|
||||||
|
|
@ -214,6 +215,7 @@ export default function RoomPage() {
|
||||||
onWaypointClick={(w) => {
|
onWaypointClick={(w) => {
|
||||||
console.log('Waypoint clicked:', w.name);
|
console.log('Waypoint clicked:', w.name);
|
||||||
}}
|
}}
|
||||||
|
onIndoorPositionSet={updateIndoorPosition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Participant Panel */}
|
{/* Participant Panel */}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const MapView = dynamic(() => import('./MapView'), {
|
||||||
loading: () => <MapLoading />,
|
loading: () => <MapLoading />,
|
||||||
});
|
});
|
||||||
|
|
||||||
const C3NavEmbed = dynamic(() => import('./C3NavEmbed'), {
|
const IndoorMapView = dynamic(() => import('./IndoorMapView'), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => <MapLoading />,
|
loading: () => <MapLoading />,
|
||||||
});
|
});
|
||||||
|
|
@ -35,6 +35,7 @@ interface DualMapViewProps {
|
||||||
initialMode?: MapMode;
|
initialMode?: MapMode;
|
||||||
onParticipantClick?: (participant: Participant) => void;
|
onParticipantClick?: (participant: Participant) => void;
|
||||||
onWaypointClick?: (waypoint: Waypoint) => void;
|
onWaypointClick?: (waypoint: Waypoint) => void;
|
||||||
|
onIndoorPositionSet?: (position: { level: number; x: number; y: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CCC venue bounds (Hamburg Congress Center)
|
// CCC venue bounds (Hamburg Congress Center)
|
||||||
|
|
@ -54,6 +55,7 @@ export default function DualMapView({
|
||||||
initialMode = 'auto',
|
initialMode = 'auto',
|
||||||
onParticipantClick,
|
onParticipantClick,
|
||||||
onWaypointClick,
|
onWaypointClick,
|
||||||
|
onIndoorPositionSet,
|
||||||
}: DualMapViewProps) {
|
}: DualMapViewProps) {
|
||||||
const [mode, setMode] = useState<MapMode>(initialMode);
|
const [mode, setMode] = useState<MapMode>(initialMode);
|
||||||
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
||||||
|
|
@ -96,21 +98,20 @@ export default function DualMapView({
|
||||||
onWaypointClick={onWaypointClick}
|
onWaypointClick={onWaypointClick}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<C3NavEmbed
|
<IndoorMapView
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onToggleOutdoor={goOutdoor}
|
onParticipantClick={onParticipantClick}
|
||||||
showToggle={true}
|
onSwitchToOutdoor={goOutdoor}
|
||||||
|
onPositionSet={onIndoorPositionSet}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Indoor Map button - opens c3nav in new tab (iframe embedding blocked) */}
|
{/* Indoor Map button - switch to indoor view */}
|
||||||
{activeView === 'outdoor' && (
|
{activeView === 'outdoor' && (
|
||||||
<a
|
<button
|
||||||
href={`https://${eventId}.c3nav.de`}
|
onClick={goIndoor}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
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"
|
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">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -122,10 +123,7 @@ export default function DualMapView({
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Indoor Map
|
Indoor Map
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</button>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Auto-mode indicator */}
|
{/* Auto-mode indicator */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,414 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
|
import type { Participant } from '@/types';
|
||||||
|
|
||||||
|
interface Level {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
level_index: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easter egg: The mythical Level -1
|
||||||
|
const LEVEL_MINUS_ONE: Level = {
|
||||||
|
id: -1,
|
||||||
|
slug: 'level--1',
|
||||||
|
title: '🕳️ The Underground of the Underground',
|
||||||
|
level_index: '-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IndoorMapViewProps {
|
||||||
|
eventId: string;
|
||||||
|
participants: Participant[];
|
||||||
|
currentUserId?: string;
|
||||||
|
onPositionSet?: (position: { level: number; x: number; y: number }) => void;
|
||||||
|
onParticipantClick?: (participant: Participant) => void;
|
||||||
|
onSwitchToOutdoor?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IndoorMapView({
|
||||||
|
eventId,
|
||||||
|
participants,
|
||||||
|
currentUserId,
|
||||||
|
onPositionSet,
|
||||||
|
onParticipantClick,
|
||||||
|
onSwitchToOutdoor,
|
||||||
|
}: IndoorMapViewProps) {
|
||||||
|
const mapContainer = useRef<HTMLDivElement>(null);
|
||||||
|
const map = useRef<maplibregl.Map | null>(null);
|
||||||
|
const markersRef = useRef<Map<string, maplibregl.Marker>>(new Map());
|
||||||
|
|
||||||
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
|
const [levels, setLevels] = useState<Level[]>([]);
|
||||||
|
const [currentLevel, setCurrentLevel] = useState<Level | null>(null);
|
||||||
|
const [bounds, setBounds] = useState<[[number, number], [number, number]] | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Easter egg state
|
||||||
|
const [level0Clicks, setLevel0Clicks] = useState(0);
|
||||||
|
const [showLevelMinus1, setShowLevelMinus1] = useState(false);
|
||||||
|
const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Fetch levels and bounds from c3nav API
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchMapData() {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch bounds
|
||||||
|
const boundsRes = await fetch(`/api/c3nav/${eventId}?endpoint=map/bounds`);
|
||||||
|
if (!boundsRes.ok) throw new Error('Failed to fetch bounds');
|
||||||
|
const boundsData = await boundsRes.json();
|
||||||
|
setBounds(boundsData.bounds);
|
||||||
|
|
||||||
|
// Fetch locations to get levels
|
||||||
|
const locationsRes = await fetch(`/api/c3nav/${eventId}?endpoint=map/locations`);
|
||||||
|
if (!locationsRes.ok) throw new Error('Failed to fetch locations');
|
||||||
|
const locationsData = await locationsRes.json();
|
||||||
|
|
||||||
|
// Filter levels and sort by level_index
|
||||||
|
const levelLocations = locationsData
|
||||||
|
.filter((loc: any) => loc.locationtype === 'level')
|
||||||
|
.sort((a: any, b: any) => parseInt(a.level_index) - parseInt(b.level_index));
|
||||||
|
|
||||||
|
setLevels(levelLocations);
|
||||||
|
|
||||||
|
// Set default level (Level 0 or first available)
|
||||||
|
const defaultLevel = levelLocations.find((l: Level) => l.level_index === '0') || levelLocations[0];
|
||||||
|
setCurrentLevel(defaultLevel);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch c3nav data:', err);
|
||||||
|
setError('Failed to load indoor map data');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchMapData();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
// Initialize/update map when level changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapContainer.current || !bounds || !currentLevel) return;
|
||||||
|
|
||||||
|
// Calculate center from bounds
|
||||||
|
const centerX = (bounds[0][0] + bounds[1][0]) / 2;
|
||||||
|
const centerY = (bounds[0][1] + bounds[1][1]) / 2;
|
||||||
|
|
||||||
|
// Destroy existing map if level changed
|
||||||
|
if (map.current) {
|
||||||
|
map.current.remove();
|
||||||
|
map.current = null;
|
||||||
|
markersRef.current.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map with c3nav tile source
|
||||||
|
map.current = new maplibregl.Map({
|
||||||
|
container: mapContainer.current,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
c3nav: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: [
|
||||||
|
`/api/c3nav/tiles/${eventId}/${currentLevel.id}/{z}/{x}/{y}`,
|
||||||
|
],
|
||||||
|
tileSize: 257,
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 5,
|
||||||
|
bounds: [bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'background',
|
||||||
|
type: 'background',
|
||||||
|
paint: {
|
||||||
|
'background-color': '#1a1a2e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c3nav-tiles',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'c3nav',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
center: [centerX, centerY],
|
||||||
|
zoom: 2,
|
||||||
|
minZoom: 0,
|
||||||
|
maxZoom: 5,
|
||||||
|
// c3nav uses a simple coordinate system, not geographic
|
||||||
|
renderWorldCopies: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add controls
|
||||||
|
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||||
|
|
||||||
|
// Handle click to set position
|
||||||
|
map.current.on('click', (e) => {
|
||||||
|
if (onPositionSet && currentLevel) {
|
||||||
|
onPositionSet({
|
||||||
|
level: currentLevel.id,
|
||||||
|
x: e.lngLat.lng,
|
||||||
|
y: e.lngLat.lat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
map.current.on('load', () => {
|
||||||
|
setMapLoaded(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.current?.remove();
|
||||||
|
map.current = null;
|
||||||
|
};
|
||||||
|
}, [eventId, bounds, currentLevel, onPositionSet]);
|
||||||
|
|
||||||
|
// Update participant markers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map.current || !mapLoaded || !currentLevel) return;
|
||||||
|
|
||||||
|
const currentMarkers = markersRef.current;
|
||||||
|
|
||||||
|
// Filter participants on current level with indoor location
|
||||||
|
const indoorParticipants = participants.filter(
|
||||||
|
(p) => p.location?.indoor && p.location.indoor.level === currentLevel.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const participantIds = new Set(indoorParticipants.map((p) => p.id));
|
||||||
|
|
||||||
|
// Remove markers for participants not on this level
|
||||||
|
currentMarkers.forEach((marker, id) => {
|
||||||
|
if (!participantIds.has(id)) {
|
||||||
|
marker.remove();
|
||||||
|
currentMarkers.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add/update markers for participants on this level
|
||||||
|
indoorParticipants.forEach((participant) => {
|
||||||
|
if (!participant.location?.indoor) return;
|
||||||
|
|
||||||
|
const { x, y } = participant.location.indoor;
|
||||||
|
let marker = currentMarkers.get(participant.id);
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
marker.setLngLat([x, y]);
|
||||||
|
} else {
|
||||||
|
// Create marker element
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'indoor-marker';
|
||||||
|
el.style.cssText = `
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${participant.color};
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
`;
|
||||||
|
el.innerHTML = participant.emoji;
|
||||||
|
|
||||||
|
if (participant.id === currentUserId) {
|
||||||
|
el.style.border = '3px solid #10b981';
|
||||||
|
el.style.boxShadow = '0 0 0 3px rgba(16, 185, 129, 0.3), 0 2px 8px rgba(0,0,0,0.3)';
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onParticipantClick?.(participant);
|
||||||
|
});
|
||||||
|
|
||||||
|
marker = new maplibregl.Marker({ element: el })
|
||||||
|
.setLngLat([x, y])
|
||||||
|
.addTo(map.current!);
|
||||||
|
|
||||||
|
currentMarkers.set(participant.id, marker);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [participants, mapLoaded, currentLevel, currentUserId, onParticipantClick]);
|
||||||
|
|
||||||
|
// Handle level change with easter egg
|
||||||
|
const handleLevelChange = useCallback((level: Level) => {
|
||||||
|
// Easter egg: Triple-click Level 0 to reveal Level -1
|
||||||
|
if (level.level_index === '0') {
|
||||||
|
if (clickTimeoutRef.current) {
|
||||||
|
clearTimeout(clickTimeoutRef.current);
|
||||||
|
}
|
||||||
|
const newClicks = level0Clicks + 1;
|
||||||
|
setLevel0Clicks(newClicks);
|
||||||
|
|
||||||
|
if (newClicks >= 3 && !showLevelMinus1) {
|
||||||
|
setShowLevelMinus1(true);
|
||||||
|
setLevel0Clicks(0);
|
||||||
|
// Brief "glitch" effect could go here
|
||||||
|
console.log('🕳️ You found Level -1! The rabbit hole goes deeper...');
|
||||||
|
return; // Don't change level, just reveal the easter egg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset click counter after 500ms
|
||||||
|
clickTimeoutRef.current = setTimeout(() => {
|
||||||
|
setLevel0Clicks(0);
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
setLevel0Clicks(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Level -1 easter egg
|
||||||
|
if (level.id === -1) {
|
||||||
|
setCurrentLevel(level);
|
||||||
|
setMapLoaded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentLevel(level);
|
||||||
|
setMapLoaded(false);
|
||||||
|
}, [level0Clicks, showLevelMinus1]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-rmaps-dark flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||||
|
<div className="text-white/60 text-sm">Loading indoor map...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-rmaps-dark flex items-center justify-center">
|
||||||
|
<div className="text-center p-4">
|
||||||
|
<div className="text-red-400 mb-2">{error}</div>
|
||||||
|
<button onClick={onSwitchToOutdoor} className="btn-ghost text-sm">
|
||||||
|
Switch to Outdoor Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
{/* Map container */}
|
||||||
|
<div ref={mapContainer} className="w-full h-full" />
|
||||||
|
|
||||||
|
{/* Level selector */}
|
||||||
|
{levels.length > 1 && (
|
||||||
|
<div className="absolute top-4 left-4 bg-rmaps-dark/95 rounded-lg shadow-lg overflow-hidden z-10">
|
||||||
|
<div className="text-xs text-white/60 px-3 py-1 border-b border-white/10">
|
||||||
|
Floor
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Easter egg: Level -1 appears at the bottom when unlocked */}
|
||||||
|
{showLevelMinus1 && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleLevelChange(LEVEL_MINUS_ONE)}
|
||||||
|
className={`px-4 py-2 text-sm text-left transition-colors ${
|
||||||
|
currentLevel?.id === -1
|
||||||
|
? 'bg-purple-600 text-white animate-pulse'
|
||||||
|
: 'text-purple-400 hover:bg-purple-900/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{LEVEL_MINUS_ONE.title}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{levels.map((level) => (
|
||||||
|
<button
|
||||||
|
key={level.id}
|
||||||
|
onClick={() => handleLevelChange(level)}
|
||||||
|
className={`px-4 py-2 text-sm text-left transition-colors ${
|
||||||
|
currentLevel?.id === level.id
|
||||||
|
? 'bg-rmaps-primary text-white'
|
||||||
|
: 'text-white/80 hover:bg-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{level.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current level indicator */}
|
||||||
|
<div className={`absolute top-4 left-1/2 -translate-x-1/2 text-white text-sm px-3 py-1.5 rounded-full z-10 ${
|
||||||
|
currentLevel?.id === -1 ? 'bg-purple-600/90 animate-pulse' : 'bg-rmaps-dark/90'
|
||||||
|
}`}>
|
||||||
|
{currentLevel?.title || 'Indoor Map'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Level -1 Easter Egg Overlay */}
|
||||||
|
{currentLevel?.id === -1 && (
|
||||||
|
<div className="absolute inset-0 bg-black/90 flex items-center justify-center z-20 backdrop-blur-sm">
|
||||||
|
<div className="text-center p-8 max-w-md">
|
||||||
|
<div className="text-6xl mb-4 animate-bounce">🕳️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-purple-400 mb-4 font-mono">
|
||||||
|
THE UNDERGROUND OF THE UNDERGROUND
|
||||||
|
</h2>
|
||||||
|
<p className="text-green-400 font-mono text-sm mb-6 leading-relaxed">
|
||||||
|
> ACCESS GRANTED<br/>
|
||||||
|
> Welcome, fellow hacker.<br/>
|
||||||
|
> You found the secret level.<br/>
|
||||||
|
> Some say it's where the real<br/>
|
||||||
|
> Congress happens... 🐇
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-white/40 font-mono">
|
||||||
|
// TODO: Add actual underground map<br/>
|
||||||
|
// when we find the blueprints
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleLevelChange(levels[0])}
|
||||||
|
className="mt-6 px-4 py-2 bg-purple-600 hover:bg-purple-500 rounded text-white text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Return to Surface
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Switch to outdoor button */}
|
||||||
|
{onSwitchToOutdoor && (
|
||||||
|
<button
|
||||||
|
onClick={onSwitchToOutdoor}
|
||||||
|
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-10"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Outdoor Map
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tap to set position hint */}
|
||||||
|
{onPositionSet && (
|
||||||
|
<div className="absolute bottom-4 right-4 bg-rmaps-secondary/90 text-white text-xs px-3 py-1.5 rounded-full z-10">
|
||||||
|
Tap map to set your position
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading overlay for level change */}
|
||||||
|
{!mapLoaded && !isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-rmaps-dark/80 flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ interface UseRoomReturn {
|
||||||
currentParticipantId: string | null;
|
currentParticipantId: string | null;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
updateLocation: (location: ParticipantLocation) => void;
|
updateLocation: (location: ParticipantLocation) => void;
|
||||||
|
updateIndoorPosition: (position: { level: number; x: number; y: number }) => void;
|
||||||
clearLocation: () => void;
|
clearLocation: () => void;
|
||||||
setStatus: (status: Participant['status']) => void;
|
setStatus: (status: Participant['status']) => void;
|
||||||
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
|
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
|
||||||
|
|
@ -74,7 +75,11 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
|
|
||||||
// Initialize room connection
|
// Initialize room connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userName) return;
|
if (!userName) {
|
||||||
|
// No user yet - not loading, just waiting
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -143,6 +148,12 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
syncRef.current.clearLocation();
|
syncRef.current.clearLocation();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Update indoor position (from c3nav map tap)
|
||||||
|
const updateIndoorPosition = useCallback((position: { level: number; x: number; y: number }) => {
|
||||||
|
if (!syncRef.current) return;
|
||||||
|
syncRef.current.updateIndoorPosition(position);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Set status
|
// Set status
|
||||||
const setStatus = useCallback((status: Participant['status']) => {
|
const setStatus = useCallback((status: Participant['status']) => {
|
||||||
if (!syncRef.current) return;
|
if (!syncRef.current) return;
|
||||||
|
|
@ -194,6 +205,7 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
currentParticipantId: participantIdRef.current,
|
currentParticipantId: participantIdRef.current,
|
||||||
roomName,
|
roomName,
|
||||||
updateLocation,
|
updateLocation,
|
||||||
|
updateIndoorPosition,
|
||||||
clearLocation,
|
clearLocation,
|
||||||
setStatus,
|
setStatus,
|
||||||
addWaypoint,
|
addWaypoint,
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,37 @@ export class RoomSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateIndoorPosition(indoor: { level: number; x: number; y: number }): void {
|
||||||
|
console.log('RoomSync.updateIndoorPosition called:', indoor.level, indoor.x, indoor.y);
|
||||||
|
if (this.state.participants[this.participantId]) {
|
||||||
|
// Update or create location with indoor data
|
||||||
|
const existingLocation = this.state.participants[this.participantId].location;
|
||||||
|
const location: LocationState = existingLocation || {
|
||||||
|
latitude: 0,
|
||||||
|
longitude: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: 'manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
location.indoor = {
|
||||||
|
level: indoor.level,
|
||||||
|
x: indoor.x,
|
||||||
|
y: indoor.y,
|
||||||
|
};
|
||||||
|
location.timestamp = new Date().toISOString();
|
||||||
|
location.source = 'manual';
|
||||||
|
|
||||||
|
this.state.participants[this.participantId].location = location;
|
||||||
|
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
|
||||||
|
this.send({ type: 'location', participantId: this.participantId, location });
|
||||||
|
console.log('Indoor position set for participant:', this.participantId);
|
||||||
|
this.notifyStateChange();
|
||||||
|
} else {
|
||||||
|
console.warn('Cannot update indoor position - participant not found:', this.participantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
addWaypoint(waypoint: WaypointState): void {
|
addWaypoint(waypoint: WaypointState): void {
|
||||||
this.state.waypoints.push(waypoint);
|
this.state.waypoints.push(waypoint);
|
||||||
this.send({ type: 'waypoint_add', waypoint });
|
this.send({ type: 'waypoint_add', waypoint });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue