/** * 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'; // 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]); // Keep ref in sync with state useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]); 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); 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; }; }, []); // ========================================================================== // 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; console.log('Map click with tool:', currentTool, 'at', coord); if (currentTool === 'marker') { addAnnotation('marker', [coord]); } // TODO: Implement line and area drawing }; map.on('load', handleLoad); map.on('moveend', handleMoveEnd); map.on('click', handleClick); return () => { // Remove event listeners before destroying map map.off('load', handleLoad); map.off('moveend', handleMoveEnd); map.off('click', handleClick); // 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]); // ========================================================================== // 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); } }); }, [shape.props.annotations, 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]); // ========================================================================== // 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}
))}
)}
{/* Attribution */}
)} {/* Map Container */}
{/* Sidebar Toggle */} {/* Style Picker */}
{/* Zoom Controls */}
{/* 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;