/** * MapShapeUtil - Mapus-inspired collaborative map shape for tldraw * * Inspired by https://github.com/alyssaxuu/mapus * * Features: * - Real-time collaboration with user cursors/presence * - Find Nearby places (restaurants, hotels, etc.) * - Drawing tools (markers, lines, areas) * - Annotations list with visibility toggle * - Color picker for annotations * - Search and routing (Nominatim + OSRM) * - GPS location sharing * - "Observe" mode to follow other users */ import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLResizeInfo, resizeBox, T } from 'tldraw'; import { useRef, useEffect, useState, useCallback } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { StandardizedToolWrapper } from '../components/StandardizedToolWrapper'; import { usePinnedToView } from '../hooks/usePinnedToView'; import { useMaximize } from '../hooks/useMaximize'; // ============================================================================= // Types // ============================================================================= export interface Coordinate { lat: number; lng: number; } export interface MapViewport { center: Coordinate; zoom: number; bearing: number; pitch: number; } export interface Annotation { id: string; type: 'marker' | 'line' | 'area'; name: string; color: string; visible: boolean; coordinates: Coordinate[]; // Single for marker, multiple for line/area createdBy?: string; createdAt: number; } export interface RouteInfo { distance: number; duration: number; geometry: GeoJSON.LineString; } export interface CollaboratorPresence { id: string; name: string; color: string; cursor?: Coordinate; location?: Coordinate; isObserving?: string; // ID of user being observed lastSeen: number; } export type IMapShape = TLBaseShape< 'Map', { w: number; h: number; viewport: MapViewport; styleKey: string; title: string; description: string; annotations: Annotation[]; route: RouteInfo | null; waypoints: Coordinate[]; collaborators: CollaboratorPresence[]; showSidebar: boolean; pinnedToView: boolean; tags: string[]; isMinimized: boolean; // Legacy compatibility properties interactive: boolean; showGPS: boolean; showSearch: boolean; showDirections: boolean; sharingLocation: boolean; gpsUsers: CollaboratorPresence[]; } >; // ============================================================================= // Constants // ============================================================================= const DEFAULT_VIEWPORT: MapViewport = { center: { lat: 40.7128, lng: -74.006 }, zoom: 12, bearing: 0, pitch: 0, }; const OSRM_BASE_URL = 'https://routing.jeffemmett.com'; // ============================================================================= // Geo Calculation Helpers // ============================================================================= // Haversine distance calculation (returns meters) function calculateDistance(coords: Coordinate[]): number { if (coords.length < 2) return 0; const R = 6371000; // Earth's radius in meters let total = 0; for (let i = 0; i < coords.length - 1; i++) { const lat1 = coords[i].lat * Math.PI / 180; const lat2 = coords[i + 1].lat * Math.PI / 180; const dLat = (coords[i + 1].lat - coords[i].lat) * Math.PI / 180; const dLng = (coords[i + 1].lng - coords[i].lng) * Math.PI / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); total += R * c; } return total; } // Shoelace formula for polygon area (returns square meters) function calculateArea(coords: Coordinate[]): number { if (coords.length < 3) return 0; // Convert to projected coordinates (approximate for small areas) const centerLat = coords.reduce((sum, c) => sum + c.lat, 0) / coords.length; const metersPerDegreeLat = 111320; const metersPerDegreeLng = 111320 * Math.cos(centerLat * Math.PI / 180); const projected = coords.map(c => ({ x: c.lng * metersPerDegreeLng, y: c.lat * metersPerDegreeLat, })); // Shoelace formula let area = 0; for (let i = 0; i < projected.length; i++) { const j = (i + 1) % projected.length; area += projected[i].x * projected[j].y; area -= projected[j].x * projected[i].y; } return Math.abs(area / 2); } // Format distance for display function formatDistance(meters: number): string { if (meters < 1000) { return `${Math.round(meters)} m`; } return `${(meters / 1000).toFixed(2)} km`; } // Format area for display function formatArea(sqMeters: number): string { if (sqMeters < 10000) { return `${Math.round(sqMeters)} mΒ²`; } else if (sqMeters < 1000000) { return `${(sqMeters / 10000).toFixed(2)} ha`; } return `${(sqMeters / 1000000).toFixed(2)} kmΒ²`; } // Mapus color palette const COLORS = [ '#E15F59', // Red '#F29F51', // Orange '#F9D458', // Yellow '#5EBE86', // Green '#4890E8', // Blue '#634FF1', // Purple '#A564D2', // Violet '#222222', // Black ]; // Find Nearby categories (inspired by Mapus) const NEARBY_CATEGORIES = [ { key: 'restaurant', label: 'Food', icon: '🍽️', color: '#4890E8', types: 'restaurant,cafe,fast_food,food_court' }, { key: 'bar', label: 'Drinks', icon: '🍺', color: '#F9D458', types: 'bar,pub,cafe,wine_bar' }, { key: 'supermarket', label: 'Groceries', icon: 'πŸ›’', color: '#5EBE86', types: 'supermarket,convenience,grocery' }, { key: 'hotel', label: 'Hotels', icon: '🏨', color: '#AC6C48', types: 'hotel,hostel,motel,guest_house' }, { key: 'hospital', label: 'Health', icon: 'πŸ₯', color: '#E15F59', types: 'hospital,pharmacy,clinic,doctors' }, { key: 'bank', label: 'Services', icon: '🏦', color: '#634FF1', types: 'bank,atm,post_office' }, { key: 'shopping', label: 'Shopping', icon: 'πŸ›οΈ', color: '#A564D2', types: 'mall,department_store,clothes' }, { key: 'transport', label: 'Transport', icon: 'πŸš‰', color: '#718390', types: 'bus_station,train_station,subway' }, ]; // Map styles const MAP_STYLES = { voyager: { name: 'Voyager', url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', icon: 'πŸ—ΊοΈ', }, positron: { name: 'Light', url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', icon: 'β˜€οΈ', }, darkMatter: { name: 'Dark', url: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', icon: 'πŸŒ™', }, satellite: { name: 'Satellite', url: { version: 8, sources: { 'esri-satellite': { type: 'raster', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, attribution: '© Esri', maxzoom: 19, }, }, layers: [{ id: 'satellite-layer', type: 'raster', source: 'esri-satellite' }], } as maplibregl.StyleSpecification, icon: 'πŸ›°οΈ', }, } as const; type StyleKey = keyof typeof MAP_STYLES; // ============================================================================= // Shape Definition // ============================================================================= export class MapShape extends BaseBoxShapeUtil { static override type = 'Map' as const; // Map theme color: Blue (consistent with mapping/navigation) static readonly PRIMARY_COLOR = '#4890E8'; static override props = { w: T.number, h: T.number, viewport: T.any, styleKey: T.string, title: T.string, description: T.string, annotations: T.any, route: T.any, waypoints: T.any, collaborators: T.any, showSidebar: T.boolean, pinnedToView: T.boolean, tags: T.any, isMinimized: T.boolean, // Legacy compatibility properties interactive: T.boolean, showGPS: T.boolean, showSearch: T.boolean, showDirections: T.boolean, sharingLocation: T.boolean, gpsUsers: T.any, }; getDefaultProps(): IMapShape['props'] { return { w: 800, h: 550, viewport: DEFAULT_VIEWPORT, styleKey: 'voyager', title: 'Collaborative Map', description: 'Click to explore together', annotations: [], route: null, waypoints: [], collaborators: [], showSidebar: true, pinnedToView: false, tags: ['map'], isMinimized: false, // Legacy compatibility defaults interactive: true, showGPS: false, showSearch: false, showDirections: false, sharingLocation: false, gpsUsers: [], }; } override canResize() { return true; } override canEdit() { return false; } override onResize(shape: IMapShape, info: TLResizeInfo) { return resizeBox(shape, info); } indicator(shape: IMapShape) { const height = shape.props.isMinimized ? 40 : shape.props.h + 40; return ; } component(shape: IMapShape) { const isSelected = this.editor.getSelectedShapeIds().includes(shape.id); return ; } } // ============================================================================= // Styles // ============================================================================= const styles = { sidebar: { width: 280, background: '#fff', borderRight: '1px solid #E8E8E8', height: '100%', overflowY: 'auto' as const, fontSize: 13, zIndex: 10, position: 'relative' as const, }, section: { padding: '14px 16px', borderBottom: '1px solid #E8E8E8', }, sectionTitle: { fontWeight: 600, fontSize: 13, color: '#222', marginBottom: 10, }, button: { border: 'none', borderRadius: 6, background: '#fff', cursor: 'pointer', transition: 'background 0.15s', pointerEvents: 'auto' as const, }, toolbar: { position: 'absolute' as const, bottom: 20, left: '50%', transform: 'translateX(-50%)', background: '#fff', borderRadius: 12, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', display: 'flex', padding: 6, gap: 4, zIndex: 10000, pointerEvents: 'auto' as const, }, toolButton: { width: 42, height: 42, border: 'none', borderRadius: 8, background: 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, transition: 'background 0.15s', pointerEvents: 'auto' as const, }, activeToolButton: { background: '#222', color: '#fff', }, mapButton: { pointerEvents: 'auto' as const, zIndex: 10000, }, }; // ============================================================================= // Map Component // ============================================================================= function MapComponent({ shape, editor, isSelected }: { shape: IMapShape; editor: MapShape['editor']; isSelected: boolean }) { const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef>(new Map()); const isMountedRef = useRef(true); // Track if component is still mounted const [isLoaded, setIsLoaded] = useState(false); const [activeTool, setActiveTool] = useState<'cursor' | 'marker' | 'line' | 'area' | 'eraser'>('cursor'); const activeToolRef = useRef(activeTool); // Ref to track current tool in event handlers const [selectedColor, setSelectedColor] = useState(COLORS[4]); // Drawing state for lines and areas const [drawingPoints, setDrawingPoints] = useState([]); const drawingPointsRef = useRef([]); // Keep refs in sync with state useEffect(() => { activeToolRef.current = activeTool; // Clear drawing points when switching tools if (activeTool !== 'line' && activeTool !== 'area') { setDrawingPoints([]); drawingPointsRef.current = []; } }, [activeTool]); useEffect(() => { drawingPointsRef.current = drawingPoints; }, [drawingPoints]); const [showColorPicker, setShowColorPicker] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [_nearbyPlaces, setNearbyPlaces] = useState([]); const [isSearching, setIsSearching] = useState(false); const [isFetchingNearby, setIsFetchingNearby] = useState(false); const [observingUser, setObservingUser] = useState(null); // GPS Location Sharing State const [isSharingLocation, setIsSharingLocation] = useState(false); const [myLocation, setMyLocation] = useState(null); const watchIdRef = useRef(null); const collaboratorMarkersRef = useRef>(new Map()); const styleKey = (shape.props.styleKey || 'voyager') as StyleKey; const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager; // Use the pinning hook to keep the shape fixed to viewport when pinned usePinnedToView(editor, shape.id, shape.props.pinnedToView); // Use the maximize hook for fullscreen functionality const { isMaximized, toggleMaximize } = useMaximize({ editor: editor, shapeId: shape.id, currentW: shape.props.w, currentH: shape.props.h, shapeType: 'Map', }); // Track mounted state for cleanup useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; // Cleanup GPS watch on unmount if (watchIdRef.current !== null) { navigator.geolocation.clearWatch(watchIdRef.current); watchIdRef.current = null; } // Cleanup collaborator markers collaboratorMarkersRef.current.forEach((marker) => { try { marker.remove(); } catch (err) { // Marker may already be removed } }); collaboratorMarkersRef.current.clear(); }; }, []); // ========================================================================== // Map Initialization // ========================================================================== useEffect(() => { if (!containerRef.current) return; const map = new maplibregl.Map({ container: containerRef.current, style: typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url, center: [shape.props.viewport.center.lng, shape.props.viewport.center.lat], zoom: shape.props.viewport.zoom, bearing: shape.props.viewport.bearing, pitch: shape.props.viewport.pitch, attributionControl: false, }); mapRef.current = map; const handleLoad = () => { if (isMountedRef.current) { setIsLoaded(true); } }; // Save viewport changes with null checks const handleMoveEnd = () => { if (!isMountedRef.current || !mapRef.current) return; try { const center = mapRef.current.getCenter(); editor.updateShape({ id: shape.id, type: 'Map', props: { viewport: { center: { lat: center.lat, lng: center.lng }, zoom: mapRef.current.getZoom(), bearing: mapRef.current.getBearing(), pitch: mapRef.current.getPitch(), }, }, }); } catch (err) { // Map may have been destroyed, ignore } }; // Handle map clicks based on active tool const handleClick = (e: maplibregl.MapMouseEvent) => { if (!isMountedRef.current) return; const coord = { lat: e.lngLat.lat, lng: e.lngLat.lng }; const currentTool = activeToolRef.current; const currentDrawingPoints = drawingPointsRef.current; console.log('Map click with tool:', currentTool, 'at', coord, 'points:', currentDrawingPoints.length); if (currentTool === 'marker') { addAnnotation('marker', [coord]); } else if (currentTool === 'line') { // Add point to line drawing const newPoints = [...currentDrawingPoints, coord]; setDrawingPoints(newPoints); drawingPointsRef.current = newPoints; } else if (currentTool === 'area') { // Add point to area drawing const newPoints = [...currentDrawingPoints, coord]; setDrawingPoints(newPoints); drawingPointsRef.current = newPoints; } else if (currentTool === 'eraser') { // Find and remove annotation at click location // Check if clicked near any annotation const clickThreshold = 0.0005; // ~50m at equator const annotationToRemove = shape.props.annotations.find((ann: Annotation) => { if (ann.type === 'marker') { const annCoord = ann.coordinates[0]; return Math.abs(annCoord.lat - coord.lat) < clickThreshold && Math.abs(annCoord.lng - coord.lng) < clickThreshold; } else { // For lines/areas, check if click is near any point return ann.coordinates.some((c: Coordinate) => Math.abs(c.lat - coord.lat) < clickThreshold && Math.abs(c.lng - coord.lng) < clickThreshold ); } }); if (annotationToRemove) { removeAnnotation(annotationToRemove.id); } } }; // Handle double-click to finish line/area drawing const handleDblClick = (_e: maplibregl.MapMouseEvent) => { if (!isMountedRef.current) return; const currentTool = activeToolRef.current; const currentDrawingPoints = drawingPointsRef.current; console.log('Map double-click with tool:', currentTool, 'points:', currentDrawingPoints.length); if (currentTool === 'line' && currentDrawingPoints.length >= 2) { addAnnotation('line', currentDrawingPoints); setDrawingPoints([]); drawingPointsRef.current = []; } else if (currentTool === 'area' && currentDrawingPoints.length >= 3) { addAnnotation('area', currentDrawingPoints); setDrawingPoints([]); drawingPointsRef.current = []; } }; map.on('load', handleLoad); map.on('moveend', handleMoveEnd); map.on('click', handleClick); map.on('dblclick', handleDblClick); return () => { // Remove event listeners before destroying map map.off('load', handleLoad); map.off('moveend', handleMoveEnd); map.off('click', handleClick); map.off('dblclick', handleDblClick); // Clear all markers markersRef.current.forEach((marker) => { try { marker.remove(); } catch (err) { // Marker may already be removed } }); markersRef.current.clear(); // Destroy the map try { map.remove(); } catch (err) { // Map may already be destroyed } mapRef.current = null; setIsLoaded(false); }; }, [containerRef.current]); // Style changes useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; try { mapRef.current.setStyle(typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url); } catch (err) { // Map may have been destroyed } }, [styleKey, isLoaded]); // Resize useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; const resizeTimeout = setTimeout(() => { if (mapRef.current && isMountedRef.current) { try { mapRef.current.resize(); } catch (err) { // Map may have been destroyed } } }, 0); return () => clearTimeout(resizeTimeout); }, [shape.props.w, shape.props.h, isLoaded, shape.props.showSidebar]); // ========================================================================== // Collaborator GPS Markers (renders ALL users sharing location on the map) // ========================================================================== useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; const map = mapRef.current; const myUserId = editor.user.getId(); // Get ALL collaborators with locations (including self) const allCollaborators = shape.props.collaborators || []; const collaboratorsWithLocation = allCollaborators.filter( (c: CollaboratorPresence) => c.location ); const currentCollaboratorIds = new Set(collaboratorsWithLocation.map((c: CollaboratorPresence) => c.id)); // Debug logging if (collaboratorsWithLocation.length > 0) { console.log('πŸ“ GPS Markers Update:', { total: allCollaborators.length, withLocation: collaboratorsWithLocation.length, users: collaboratorsWithLocation.map(c => ({ id: c.id.slice(0, 8), name: c.name, loc: c.location })), }); } // Remove old collaborator markers that are no longer sharing collaboratorMarkersRef.current.forEach((marker, id) => { if (!currentCollaboratorIds.has(id)) { try { marker.remove(); } catch (err) { // Marker may already be removed } collaboratorMarkersRef.current.delete(id); } }); // Add/update markers for ALL collaborators sharing location collaboratorsWithLocation.forEach((collab: CollaboratorPresence) => { if (!isMountedRef.current || !mapRef.current || !collab.location) return; const isMe = collab.id === myUserId; let marker = collaboratorMarkersRef.current.get(collab.id); const displayName = isMe ? 'You' : collab.name; const markerColor = isMe ? '#3b82f6' : collab.color; if (!marker) { // Create pin-style marker with name bubble and pointer const container = document.createElement('div'); container.className = `gps-location-pin ${isMe ? 'is-me' : ''}`; container.style.cssText = ` display: flex; flex-direction: column; align-items: center; cursor: pointer; filter: drop-shadow(0 4px 8px rgba(0,0,0,0.3)); z-index: ${isMe ? 1000 : 100}; `; container.title = isMe ? 'You are here' : `${collab.name} is here`; // Name bubble (pill shape with name) const bubble = document.createElement('div'); bubble.style.cssText = ` background: ${markerColor}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; white-space: nowrap; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.2); display: flex; align-items: center; gap: 6px; animation: gps-pin-pulse 2s ease-in-out infinite; `; // Add pulsing dot indicator const dot = document.createElement('div'); dot.style.cssText = ` width: 8px; height: 8px; background: ${isMe ? '#22c55e' : 'white'}; border-radius: 50%; animation: gps-dot-pulse 1.5s ease-in-out infinite; `; const nameSpan = document.createElement('span'); nameSpan.textContent = displayName; bubble.appendChild(dot); bubble.appendChild(nameSpan); // Pointer/arrow pointing down to exact location const pointer = document.createElement('div'); pointer.style.cssText = ` width: 0; height: 0; border-left: 10px solid transparent; border-right: 10px solid transparent; border-top: 14px solid ${markerColor}; margin-top: -2px; filter: drop-shadow(0 2px 2px rgba(0,0,0,0.2)); `; // Small dot at the exact location point const locationDot = document.createElement('div'); locationDot.style.cssText = ` width: 12px; height: 12px; background: ${markerColor}; border: 2px solid white; border-radius: 50%; margin-top: -3px; box-shadow: 0 0 0 4px ${markerColor}40, 0 2px 4px rgba(0,0,0,0.3); animation: gps-location-ring 2s ease-out infinite; `; container.appendChild(bubble); container.appendChild(pointer); container.appendChild(locationDot); // Anchor at bottom so the pin points to exact location marker = new maplibregl.Marker({ element: container, anchor: 'bottom' }) .setLngLat([collab.location.lng, collab.location.lat]) .addTo(map); collaboratorMarkersRef.current.set(collab.id, marker); // If this is me and I just started sharing, fly to my location if (isMe) { map.flyTo({ center: [collab.location.lng, collab.location.lat], zoom: Math.max(map.getZoom(), 14), duration: 1500, }); } } else { // Update existing marker position marker.setLngLat([collab.location.lng, collab.location.lat]); } }); // Inject pulse animation CSS if not already present if (!document.getElementById('collaborator-gps-styles')) { const style = document.createElement('style'); style.id = 'collaborator-gps-styles'; style.textContent = ` @keyframes gps-pin-pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.03); } } @keyframes gps-dot-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(0.8); } } @keyframes gps-location-ring { 0% { box-shadow: 0 0 0 0 currentColor, 0 2px 4px rgba(0,0,0,0.3); } 70% { box-shadow: 0 0 0 12px transparent, 0 2px 4px rgba(0,0,0,0.3); } 100% { box-shadow: 0 0 0 0 transparent, 0 2px 4px rgba(0,0,0,0.3); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .gps-location-pin { transition: transform 0.2s ease; } .gps-location-pin:hover { transform: scale(1.1); z-index: 2000 !important; } .gps-location-pin.is-me { z-index: 1000; } `; document.head.appendChild(style); } }, [shape.props.collaborators, isLoaded, editor]); // ========================================================================== // Annotations // ========================================================================== useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; const map = mapRef.current; const currentIds = new Set(shape.props.annotations.map((a: Annotation) => a.id)); // Remove old markers markersRef.current.forEach((marker, id) => { if (!currentIds.has(id)) { try { marker.remove(); } catch (err) { // Marker may already be removed } markersRef.current.delete(id); } }); // Add/update markers shape.props.annotations.forEach((ann: Annotation) => { if (!isMountedRef.current || !mapRef.current) return; if (ann.type !== 'marker' || !ann.visible) return; let marker = markersRef.current.get(ann.id); const coord = ann.coordinates[0]; if (!marker && coord) { try { const el = document.createElement('div'); el.className = 'map-annotation-marker'; el.style.cssText = ` width: 32px; height: 32px; background: ${ann.color}; border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 8px rgba(0,0,0,0.3); cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; `; el.textContent = 'πŸ“'; el.title = ann.name; const popup = new maplibregl.Popup({ offset: 20 }) .setHTML(`
${ann.name}
`); marker = new maplibregl.Marker({ element: el, draggable: true }) .setLngLat([coord.lng, coord.lat]) .setPopup(popup) .addTo(map); marker.on('dragend', () => { if (!isMountedRef.current || !marker) return; try { const lngLat = marker.getLngLat(); updateAnnotationPosition(ann.id, [{ lat: lngLat.lat, lng: lngLat.lng }]); } catch (err) { // Marker may have been removed } }); markersRef.current.set(ann.id, marker); } catch (err) { // Map may have been destroyed } } else if (marker && coord) { try { marker.setLngLat([coord.lng, coord.lat]); } catch (err) { // Marker may have been removed } } }); // Hide markers for invisible annotations markersRef.current.forEach((marker, id) => { const ann = shape.props.annotations.find((a: Annotation) => a.id === id); if (ann && !ann.visible) { try { marker.remove(); } catch (err) { // Marker may already be removed } markersRef.current.delete(id); } }); // Render lines and areas shape.props.annotations.forEach((ann: Annotation) => { if (!isMountedRef.current || !mapRef.current) return; if (!ann.visible) { // Remove layer/source if hidden try { if (map.getLayer(`ann-layer-${ann.id}`)) map.removeLayer(`ann-layer-${ann.id}`); if (ann.type === 'area' && map.getLayer(`ann-fill-${ann.id}`)) map.removeLayer(`ann-fill-${ann.id}`); if (map.getSource(`ann-source-${ann.id}`)) map.removeSource(`ann-source-${ann.id}`); } catch (err) { /* ignore */ } return; } if (ann.type === 'line' && ann.coordinates.length >= 2) { const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]); const sourceId = `ann-source-${ann.id}`; const layerId = `ann-layer-${ann.id}`; try { if (map.getSource(sourceId)) { (map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords }, }); } else { map.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords }, }, }); map.addLayer({ id: layerId, type: 'line', source: sourceId, paint: { 'line-color': ann.color, 'line-width': 4, 'line-opacity': 0.8, }, }); } } catch (err) { console.warn('Error rendering line:', err); } } else if (ann.type === 'area' && ann.coordinates.length >= 3) { const coords = ann.coordinates.map((c: Coordinate) => [c.lng, c.lat]); // Close the polygon const closedCoords = [...coords, coords[0]]; const sourceId = `ann-source-${ann.id}`; const fillLayerId = `ann-fill-${ann.id}`; const lineLayerId = `ann-layer-${ann.id}`; try { if (map.getSource(sourceId)) { (map.getSource(sourceId) as maplibregl.GeoJSONSource).setData({ type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [closedCoords] }, }); } else { map.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'Polygon', coordinates: [closedCoords] }, }, }); map.addLayer({ id: fillLayerId, type: 'fill', source: sourceId, paint: { 'fill-color': ann.color, 'fill-opacity': 0.3, }, }); map.addLayer({ id: lineLayerId, type: 'line', source: sourceId, paint: { 'line-color': ann.color, 'line-width': 3, 'line-opacity': 0.8, }, }); } } catch (err) { console.warn('Error rendering area:', err); } } }); // Clean up removed annotation layers currentIds.forEach((id) => { const ann = shape.props.annotations.find((a: Annotation) => a.id === id); if (!ann) { try { if (map.getLayer(`ann-layer-${id}`)) map.removeLayer(`ann-layer-${id}`); if (map.getLayer(`ann-fill-${id}`)) map.removeLayer(`ann-fill-${id}`); if (map.getSource(`ann-source-${id}`)) map.removeSource(`ann-source-${id}`); } catch (err) { /* ignore */ } } }); }, [shape.props.annotations, isLoaded]); // ========================================================================== // Drawing Preview (for lines/areas in progress) // ========================================================================== useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; const map = mapRef.current; const sourceId = 'drawing-preview'; const lineLayerId = 'drawing-preview-line'; const fillLayerId = 'drawing-preview-fill'; const pointsLayerId = 'drawing-preview-points'; try { // Remove existing preview layers first if (map.getLayer(pointsLayerId)) map.removeLayer(pointsLayerId); if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); if (map.getLayer(lineLayerId)) map.removeLayer(lineLayerId); if (map.getSource(sourceId)) map.removeSource(sourceId); if (drawingPoints.length === 0) return; const coords = drawingPoints.map((c) => [c.lng, c.lat]); if (activeTool === 'line' && coords.length >= 1) { map.addSource(sourceId, { type: 'geojson', data: { type: 'FeatureCollection', features: [ ...(coords.length >= 2 ? [{ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, }] : []), ...coords.map((coord) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, })), ], }, }); if (coords.length >= 2) { map.addLayer({ id: lineLayerId, type: 'line', source: sourceId, paint: { 'line-color': selectedColor, 'line-width': 4, 'line-opacity': 0.6, 'line-dasharray': [2, 2], }, }); } map.addLayer({ id: pointsLayerId, type: 'circle', source: sourceId, filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': selectedColor, 'circle-stroke-width': 2, 'circle-stroke-color': '#fff', }, }); } else if (activeTool === 'area' && coords.length >= 1) { const closedCoords = coords.length >= 3 ? [...coords, coords[0]] : coords; map.addSource(sourceId, { type: 'geojson', data: { type: 'FeatureCollection', features: [ ...(coords.length >= 3 ? [{ type: 'Feature' as const, properties: {}, geometry: { type: 'Polygon' as const, coordinates: [closedCoords] }, }] : []), ...(coords.length >= 2 ? [{ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: coords }, }] : []), ...coords.map((coord) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'Point' as const, coordinates: coord }, })), ], }, }); if (coords.length >= 3) { map.addLayer({ id: fillLayerId, type: 'fill', source: sourceId, filter: ['==', '$type', 'Polygon'], paint: { 'fill-color': selectedColor, 'fill-opacity': 0.2, }, }); } if (coords.length >= 2) { map.addLayer({ id: lineLayerId, type: 'line', source: sourceId, filter: ['==', '$type', 'LineString'], paint: { 'line-color': selectedColor, 'line-width': 3, 'line-opacity': 0.6, 'line-dasharray': [2, 2], }, }); } map.addLayer({ id: pointsLayerId, type: 'circle', source: sourceId, filter: ['==', '$type', 'Point'], paint: { 'circle-radius': 6, 'circle-color': selectedColor, 'circle-stroke-width': 2, 'circle-stroke-color': '#fff', }, }); } } catch (err) { console.warn('Error rendering drawing preview:', err); } }, [drawingPoints, activeTool, selectedColor, isLoaded]); // ========================================================================== // Collaborator presence (cursors/locations) // ========================================================================== useEffect(() => { if (!mapRef.current || !isLoaded || !isMountedRef.current) return; // TODO: Render collaborator cursors on map // This would be integrated with tldraw's presence system }, [shape.props.collaborators, isLoaded]); // ========================================================================== // Actions // ========================================================================== const addAnnotation = useCallback((type: Annotation['type'], coordinates: Coordinate[], options?: { name?: string; color?: string }) => { const newAnnotation: Annotation = { id: `ann-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, type, name: options?.name || `${type.charAt(0).toUpperCase() + type.slice(1)} ${shape.props.annotations.length + 1}`, color: options?.color || selectedColor, visible: true, coordinates, createdAt: Date.now(), }; editor.updateShape({ id: shape.id, type: 'Map', props: { annotations: [...shape.props.annotations, newAnnotation] }, }); }, [shape.props.annotations, selectedColor, shape.id, editor]); const updateAnnotationPosition = useCallback((annotationId: string, coordinates: Coordinate[]) => { const updated = shape.props.annotations.map((ann: Annotation) => ann.id === annotationId ? { ...ann, coordinates } : ann ); editor.updateShape({ id: shape.id, type: 'Map', props: { annotations: updated }, }); }, [shape.props.annotations, shape.id, editor]); const toggleAnnotationVisibility = useCallback((annotationId: string) => { const updated = shape.props.annotations.map((ann: Annotation) => ann.id === annotationId ? { ...ann, visible: !ann.visible } : ann ); editor.updateShape({ id: shape.id, type: 'Map', props: { annotations: updated }, }); }, [shape.props.annotations, shape.id, editor]); const removeAnnotation = useCallback((annotationId: string) => { const updated = shape.props.annotations.filter((ann: Annotation) => ann.id !== annotationId); editor.updateShape({ id: shape.id, type: 'Map', props: { annotations: updated }, }); }, [shape.props.annotations, shape.id, editor]); const hideAllAnnotations = useCallback(() => { const updated = shape.props.annotations.map((ann: Annotation) => ({ ...ann, visible: false })); editor.updateShape({ id: shape.id, type: 'Map', props: { annotations: updated }, }); }, [shape.props.annotations, shape.id, editor]); // ========================================================================== // Search // ========================================================================== const searchPlaces = useCallback(async () => { if (!searchQuery.trim()) return; setIsSearching(true); try { const response = await fetch( `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=6`, { headers: { 'User-Agent': 'CanvasWebsite/1.0' } } ); const data = await response.json() as { display_name: string; lat: string; lon: string }[]; setSearchResults(data.map((r) => ({ name: r.display_name, lat: parseFloat(r.lat), lng: parseFloat(r.lon), }))); } catch (err) { console.error('Search error:', err); } finally { setIsSearching(false); } }, [searchQuery]); const selectSearchResult = useCallback((result: { lat: number; lng: number; name: string }) => { if (mapRef.current && isMountedRef.current) { try { mapRef.current.flyTo({ center: [result.lng, result.lat], zoom: 15, duration: 1000 }); } catch (err) { // Map may have been destroyed } } setSearchQuery(''); setSearchResults([]); }, []); // ========================================================================== // Find Nearby // ========================================================================== const findNearby = useCallback(async (category: typeof NEARBY_CATEGORIES[0]) => { if (!mapRef.current || !isMountedRef.current) return; console.log('πŸ—ΊοΈ findNearby called for category:', category.label); setIsFetchingNearby(true); let bounds; try { bounds = mapRef.current.getBounds(); console.log('πŸ—ΊοΈ Map bounds:', bounds.toString()); } catch (err) { console.error('πŸ—ΊοΈ Error getting bounds:', err); setIsFetchingNearby(false); return; } try { const query = ` [out:json][timeout:10]; ( node["amenity"~"${category.types.replace(/,/g, '|')}"](${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}); ); out body 10; `; console.log('πŸ—ΊοΈ Overpass query:', query); const response = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: query, }); if (!isMountedRef.current) { setIsFetchingNearby(false); return; } console.log('πŸ—ΊοΈ Overpass response status:', response.status); const data = await response.json() as { elements: { id: number; lat: number; lon: number; tags?: { name?: string; amenity?: string } }[] }; console.log('πŸ—ΊοΈ Found', data.elements.length, 'places'); const places = data.elements.slice(0, 10).map((el) => ({ id: el.id, name: el.tags?.name || category.label, lat: el.lat, lng: el.lon, type: el.tags?.amenity || category.key, color: category.color, })); if (!isMountedRef.current) { setIsFetchingNearby(false); return; } setNearbyPlaces(places); // Add markers for nearby places console.log('πŸ—ΊοΈ Adding', places.length, 'markers'); places.forEach((place: any) => { if (isMountedRef.current) { addAnnotation('marker', [{ lat: place.lat, lng: place.lng }], { name: place.name, color: place.color, }); } }); setIsFetchingNearby(false); } catch (err) { console.error('πŸ—ΊοΈ Find nearby error:', err); setIsFetchingNearby(false); } }, [addAnnotation]); // ========================================================================== // Observe User // ========================================================================== const observeUser = useCallback((userId: string | null) => { setObservingUser(userId); if (userId && mapRef.current && isMountedRef.current) { const collaborator = shape.props.collaborators.find((c: CollaboratorPresence) => c.id === userId); if (collaborator?.location) { try { mapRef.current.flyTo({ center: [collaborator.location.lng, collaborator.location.lat], zoom: 15, duration: 1000, }); } catch (err) { // Map may have been destroyed } } } }, [shape.props.collaborators]); // ========================================================================== // GPS Location Sharing // ========================================================================== const startSharingLocation = useCallback(() => { if (!navigator.geolocation) { console.error('Geolocation not supported'); return; } const userId = editor.user.getId(); const userName = editor.user.getName() || 'Anonymous'; const userColor = editor.user.getColor(); setIsSharingLocation(true); console.log('πŸ“ Starting location sharing for user:', userName); watchIdRef.current = navigator.geolocation.watchPosition( (position) => { const newLocation: Coordinate = { lat: position.coords.latitude, lng: position.coords.longitude, }; setMyLocation(newLocation); // IMPORTANT: Get the CURRENT shape from editor to avoid stale closure! // This ensures we don't overwrite other users' locations const currentShape = editor.getShape(shape.id); if (!currentShape) { console.error('πŸ“ Shape not found, cannot update location'); return; } // Filter out our old entry and keep all other collaborators const existingCollaborators = (currentShape.props.collaborators || []).filter( (c: CollaboratorPresence) => c.id !== userId ); const myPresence: CollaboratorPresence = { id: userId, name: userName, color: userColor, location: newLocation, lastSeen: Date.now(), }; console.log('πŸ“ Broadcasting location:', newLocation, 'Total collaborators:', existingCollaborators.length + 1); editor.updateShape({ id: shape.id, type: 'Map', props: { collaborators: [...existingCollaborators, myPresence], }, }); }, (error) => { console.error('Geolocation error:', error.message); setIsSharingLocation(false); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 5000, } ); }, [editor, shape.id]); // Note: No shape.props.collaborators - we get current data from editor const stopSharingLocation = useCallback(() => { if (watchIdRef.current !== null) { navigator.geolocation.clearWatch(watchIdRef.current); watchIdRef.current = null; } setIsSharingLocation(false); setMyLocation(null); // Get current shape to avoid stale closure const currentShape = editor.getShape(shape.id); if (!currentShape) { console.log('πŸ“ Shape not found, skipping collaborator removal'); return; } // Remove self from collaborators const userId = editor.user.getId(); const filteredCollaborators = (currentShape.props.collaborators || []).filter( (c: CollaboratorPresence) => c.id !== userId ); console.log('πŸ“ Stopping location sharing, remaining collaborators:', filteredCollaborators.length); editor.updateShape({ id: shape.id, type: 'Map', props: { collaborators: filteredCollaborators, }, }); }, [editor, shape.id]); // Note: No shape.props.collaborators - we get current data from editor const toggleLocationSharing = useCallback(() => { if (isSharingLocation) { stopSharingLocation(); } else { startSharingLocation(); } }, [isSharingLocation, startSharingLocation, stopSharingLocation]); // ========================================================================== // Title/Description // ========================================================================== const updateTitle = useCallback((title: string) => { editor.updateShape({ id: shape.id, type: 'Map', props: { title }, }); }, [shape.id, editor]); const updateDescription = useCallback((description: string) => { editor.updateShape({ id: shape.id, type: 'Map', props: { description }, }); }, [shape.id, editor]); // ========================================================================== // Toggle Sidebar // ========================================================================== const toggleSidebar = useCallback(() => { editor.updateShape({ id: shape.id, type: 'Map', props: { showSidebar: !shape.props.showSidebar }, }); }, [shape.id, shape.props.showSidebar, editor]); // ========================================================================== // Style Change // ========================================================================== const changeStyle = useCallback((key: StyleKey) => { editor.updateShape({ id: shape.id, type: 'Map', props: { styleKey: key }, }); }, [shape.id, editor]); // ========================================================================== // Event Handlers // ========================================================================== const stopPropagation = useCallback((e: { stopPropagation: () => void }) => { e.stopPropagation(); }, []); // Handle wheel events on map container - attach native listener for proper capture useEffect(() => { const mapContainer = containerRef.current?.parentElement; if (!mapContainer) return; const handleWheel = (e: WheelEvent) => { // Stop propagation to prevent tldraw from capturing the wheel event e.stopPropagation(); // Let maplibre handle the wheel event natively for zooming // Don't prevent default - let the map's scrollZoom handle it }; // Capture wheel events before they bubble up to tldraw mapContainer.addEventListener('wheel', handleWheel, { passive: true }); return () => { mapContainer.removeEventListener('wheel', handleWheel); }; }, [isLoaded]); // Close handler for StandardizedToolWrapper const handleClose = useCallback(() => { editor.deleteShape(shape.id); }, [editor, shape.id]); // Minimize handler const handleMinimize = useCallback(() => { editor.updateShape({ id: shape.id, type: 'Map', props: { isMinimized: !shape.props.isMinimized }, }); }, [editor, shape.id, shape.props.isMinimized]); // Pin handler const handlePinToggle = useCallback(() => { editor.updateShape({ id: shape.id, type: 'Map', props: { pinnedToView: !shape.props.pinnedToView }, }); }, [editor, shape.id, shape.props.pinnedToView]); // Tags handler const handleTagsChange = useCallback((newTags: string[]) => { editor.updateShape({ id: shape.id, type: 'Map', props: { tags: newTags }, }); }, [editor, shape.id]); // ========================================================================== // Render // ========================================================================== const contentHeight = shape.props.h; return (
{/* Left Sidebar */} {shape.props.showSidebar && (
{/* Search */}
setSearchQuery(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') searchPlaces(); }} onPointerDown={stopPropagation} placeholder="Search for a place..." style={{ flex: 1, padding: '10px 12px', border: '1px solid #E8E8E8', borderRadius: 6, fontSize: 13, outline: 'none', }} />
{/* Search Results */} {searchResults.length > 0 && (
{searchResults.map((result, i) => (
selectSearchResult(result)} onPointerDown={stopPropagation} style={{ padding: '10px 8px', cursor: 'pointer', fontSize: 12, borderRadius: 4, }} > πŸ“ {result.name.slice(0, 50)}...
))}
)}
{/* Find Nearby */}
Find nearby {isFetchingNearby && ⏳}
{NEARBY_CATEGORIES.map((cat) => (
!isFetchingNearby && findNearby(cat)} onPointerDown={stopPropagation} style={{ textAlign: 'center', padding: '10px 4px', borderRadius: 6, cursor: isFetchingNearby ? 'wait' : 'pointer', fontSize: 11, color: '#626C72', }} >
{cat.icon}
{cat.label}
))}
{/* Collaborators */} {shape.props.collaborators.length > 0 && (
People ({shape.props.collaborators.length})
{shape.props.collaborators.map((collab: CollaboratorPresence) => (
observeUser(observingUser === collab.id ? null : collab.id)} onPointerDown={stopPropagation} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 6px', borderRadius: 6, cursor: 'pointer', background: observingUser === collab.id ? '#f0fdf4' : 'transparent', }} >
{collab.name[0].toUpperCase()}
{collab.name}
{observingUser === collab.id && (
πŸ‘οΈ Observing
)}
))}
)} {/* Annotations */}
Annotations
{shape.props.annotations.length === 0 ? (
No annotations yet.
Use the tools below to add some!
) : (
{shape.props.annotations.map((ann: Annotation) => (
{ann.name}
{ann.type === 'line' && ann.coordinates.length >= 2 && (
πŸ“ {formatDistance(calculateDistance(ann.coordinates))}
)} {ann.type === 'area' && ann.coordinates.length >= 3 && (
⬑ {formatArea(calculateArea(ann.coordinates))} β€’ {formatDistance(calculateDistance([...ann.coordinates, ann.coordinates[0]]))} perimeter
)}
))}
)}
{/* Attribution */}
)} {/* Map Container */}
{/* Sidebar Toggle */} {/* Style Picker */}
{/* Zoom Controls */}
{/* Measurement Display and Drawing Instructions */} {(activeTool === 'line' || activeTool === 'area') && (
{drawingPoints.length > 0 && (
{activeTool === 'line' && formatDistance(calculateDistance(drawingPoints))} {activeTool === 'area' && drawingPoints.length >= 3 && formatArea(calculateArea(drawingPoints))} {activeTool === 'area' && drawingPoints.length < 3 && `${drawingPoints.length}/3 points`}
)}
{drawingPoints.length === 0 && 'Click to start drawing'} {drawingPoints.length > 0 && activeTool === 'line' && drawingPoints.length < 2 && 'Click to add more points'} {drawingPoints.length >= 2 && activeTool === 'line' && 'Double-click to finish line'} {drawingPoints.length > 0 && activeTool === 'area' && drawingPoints.length < 3 && 'Click to add more points'} {drawingPoints.length >= 3 && activeTool === 'area' && 'Double-click to finish area'}
)} {/* Drawing Toolbar (Mapus-style) */}
{/* Cursor Tool */} {/* Marker Tool */} {/* Line Tool */} {/* Area Tool */} {/* Eraser */} {/* Divider */}
{/* Color Picker */}
{showColorPicker && (
{COLORS.map((color) => (
{ setSelectedColor(color); setShowColorPicker(false); }} style={{ width: 28, height: 28, borderRadius: '50%', background: color, cursor: 'pointer', transition: 'transform 0.15s', }} /> ))}
)}
{/* Observing Indicator */} {observingUser && (
c.id === observingUser)?.color || '#3b82f6'}`, borderRadius: 8, }}>
c.id === observingUser)?.color || '#3b82f6', color: '#fff', padding: '4px 10px', borderRadius: 4, fontSize: 12, fontWeight: 500, }}> πŸ‘οΈ Observing {shape.props.collaborators.find((c: CollaboratorPresence) => c.id === observingUser)?.name}
)}
); } export default MapShape;