2190 lines
75 KiB
TypeScript
2190 lines
75 KiB
TypeScript
/**
|
||
* 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<IMapShape> {
|
||
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<IMapShape>) {
|
||
return resizeBox(shape, info);
|
||
}
|
||
|
||
indicator(shape: IMapShape) {
|
||
const height = shape.props.isMinimized ? 40 : shape.props.h + 40;
|
||
return <rect x={0} y={0} width={shape.props.w} height={height} fill="none" rx={8} />;
|
||
}
|
||
|
||
component(shape: IMapShape) {
|
||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id);
|
||
return <MapComponent shape={shape} editor={this.editor} isSelected={isSelected} />;
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// 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<HTMLDivElement>(null);
|
||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||
const markersRef = useRef<Map<string, maplibregl.Marker>>(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<Coordinate[]>([]);
|
||
const drawingPointsRef = useRef<Coordinate[]>([]);
|
||
|
||
// 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<any[]>([]);
|
||
const [_nearbyPlaces, setNearbyPlaces] = useState<any[]>([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [isFetchingNearby, setIsFetchingNearby] = useState(false);
|
||
const [observingUser, setObservingUser] = useState<string | null>(null);
|
||
|
||
// GPS Location Sharing State
|
||
const [isSharingLocation, setIsSharingLocation] = useState(false);
|
||
const [myLocation, setMyLocation] = useState<Coordinate | null>(null);
|
||
const watchIdRef = useRef<number | null>(null);
|
||
const collaboratorMarkersRef = useRef<Map<string, maplibregl.Marker>>(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<IMapShape>({
|
||
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(`<div style="padding: 8px; font-size: 13px;"><strong>${ann.name}</strong></div>`);
|
||
|
||
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<IMapShape>({
|
||
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<IMapShape>({
|
||
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<IMapShape>({
|
||
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<IMapShape>({
|
||
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<IMapShape>({
|
||
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<IMapShape>(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<IMapShape>({
|
||
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<IMapShape>(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<IMapShape>({
|
||
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<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { title },
|
||
});
|
||
}, [shape.id, editor]);
|
||
|
||
const updateDescription = useCallback((description: string) => {
|
||
editor.updateShape<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { description },
|
||
});
|
||
}, [shape.id, editor]);
|
||
|
||
// ==========================================================================
|
||
// Toggle Sidebar
|
||
// ==========================================================================
|
||
|
||
const toggleSidebar = useCallback(() => {
|
||
editor.updateShape<IMapShape>({
|
||
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<IMapShape>({
|
||
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<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { isMinimized: !shape.props.isMinimized },
|
||
});
|
||
}, [editor, shape.id, shape.props.isMinimized]);
|
||
|
||
// Pin handler
|
||
const handlePinToggle = useCallback(() => {
|
||
editor.updateShape<IMapShape>({
|
||
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<IMapShape>({
|
||
id: shape.id,
|
||
type: 'Map',
|
||
props: { tags: newTags },
|
||
});
|
||
}, [editor, shape.id]);
|
||
|
||
// ==========================================================================
|
||
// Render
|
||
// ==========================================================================
|
||
|
||
const contentHeight = shape.props.h;
|
||
|
||
return (
|
||
<HTMLContainer style={{ width: shape.props.w, height: contentHeight + 40 }}>
|
||
<style>{`
|
||
.mapus-sidebar::-webkit-scrollbar { width: 6px; }
|
||
.mapus-sidebar::-webkit-scrollbar-thumb { background: #ddd; border-radius: 3px; }
|
||
.mapus-btn:hover { background: #f7f7f7 !important; }
|
||
.mapus-btn:active { transform: scale(0.97); }
|
||
.mapus-category:hover { background: #f7f7f7 !important; }
|
||
.mapus-annotation:hover { background: #f7f7f7; }
|
||
.mapus-result:hover { background: #f3f4f6 !important; }
|
||
.mapus-tool:hover { background: #f7f7f7; }
|
||
.mapus-tool.active { background: #222 !important; color: #fff !important; }
|
||
.mapus-color:hover { transform: scale(1.15); }
|
||
.mapus-color.selected { transform: scale(1.2); box-shadow: 0 0 0 2px #222; }
|
||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
||
`}</style>
|
||
|
||
<StandardizedToolWrapper
|
||
title={shape.props.title || 'Collaborative Map'}
|
||
primaryColor={MapShape.PRIMARY_COLOR}
|
||
isSelected={isSelected}
|
||
width={shape.props.w}
|
||
height={contentHeight + 40}
|
||
onClose={handleClose}
|
||
onMinimize={handleMinimize}
|
||
isMinimized={shape.props.isMinimized}
|
||
onMaximize={toggleMaximize}
|
||
isMaximized={isMaximized}
|
||
editor={editor}
|
||
shapeId={shape.id}
|
||
isPinnedToView={shape.props.pinnedToView}
|
||
onPinToggle={handlePinToggle}
|
||
tags={shape.props.tags || ['map']}
|
||
onTagsChange={handleTagsChange}
|
||
tagsEditable={true}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
display: 'flex',
|
||
position: 'relative',
|
||
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif',
|
||
}}
|
||
>
|
||
{/* Left Sidebar */}
|
||
{shape.props.showSidebar && (
|
||
<div
|
||
className="mapus-sidebar"
|
||
style={styles.sidebar}
|
||
onPointerDown={stopPropagation}
|
||
>
|
||
{/* Search */}
|
||
<div style={styles.section}>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={(e) => 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',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={searchPlaces}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
...styles.button,
|
||
padding: '10px 14px',
|
||
border: '1px solid #E8E8E8',
|
||
}}
|
||
>
|
||
{isSearching ? '⏳' : '🔍'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Results */}
|
||
{searchResults.length > 0 && (
|
||
<div style={{ marginTop: 8, maxHeight: 200, overflowY: 'auto' }}>
|
||
{searchResults.map((result, i) => (
|
||
<div
|
||
key={i}
|
||
className="mapus-result"
|
||
onClick={() => selectSearchResult(result)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
padding: '10px 8px',
|
||
cursor: 'pointer',
|
||
fontSize: 12,
|
||
borderRadius: 4,
|
||
}}
|
||
>
|
||
📍 {result.name.slice(0, 50)}...
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Find Nearby */}
|
||
<div style={styles.section}>
|
||
<div style={styles.sectionTitle}>
|
||
Find nearby {isFetchingNearby && <span style={{ marginLeft: 8, fontSize: 12 }}>⏳</span>}
|
||
</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, opacity: isFetchingNearby ? 0.5 : 1 }}>
|
||
{NEARBY_CATEGORIES.map((cat) => (
|
||
<div
|
||
key={cat.key}
|
||
className="mapus-category"
|
||
onClick={() => !isFetchingNearby && findNearby(cat)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
textAlign: 'center',
|
||
padding: '10px 4px',
|
||
borderRadius: 6,
|
||
cursor: isFetchingNearby ? 'wait' : 'pointer',
|
||
fontSize: 11,
|
||
color: '#626C72',
|
||
}}
|
||
>
|
||
<div style={{ fontSize: 22, marginBottom: 4 }}>{cat.icon}</div>
|
||
{cat.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Collaborators */}
|
||
{shape.props.collaborators.length > 0 && (
|
||
<div style={styles.section}>
|
||
<div style={styles.sectionTitle}>People ({shape.props.collaborators.length})</div>
|
||
{shape.props.collaborators.map((collab: CollaboratorPresence) => (
|
||
<div
|
||
key={collab.id}
|
||
className="mapus-annotation"
|
||
onClick={() => 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',
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
background: collab.color,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 12,
|
||
color: 'white',
|
||
fontWeight: 600,
|
||
}}>
|
||
{collab.name[0].toUpperCase()}
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 500 }}>{collab.name}</div>
|
||
{observingUser === collab.id && (
|
||
<div style={{ fontSize: 11, color: '#22c55e' }}>👁️ Observing</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Annotations */}
|
||
<div style={styles.section}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||
<div style={styles.sectionTitle}>Annotations</div>
|
||
<button
|
||
onClick={hideAllAnnotations}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, fontSize: 12, padding: '4px 8px', color: '#626C72' }}
|
||
>
|
||
Hide all
|
||
</button>
|
||
</div>
|
||
|
||
{shape.props.annotations.length === 0 ? (
|
||
<div style={{ fontSize: 12, color: '#9ca3af', textAlign: 'center', padding: 16 }}>
|
||
No annotations yet.<br />Use the tools below to add some!
|
||
</div>
|
||
) : (
|
||
<div style={{ maxHeight: 200, overflowY: 'auto' }}>
|
||
{shape.props.annotations.map((ann: Annotation) => (
|
||
<div
|
||
key={ann.id}
|
||
className="mapus-annotation"
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
padding: '8px 6px',
|
||
borderRadius: 6,
|
||
cursor: 'pointer',
|
||
opacity: ann.visible ? 1 : 0.5,
|
||
}}
|
||
>
|
||
<div style={{
|
||
width: 12,
|
||
height: 12,
|
||
borderRadius: '50%',
|
||
background: ann.color,
|
||
}} />
|
||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||
<div style={{ fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{ann.name}
|
||
</div>
|
||
{ann.type === 'line' && ann.coordinates.length >= 2 && (
|
||
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
|
||
📏 {formatDistance(calculateDistance(ann.coordinates))}
|
||
</div>
|
||
)}
|
||
{ann.type === 'area' && ann.coordinates.length >= 3 && (
|
||
<div style={{ fontSize: 10, color: '#666', marginTop: 1 }}>
|
||
⬡ {formatArea(calculateArea(ann.coordinates))} • {formatDistance(calculateDistance([...ann.coordinates, ann.coordinates[0]]))} perimeter
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); toggleAnnotationVisibility(ann.id); }}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, padding: 4, fontSize: 14 }}
|
||
>
|
||
{ann.visible ? '👁️' : '👁️🗨️'}
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); removeAnnotation(ann.id); }}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{ ...styles.button, padding: 4, fontSize: 14, color: '#E15F59' }}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Attribution */}
|
||
<div style={{ ...styles.section, borderBottom: 'none', fontSize: 11, color: '#9ca3af' }}>
|
||
© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener" style={{ color: '#9ca3af' }}>OpenStreetMap</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Map Container */}
|
||
<div
|
||
style={{ flex: 1, position: 'relative', pointerEvents: 'auto' }}
|
||
onPointerDown={stopPropagation}
|
||
>
|
||
<div ref={containerRef} style={{ width: '100%', height: '100%', pointerEvents: 'auto' }} />
|
||
|
||
{/* Sidebar Toggle */}
|
||
<button
|
||
onClick={toggleSidebar}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
position: 'absolute',
|
||
top: 10,
|
||
left: 10,
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
fontSize: 18,
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
{shape.props.showSidebar ? '◀' : '▶'}
|
||
</button>
|
||
|
||
{/* Style Picker */}
|
||
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
|
||
<select
|
||
value={styleKey}
|
||
onChange={(e) => changeStyle(e.target.value as StyleKey)}
|
||
onPointerDown={stopPropagation}
|
||
style={{
|
||
padding: '8px 12px',
|
||
borderRadius: 6,
|
||
border: 'none',
|
||
background: '#fff',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
fontSize: 13,
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{Object.entries(MAP_STYLES).map(([key, style]) => (
|
||
<option key={key} value={key}>{style.icon} {style.name}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Zoom Controls */}
|
||
<div style={{ position: 'absolute', bottom: 80, right: 10, display: 'flex', flexDirection: 'column', gap: 4, zIndex: 10 }}>
|
||
<button
|
||
onClick={() => mapRef.current?.zoomIn()}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 18,
|
||
}}
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => mapRef.current?.zoomOut()}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 18,
|
||
}}
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
navigator.geolocation?.getCurrentPosition((pos) => {
|
||
mapRef.current?.flyTo({
|
||
center: [pos.coords.longitude, pos.coords.latitude],
|
||
zoom: 15,
|
||
duration: 1000,
|
||
});
|
||
});
|
||
}}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: '#fff',
|
||
border: 'none',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 16,
|
||
marginTop: 4,
|
||
}}
|
||
title="My location"
|
||
>
|
||
⊙
|
||
</button>
|
||
<button
|
||
onClick={toggleLocationSharing}
|
||
onPointerDown={stopPropagation}
|
||
className="mapus-btn"
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 8,
|
||
background: isSharingLocation ? '#22c55e' : '#fff',
|
||
border: isSharingLocation ? '2px solid #16a34a' : 'none',
|
||
boxShadow: isSharingLocation
|
||
? '0 0 12px rgba(34, 197, 94, 0.5)'
|
||
: '0 2px 8px rgba(0,0,0,0.15)',
|
||
cursor: 'pointer',
|
||
fontSize: 14,
|
||
marginTop: 4,
|
||
transition: 'all 0.2s ease',
|
||
animation: isSharingLocation ? 'pulse 2s infinite' : 'none',
|
||
}}
|
||
title={isSharingLocation ? 'Stop sharing location' : 'Share my location'}
|
||
>
|
||
{isSharingLocation ? '📡' : '📍'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Measurement Display and Drawing Instructions */}
|
||
{(activeTool === 'line' || activeTool === 'area') && (
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
bottom: 80,
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
background: '#fff',
|
||
borderRadius: 8,
|
||
boxShadow: '0 2px 12px rgba(0,0,0,0.15)',
|
||
padding: '8px 14px',
|
||
zIndex: 10000,
|
||
pointerEvents: 'none',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{drawingPoints.length > 0 && (
|
||
<div style={{ fontSize: 14, fontWeight: 600, color: selectedColor }}>
|
||
{activeTool === 'line' && formatDistance(calculateDistance(drawingPoints))}
|
||
{activeTool === 'area' && drawingPoints.length >= 3 && formatArea(calculateArea(drawingPoints))}
|
||
{activeTool === 'area' && drawingPoints.length < 3 && `${drawingPoints.length}/3 points`}
|
||
</div>
|
||
)}
|
||
<div style={{ fontSize: 11, color: '#666' }}>
|
||
{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'}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Drawing Toolbar (Mapus-style) */}
|
||
<div style={styles.toolbar} onPointerDown={stopPropagation}>
|
||
{/* Cursor Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('cursor')}
|
||
className={`mapus-tool ${activeTool === 'cursor' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Select"
|
||
>
|
||
↖️
|
||
</button>
|
||
|
||
{/* Marker Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('marker')}
|
||
className={`mapus-tool ${activeTool === 'marker' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Add marker"
|
||
>
|
||
📍
|
||
</button>
|
||
|
||
{/* Line Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('line')}
|
||
className={`mapus-tool ${activeTool === 'line' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Draw line"
|
||
>
|
||
📏
|
||
</button>
|
||
|
||
{/* Area Tool */}
|
||
<button
|
||
onClick={() => setActiveTool('area')}
|
||
className={`mapus-tool ${activeTool === 'area' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Draw area"
|
||
>
|
||
⬡
|
||
</button>
|
||
|
||
{/* Eraser */}
|
||
<button
|
||
onClick={() => setActiveTool('eraser')}
|
||
className={`mapus-tool ${activeTool === 'eraser' ? 'active' : ''}`}
|
||
style={styles.toolButton}
|
||
title="Eraser"
|
||
>
|
||
🧹
|
||
</button>
|
||
|
||
{/* Divider */}
|
||
<div style={{ width: 1, background: '#E8E8E8', margin: '4px 6px' }} />
|
||
|
||
{/* Color Picker */}
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => setShowColorPicker(!showColorPicker)}
|
||
style={{
|
||
...styles.toolButton,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
}}
|
||
title="Color"
|
||
>
|
||
<div style={{
|
||
width: 24,
|
||
height: 24,
|
||
borderRadius: '50%',
|
||
background: selectedColor,
|
||
border: '2px solid #fff',
|
||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||
}} />
|
||
</button>
|
||
|
||
{showColorPicker && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: '100%',
|
||
left: '50%',
|
||
transform: 'translateX(-50%)',
|
||
marginBottom: 8,
|
||
background: '#fff',
|
||
borderRadius: 8,
|
||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||
padding: 8,
|
||
display: 'flex',
|
||
gap: 6,
|
||
}}>
|
||
{COLORS.map((color) => (
|
||
<div
|
||
key={color}
|
||
className={`mapus-color ${selectedColor === color ? 'selected' : ''}`}
|
||
onClick={() => { setSelectedColor(color); setShowColorPicker(false); }}
|
||
style={{
|
||
width: 28,
|
||
height: 28,
|
||
borderRadius: '50%',
|
||
background: color,
|
||
cursor: 'pointer',
|
||
transition: 'transform 0.15s',
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Observing Indicator */}
|
||
{observingUser && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
left: 8,
|
||
right: 8,
|
||
bottom: 8,
|
||
pointerEvents: 'none',
|
||
border: `3px solid ${shape.props.collaborators.find((c: CollaboratorPresence) => c.id === observingUser)?.color || '#3b82f6'}`,
|
||
borderRadius: 8,
|
||
}}>
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: -30,
|
||
left: 10,
|
||
background: shape.props.collaborators.find((c: CollaboratorPresence) => 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}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</StandardizedToolWrapper>
|
||
</HTMLContainer>
|
||
);
|
||
}
|
||
|
||
export default MapShape;
|