fix: Improve location handling, session persistence, and mobile UI
Location fixes: - Add coordinate validation to reject invalid (0,0) and out-of-bounds locations - Clean up stale participants after 15 minutes (was 1 hour) - Remove invalid locations from localStorage on load - Use CCH venue center instead of (0,0) for indoor positions without GPS Session persistence: - Remember user name and emoji across sessions on home page - Pre-fill profile form with saved user info Mobile UI improvements: - Add floating location share button inside the map - Visible on all screen sizes with responsive text - Prominent styling: white when inactive, green when sharing 🤖 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
a6c124c14c
commit
e59150a9ce
|
|
@ -1,10 +1,11 @@
|
|||
---
|
||||
id: task-1
|
||||
title: Implement navigation routes
|
||||
status: To Do
|
||||
assignee: []
|
||||
status: Done
|
||||
assignee: [@claude]
|
||||
created_date: '2025-12-15 19:37'
|
||||
labels: []
|
||||
completed_date: '2025-12-28'
|
||||
labels: [feature, navigation]
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
|
@ -14,3 +15,27 @@ priority: high
|
|||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add routing between participants and waypoints using c3nav for indoor routes and OSRM/GraphHopper for outdoor routes
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Components Added
|
||||
- `/api/routing/route.ts` - API endpoint for route calculation
|
||||
- `RouteOverlay.tsx` - Map overlay component for visualizing routes
|
||||
- `NavigationPanel.tsx` - UI panel for selecting navigation targets
|
||||
|
||||
### Features Implemented
|
||||
- **Outdoor routing**: Uses OSRM public API for walking/driving routes
|
||||
- **Indoor routing**: Integrates c3nav API for CCC venue navigation
|
||||
- **Mixed routes**: Handles transitions between indoor/outdoor
|
||||
- **Route visualization**: GeoJSON line layer on MapLibre GL map
|
||||
- **Navigation UI**: Click on participant/waypoint to get directions
|
||||
- **State management**: Route state integrated into Zustand store
|
||||
|
||||
### Technical Details
|
||||
- Route segments are typed as `outdoor`, `indoor`, or `transition`
|
||||
- Distance/time estimates calculated from OSRM and c3nav responses
|
||||
- Route line rendered with outline for visibility
|
||||
- Map auto-fits to route bounds when calculated
|
||||
|
||||
### Commit
|
||||
`a6c124c` - feat: Add navigation routes feature with indoor/outdoor routing
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
|
|
@ -17,8 +17,32 @@ export default function HomePage() {
|
|||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [joinSlug, setJoinSlug] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [emoji, setEmoji] = useState(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]);
|
||||
const [emoji, setEmoji] = useState('');
|
||||
const [roomName, setRoomName] = useState('');
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
// Load saved user info from localStorage on mount
|
||||
useEffect(() => {
|
||||
let loadedEmoji = '';
|
||||
try {
|
||||
const stored = localStorage.getItem('rmaps_user');
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored);
|
||||
if (user.name) setName(user.name);
|
||||
if (user.emoji) {
|
||||
setEmoji(user.emoji);
|
||||
loadedEmoji = user.emoji;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
// Set random emoji if none loaded
|
||||
if (!loadedEmoji) {
|
||||
setEmoji(EMOJI_OPTIONS[Math.floor(Math.random() * EMOJI_OPTIONS.length)]);
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateRoom = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@ export default function RoomPage() {
|
|||
currentUserId={currentParticipantId || undefined}
|
||||
currentLocation={currentLocation}
|
||||
eventId="38c3"
|
||||
isSharing={isSharing}
|
||||
onToggleSharing={handleToggleSharing}
|
||||
onParticipantClick={(p) => {
|
||||
setSelectedParticipant(p);
|
||||
setShowParticipants(true);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ interface DualMapViewProps {
|
|||
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)
|
||||
|
|
@ -58,6 +62,8 @@ export default function DualMapView({
|
|||
onParticipantClick,
|
||||
onWaypointClick,
|
||||
onIndoorPositionSet,
|
||||
isSharing = false,
|
||||
onToggleSharing,
|
||||
}: DualMapViewProps) {
|
||||
const [mode, setMode] = useState<MapMode>(initialMode);
|
||||
const [activeView, setActiveView] = useState<'outdoor' | 'indoor'>('outdoor');
|
||||
|
|
@ -152,6 +158,42 @@ export default function DualMapView({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Location sharing button - floating inside map for mobile visibility */}
|
||||
{onToggleSharing && (
|
||||
<button
|
||||
onClick={onToggleSharing}
|
||||
className={`absolute top-4 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
|
||||
|
|
|
|||
|
|
@ -81,6 +81,19 @@ export type SyncMessage =
|
|||
type SyncCallback = (state: RoomState) => void;
|
||||
type ConnectionCallback = (connected: boolean) => void;
|
||||
|
||||
// Validate that coordinates are reasonable (not 0,0 or out of bounds)
|
||||
function isValidLocation(location: LocationState | undefined): boolean {
|
||||
if (!location) return false;
|
||||
const { latitude, longitude } = location;
|
||||
// Basic bounds check
|
||||
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false;
|
||||
// Reject (0,0) which is a common invalid default
|
||||
if (latitude === 0 && longitude === 0) return false;
|
||||
// Reject NaN
|
||||
if (isNaN(latitude) || isNaN(longitude)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export class RoomSync {
|
||||
private slug: string;
|
||||
private state: RoomState;
|
||||
|
|
@ -132,10 +145,11 @@ export class RoomSync {
|
|||
}
|
||||
|
||||
private cleanupStaleParticipants(state: RoomState): RoomState {
|
||||
const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
||||
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (was 1 hour)
|
||||
const now = Date.now();
|
||||
const cleanedParticipants: Record<string, ParticipantState> = {};
|
||||
let removedCount = 0;
|
||||
let invalidLocationCount = 0;
|
||||
|
||||
for (const [id, participant] of Object.entries(state.participants)) {
|
||||
const lastSeen = new Date(participant.lastSeen).getTime();
|
||||
|
|
@ -144,6 +158,12 @@ export class RoomSync {
|
|||
|
||||
// Keep current user (they'll be updated) and non-stale participants
|
||||
if (isCurrentUser || !isStale) {
|
||||
// Also clean up invalid locations
|
||||
if (participant.location && !isValidLocation(participant.location)) {
|
||||
console.log(`Removing invalid location for participant ${id}:`, participant.location);
|
||||
delete participant.location;
|
||||
invalidLocationCount++;
|
||||
}
|
||||
cleanedParticipants[id] = participant;
|
||||
} else {
|
||||
removedCount++;
|
||||
|
|
@ -153,6 +173,9 @@ export class RoomSync {
|
|||
if (removedCount > 0) {
|
||||
console.log(`Cleaned up ${removedCount} stale participant(s) from room state`);
|
||||
}
|
||||
if (invalidLocationCount > 0) {
|
||||
console.log(`Cleaned up ${invalidLocationCount} invalid location(s) from room state`);
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
|
|
@ -257,8 +280,17 @@ export class RoomSync {
|
|||
|
||||
case 'location':
|
||||
if (this.state.participants[message.participantId]) {
|
||||
// Validate location before accepting
|
||||
if (message.location && isValidLocation(message.location)) {
|
||||
this.state.participants[message.participantId].location = message.location;
|
||||
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
|
||||
} else if (message.location === null) {
|
||||
// Allow explicit null to clear location
|
||||
delete this.state.participants[message.participantId].location;
|
||||
this.state.participants[message.participantId].lastSeen = new Date().toISOString();
|
||||
} else {
|
||||
console.warn('Ignoring invalid location from participant:', message.participantId, message.location);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -310,6 +342,11 @@ export class RoomSync {
|
|||
|
||||
updateLocation(location: LocationState): void {
|
||||
console.log('RoomSync.updateLocation called:', location.latitude, location.longitude);
|
||||
// Validate location before setting
|
||||
if (!isValidLocation(location)) {
|
||||
console.warn('Rejecting invalid location:', location);
|
||||
return;
|
||||
}
|
||||
if (this.state.participants[this.participantId]) {
|
||||
this.state.participants[this.participantId].location = location;
|
||||
this.state.participants[this.participantId].lastSeen = new Date().toISOString();
|
||||
|
|
@ -346,11 +383,13 @@ export class RoomSync {
|
|||
console.log('RoomSync.updateIndoorPosition called:', indoor.level, indoor.x, indoor.y);
|
||||
if (this.state.participants[this.participantId]) {
|
||||
// Update or create location with indoor data
|
||||
// Use CCH venue center as default when no outdoor location exists
|
||||
const existingLocation = this.state.participants[this.participantId].location;
|
||||
const CCH_CENTER = { latitude: 53.5555, longitude: 9.9898 }; // Hamburg CCH venue center
|
||||
const location: LocationState = existingLocation || {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
accuracy: 0,
|
||||
latitude: CCH_CENTER.latitude,
|
||||
longitude: CCH_CENTER.longitude,
|
||||
accuracy: 50, // Indoor accuracy
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'manual',
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue