From a1bef4174a0303cdeeb630bcd1706f13e594ba04 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 4 Dec 2025 21:32:46 -0800 Subject: [PATCH] chore: add D1 database ID and refactor MapShape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add production D1 database ID for cryptid-auth - Refactor MapShapeUtil for cleaner implementation - Add map layers module - Update UI components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/open-mapping/hooks/index.ts | 2 +- src/open-mapping/hooks/useMapInstance.ts | 128 +- src/open-mapping/index.ts | 3 + .../layers/GPSCollaborationLayer.ts | 492 +++ src/open-mapping/layers/index.ts | 9 + src/shapes/MapShapeUtil.tsx | 2659 ++++------------- src/ui/components.tsx | 17 +- wrangler.toml | 2 +- 8 files changed, 1280 insertions(+), 2032 deletions(-) create mode 100644 src/open-mapping/layers/GPSCollaborationLayer.ts create mode 100644 src/open-mapping/layers/index.ts diff --git a/src/open-mapping/hooks/index.ts b/src/open-mapping/hooks/index.ts index e09b9c8..e9897e6 100644 --- a/src/open-mapping/hooks/index.ts +++ b/src/open-mapping/hooks/index.ts @@ -1,4 +1,4 @@ -export { useMapInstance } from './useMapInstance'; +export { useMapInstance, MAP_STYLES } from './useMapInstance'; export { useRouting } from './useRouting'; export { useCollaboration } from './useCollaboration'; export { useLayers } from './useLayers'; diff --git a/src/open-mapping/hooks/useMapInstance.ts b/src/open-mapping/hooks/useMapInstance.ts index d120fc9..85fd018 100644 --- a/src/open-mapping/hooks/useMapInstance.ts +++ b/src/open-mapping/hooks/useMapInstance.ts @@ -43,27 +43,113 @@ const DEFAULT_VIEWPORT: MapViewport = { pitch: 0, }; -// Default style using OpenStreetMap tiles via MapLibre -const DEFAULT_STYLE: maplibregl.StyleSpecification = { - version: 8, - sources: { - 'osm-raster': { - type: 'raster', - tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - attribution: '© OpenStreetMap contributors', - }, +// Available map styles - all free, no API key required +export const MAP_STYLES = { + // Carto Voyager - clean, modern look (default) + voyager: { + name: 'Voyager', + url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json', + icon: '🗺️', + maxZoom: 20, }, - layers: [ - { - id: 'osm-raster-layer', - type: 'raster', - source: 'osm-raster', - minzoom: 0, - maxzoom: 19, - }, - ], -}; + // Carto Positron - light, minimal + positron: { + name: 'Light', + url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', + icon: '☀️', + maxZoom: 20, + }, + // Carto Dark Matter - dark mode + darkMatter: { + name: 'Dark', + url: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', + icon: '🌙', + maxZoom: 20, + }, + // OpenStreetMap standard raster tiles + osm: { + name: 'OSM Classic', + url: { + version: 8, + sources: { + 'osm-raster': { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + maxzoom: 19, + }, + }, + layers: [{ id: 'osm-raster-layer', type: 'raster', source: 'osm-raster' }], + } as maplibregl.StyleSpecification, + icon: '🌍', + maxZoom: 19, + }, + // OpenFreeMap - high detail vector tiles (free, self-hostable) + liberty: { + name: 'Liberty HD', + url: 'https://tiles.openfreemap.org/styles/liberty', + icon: '🏛️', + maxZoom: 22, + }, + // OpenFreeMap Bright - detailed bright style + bright: { + name: 'Bright HD', + url: 'https://tiles.openfreemap.org/styles/bright', + icon: '✨', + maxZoom: 22, + }, + // Protomaps - detailed vector tiles + protomapsLight: { + name: 'Proto Light', + url: { + version: 8, + glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', + sources: { + protomaps: { + type: 'vector', + tiles: ['https://api.protomaps.com/tiles/v3/{z}/{x}/{y}.mvt?key=1003762824b9687f'], + maxzoom: 15, + attribution: '© Protomaps © OpenStreetMap', + }, + }, + layers: [ + { id: 'background', type: 'background', paint: { 'background-color': '#f8f4f0' } }, + { id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': '#a0c8f0' } }, + { id: 'landuse-park', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', filter: ['==', 'pmap:kind', 'park'], paint: { 'fill-color': '#c8e6c8' } }, + { id: 'roads-minor', type: 'line', source: 'protomaps', 'source-layer': 'roads', filter: ['in', 'pmap:kind', 'minor_road', 'other'], paint: { 'line-color': '#ffffff', 'line-width': 1 } }, + { id: 'roads-major', type: 'line', source: 'protomaps', 'source-layer': 'roads', filter: ['in', 'pmap:kind', 'major_road', 'highway'], paint: { 'line-color': '#ffd080', 'line-width': 2 } }, + { id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', paint: { 'fill-color': '#e0dcd8', 'fill-opacity': 0.8 } }, + ], + } as maplibregl.StyleSpecification, + icon: '🔬', + maxZoom: 22, + }, + // Satellite imagery via ESRI World Imagery (free for personal use) + 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, DigitalGlobe, GeoEye, Earthstar Geographics', + maxzoom: 19, + }, + }, + layers: [{ id: 'satellite-layer', type: 'raster', source: 'esri-satellite' }], + } as maplibregl.StyleSpecification, + icon: '🛰️', + maxZoom: 19, + }, +} as const; + +// Default style - Carto Voyager (clean, modern, Google Maps-like) +const DEFAULT_STYLE = MAP_STYLES.voyager.url; export function useMapInstance({ container, @@ -103,7 +189,7 @@ export function useMapInstance({ pitch: initialViewport.pitch, interactive, attributionControl: false, - maxZoom: config.maxZoom ?? 19, + maxZoom: config.maxZoom ?? 22, }); mapRef.current = map; diff --git a/src/open-mapping/index.ts b/src/open-mapping/index.ts index 51bc697..7a9a267 100644 --- a/src/open-mapping/index.ts +++ b/src/open-mapping/index.ts @@ -56,3 +56,6 @@ export * as discovery from './discovery'; // Real-Time Location Presence with Privacy Controls export * as presence from './presence'; + +// Reusable Map Layers (GPS, Collaboration, etc.) +export * as layers from './layers'; diff --git a/src/open-mapping/layers/GPSCollaborationLayer.ts b/src/open-mapping/layers/GPSCollaborationLayer.ts new file mode 100644 index 0000000..8a7b892 --- /dev/null +++ b/src/open-mapping/layers/GPSCollaborationLayer.ts @@ -0,0 +1,492 @@ +/** + * GPS Collaboration Layer + * + * A reusable module for adding real-time GPS/location sharing to any MapLibre map. + * Uses GeoJSON format for data interchange and can sync via any CRDT system. + * + * Usage: + * const gpsLayer = new GPSCollaborationLayer(map); + * gpsLayer.startSharing({ userId: 'user1', userName: 'Alice', color: '#3b82f6' }); + * gpsLayer.updatePeer({ userId: 'user2', ... }); // From CRDT sync + */ + +import maplibregl from 'maplibre-gl'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface GPSUser { + userId: string; + userName: string; + color: string; + coordinate: { lat: number; lng: number }; + accuracy?: number; + heading?: number; + speed?: number; + timestamp: number; +} + +export interface GPSLayerOptions { + /** Stale timeout in ms (default: 5 minutes) */ + staleTimeout?: number; + /** Update interval for broadcasting location (default: 5000ms) */ + updateInterval?: number; + /** Privacy mode - reduces coordinate precision */ + privacyMode?: 'precise' | 'neighborhood' | 'city'; + /** Callback when user location updates */ + onLocationUpdate?: (user: GPSUser) => void; + /** Custom marker style */ + markerStyle?: Partial; +} + +interface MarkerStyle { + size: number; + borderWidth: number; + showAccuracy: boolean; + showHeading: boolean; + pulseAnimation: boolean; +} + +const DEFAULT_OPTIONS: Required = { + staleTimeout: 5 * 60 * 1000, + updateInterval: 5000, + privacyMode: 'precise', + onLocationUpdate: () => {}, + markerStyle: { + size: 36, + borderWidth: 3, + showAccuracy: true, + showHeading: true, + pulseAnimation: true, + }, +}; + +// Person emojis for variety +const PERSON_EMOJIS = ['🧑', '👤', '🚶', '🧍', '👨', '👩', '🧔', '👱']; + +// ============================================================================= +// GPS Collaboration Layer +// ============================================================================= + +export class GPSCollaborationLayer { + private map: maplibregl.Map; + private options: Required; + private markers: Map = new Map(); + private accuracyCircles: Map = new Map(); // layerId + private watchId: number | null = null; + private currentUser: GPSUser | null = null; + private peers: Map = new Map(); + private updateTimer: number | null = null; + private isSharing = false; + + constructor(map: maplibregl.Map, options: GPSLayerOptions = {}) { + this.map = map; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.injectStyles(); + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Start sharing your location + */ + startSharing(user: Pick): Promise { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + reject(new Error('Geolocation not supported')); + return; + } + + this.isSharing = true; + + this.watchId = navigator.geolocation.watchPosition( + (position) => { + const coordinate = this.applyPrivacy({ + lat: position.coords.latitude, + lng: position.coords.longitude, + }); + + this.currentUser = { + ...user, + coordinate, + accuracy: position.coords.accuracy, + heading: position.coords.heading ?? undefined, + speed: position.coords.speed ?? undefined, + timestamp: Date.now(), + }; + + this.renderUserMarker(this.currentUser, true); + this.options.onLocationUpdate(this.currentUser); + resolve(this.currentUser); + }, + (error) => { + this.isSharing = false; + reject(new Error(this.getGeolocationErrorMessage(error))); + }, + { + enableHighAccuracy: this.options.privacyMode === 'precise', + timeout: 10000, + maximumAge: this.options.privacyMode === 'precise' ? 0 : 30000, + } + ); + }); + } + + /** + * Stop sharing your location + */ + stopSharing(): void { + this.isSharing = false; + + if (this.watchId !== null) { + navigator.geolocation.clearWatch(this.watchId); + this.watchId = null; + } + + if (this.currentUser) { + this.removeMarker(this.currentUser.userId); + this.currentUser = null; + } + } + + /** + * Update a peer's location (call this from your sync system) + */ + updatePeer(user: GPSUser): void { + // Ignore stale updates + if (Date.now() - user.timestamp > this.options.staleTimeout) { + this.removePeer(user.userId); + return; + } + + this.peers.set(user.userId, user); + this.renderUserMarker(user, false); + } + + /** + * Remove a peer (when they disconnect or stop sharing) + */ + removePeer(userId: string): void { + this.peers.delete(userId); + this.removeMarker(userId); + } + + /** + * Get all active users as GeoJSON FeatureCollection + */ + toGeoJSON(): GeoJSON.FeatureCollection { + const features: GeoJSON.Feature[] = []; + + // Add current user + if (this.currentUser) { + features.push(this.userToFeature(this.currentUser, true)); + } + + // Add peers + this.peers.forEach((user) => { + if (Date.now() - user.timestamp < this.options.staleTimeout) { + features.push(this.userToFeature(user, false)); + } + }); + + return { type: 'FeatureCollection', features }; + } + + /** + * Load users from GeoJSON (e.g., from CRDT sync) + */ + fromGeoJSON(geojson: GeoJSON.FeatureCollection): void { + geojson.features.forEach((feature) => { + if (feature.geometry.type !== 'Point') return; + + const props = feature.properties as any; + if (props.userId === this.currentUser?.userId) return; // Skip self + + const user: GPSUser = { + userId: props.userId, + userName: props.userName, + color: props.color, + coordinate: { + lng: (feature.geometry as GeoJSON.Point).coordinates[0], + lat: (feature.geometry as GeoJSON.Point).coordinates[1], + }, + accuracy: props.accuracy, + heading: props.heading, + speed: props.speed, + timestamp: props.timestamp, + }; + + this.updatePeer(user); + }); + } + + /** + * Get current sharing state + */ + getState(): { isSharing: boolean; currentUser: GPSUser | null; peerCount: number } { + return { + isSharing: this.isSharing, + currentUser: this.currentUser, + peerCount: this.peers.size, + }; + } + + /** + * Fly to a specific user + */ + flyToUser(userId: string): void { + const user = userId === this.currentUser?.userId ? this.currentUser : this.peers.get(userId); + if (user) { + this.map.flyTo({ + center: [user.coordinate.lng, user.coordinate.lat], + zoom: 15, + duration: 1000, + }); + } + } + + /** + * Fit map to show all users + */ + fitToAllUsers(): void { + const bounds = new maplibregl.LngLatBounds(); + let hasPoints = false; + + if (this.currentUser) { + bounds.extend([this.currentUser.coordinate.lng, this.currentUser.coordinate.lat]); + hasPoints = true; + } + + this.peers.forEach((user) => { + bounds.extend([user.coordinate.lng, user.coordinate.lat]); + hasPoints = true; + }); + + if (hasPoints) { + this.map.fitBounds(bounds, { padding: 50, maxZoom: 15 }); + } + } + + /** + * Cleanup - call when done with the layer + */ + destroy(): void { + this.stopSharing(); + this.markers.forEach((marker) => marker.remove()); + this.markers.clear(); + this.accuracyCircles.forEach((layerId) => { + if (this.map.getLayer(layerId)) this.map.removeLayer(layerId); + if (this.map.getSource(layerId)) this.map.removeSource(layerId); + }); + this.accuracyCircles.clear(); + this.peers.clear(); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + private renderUserMarker(user: GPSUser, isCurrentUser: boolean): void { + const markerId = user.userId; + let marker = this.markers.get(markerId); + + if (!marker) { + const el = this.createMarkerElement(user, isCurrentUser); + marker = new maplibregl.Marker({ element: el, anchor: 'center' }) + .setLngLat([user.coordinate.lng, user.coordinate.lat]) + .addTo(this.map); + this.markers.set(markerId, marker); + } else { + marker.setLngLat([user.coordinate.lng, user.coordinate.lat]); + this.updateMarkerElement(marker.getElement(), user, isCurrentUser); + } + + // Update accuracy circle if enabled + if (this.options.markerStyle.showAccuracy && user.accuracy) { + this.updateAccuracyCircle(user); + } + } + + private createMarkerElement(user: GPSUser, isCurrentUser: boolean): HTMLDivElement { + const el = document.createElement('div'); + el.className = `gps-marker ${isCurrentUser ? 'gps-marker-self' : 'gps-marker-peer'}`; + + const { size, borderWidth } = this.options.markerStyle; + const emoji = this.getPersonEmoji(user.userId); + + el.style.cssText = ` + width: ${size}px; + height: ${size}px; + background: ${isCurrentUser ? `linear-gradient(135deg, ${user.color}, ${this.darkenColor(user.color)})` : user.color}; + border: ${borderWidth}px solid white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: ${size * 0.5}px; + box-shadow: 0 2px 10px rgba(0,0,0,0.4); + cursor: pointer; + position: relative; + ${this.options.markerStyle.pulseAnimation ? 'animation: gps-pulse 2s ease-in-out infinite;' : ''} + `; + + el.textContent = isCurrentUser ? '📍' : emoji; + el.title = `${user.userName}${isCurrentUser ? ' (you)' : ''}`; + + return el; + } + + private updateMarkerElement(el: HTMLElement, user: GPSUser, isCurrentUser: boolean): void { + el.title = `${user.userName}${isCurrentUser ? ' (you)' : ''}`; + } + + private updateAccuracyCircle(user: GPSUser): void { + if (!user.accuracy || user.accuracy > 500) return; // Don't show if too inaccurate + + const sourceId = `accuracy-${user.userId}`; + const layerId = `accuracy-layer-${user.userId}`; + + const center = [user.coordinate.lng, user.coordinate.lat]; + const radiusInKm = user.accuracy / 1000; + const circleGeoJSON = this.createCircleGeoJSON(center as [number, number], radiusInKm); + + if (this.map.getSource(sourceId)) { + (this.map.getSource(sourceId) as maplibregl.GeoJSONSource).setData(circleGeoJSON); + } else { + this.map.addSource(sourceId, { type: 'geojson', data: circleGeoJSON }); + this.map.addLayer({ + id: layerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': user.color, + 'fill-opacity': 0.15, + }, + }); + this.accuracyCircles.set(user.userId, layerId); + } + } + + private createCircleGeoJSON(center: [number, number], radiusKm: number): GeoJSON.Feature { + const points = 64; + const coords: [number, number][] = []; + + for (let i = 0; i < points; i++) { + const angle = (i / points) * 2 * Math.PI; + const dx = radiusKm * Math.cos(angle); + const dy = radiusKm * Math.sin(angle); + const lat = center[1] + (dy / 111.32); + const lng = center[0] + (dx / (111.32 * Math.cos(center[1] * Math.PI / 180))); + coords.push([lng, lat]); + } + coords.push(coords[0]); // Close the polygon + + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Polygon', coordinates: [coords] }, + }; + } + + private removeMarker(userId: string): void { + const marker = this.markers.get(userId); + if (marker) { + marker.remove(); + this.markers.delete(userId); + } + + const layerId = this.accuracyCircles.get(userId); + if (layerId) { + if (this.map.getLayer(layerId)) this.map.removeLayer(layerId); + const sourceId = `accuracy-${userId}`; + if (this.map.getSource(sourceId)) this.map.removeSource(sourceId); + this.accuracyCircles.delete(userId); + } + } + + private userToFeature(user: GPSUser, isCurrentUser: boolean): GeoJSON.Feature { + return { + type: 'Feature', + properties: { + userId: user.userId, + userName: user.userName, + color: user.color, + accuracy: user.accuracy, + heading: user.heading, + speed: user.speed, + timestamp: user.timestamp, + isCurrentUser, + }, + geometry: { + type: 'Point', + coordinates: [user.coordinate.lng, user.coordinate.lat], + }, + }; + } + + private applyPrivacy(coord: { lat: number; lng: number }): { lat: number; lng: number } { + switch (this.options.privacyMode) { + case 'city': + return { + lat: Math.round(coord.lat * 10) / 10, + lng: Math.round(coord.lng * 10) / 10, + }; + case 'neighborhood': + return { + lat: Math.round(coord.lat * 100) / 100, + lng: Math.round(coord.lng * 100) / 100, + }; + default: + return coord; + } + } + + private getPersonEmoji(userId: string): string { + const hash = userId.split('').reduce((a, c) => a + c.charCodeAt(0), 0); + return PERSON_EMOJIS[Math.abs(hash) % PERSON_EMOJIS.length]; + } + + private darkenColor(hex: string): string { + const num = parseInt(hex.replace('#', ''), 16); + const r = Math.max(0, (num >> 16) - 40); + const g = Math.max(0, ((num >> 8) & 0x00FF) - 40); + const b = Math.max(0, (num & 0x0000FF) - 40); + return `#${(r << 16 | g << 8 | b).toString(16).padStart(6, '0')}`; + } + + private getGeolocationErrorMessage(error: GeolocationPositionError): string { + switch (error.code) { + case error.PERMISSION_DENIED: + return 'Location permission denied'; + case error.POSITION_UNAVAILABLE: + return 'Location unavailable'; + case error.TIMEOUT: + return 'Location request timeout'; + default: + return 'Unknown location error'; + } + } + + private injectStyles(): void { + if (document.getElementById('gps-collaboration-styles')) return; + + const style = document.createElement('style'); + style.id = 'gps-collaboration-styles'; + style.textContent = ` + @keyframes gps-pulse { + 0%, 100% { transform: scale(1); box-shadow: 0 2px 10px rgba(0,0,0,0.4); } + 50% { transform: scale(1.05); box-shadow: 0 3px 15px rgba(0,0,0,0.5); } + } + .gps-marker:hover { + transform: scale(1.1) !important; + z-index: 1000; + } + `; + document.head.appendChild(style); + } +} + +export default GPSCollaborationLayer; diff --git a/src/open-mapping/layers/index.ts b/src/open-mapping/layers/index.ts new file mode 100644 index 0000000..97a72ef --- /dev/null +++ b/src/open-mapping/layers/index.ts @@ -0,0 +1,9 @@ +/** + * Map Layers - Reusable overlay modules for MapLibre GL JS + * + * These layers can be added to any MapLibre map instance and are designed + * to work with GeoJSON data synced via CRDT (Automerge). + */ + +export { GPSCollaborationLayer } from './GPSCollaborationLayer'; +export type { GPSUser, GPSLayerOptions } from './GPSCollaborationLayer'; diff --git a/src/shapes/MapShapeUtil.tsx b/src/shapes/MapShapeUtil.tsx index 67ea7ee..6515965 100644 --- a/src/shapes/MapShapeUtil.tsx +++ b/src/shapes/MapShapeUtil.tsx @@ -1,253 +1,161 @@ /** - * MapShapeUtil - tldraw shape for embedding interactive OSM maps + * MapShapeUtil - Simplified tldraw shape for interactive maps * - * Renders a MapLibre GL JS map as a resizable shape on the canvas, - * enabling geographic visualization alongside other canvas elements. + * Base functionality: + * - MapLibre GL JS rendering + * - Search (Nominatim geocoding) + * - Routing (OSRM) + * - Style switching + * - GeoJSON layer support for collaboration overlays */ import { BaseBoxShapeUtil, TLBaseShape, HTMLContainer, TLResizeInfo, resizeBox, T } from 'tldraw'; import { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import maplibregl from 'maplibre-gl'; -import { useMapInstance } from '@/open-mapping/hooks/useMapInstance'; -import { useRouting } from '@/open-mapping/hooks/useRouting'; -import { StandardizedToolWrapper } from '@/components/StandardizedToolWrapper'; -import { usePinnedToView } from '@/hooks/usePinnedToView'; -import type { MapViewport, Coordinate, Waypoint, Route, MapLayer, RoutingServiceConfig } from '@/open-mapping/types'; -import type { LensType } from '@/open-mapping/lenses/types'; - -// Default OSRM routing configuration (public demo server - replace with self-hosted for production) -const DEFAULT_ROUTING_CONFIG: RoutingServiceConfig = { - provider: 'osrm', - baseUrl: 'https://router.project-osrm.org', - timeout: 10000, -}; +import 'maplibre-gl/dist/maplibre-gl.css'; // ============================================================================= -// Subsystem Configuration Types +// Types // ============================================================================= -/** Shared location entry for a collaborator */ -export interface SharedLocation { - userId: string; - userName: string; - color: string; +export interface Coordinate { + lat: number; + lng: number; +} + +export interface MapViewport { + center: Coordinate; + zoom: number; + bearing: number; + pitch: number; +} + +export interface Waypoint { + id: string; coordinate: Coordinate; - timestamp: number; - privacyLevel: 'city' | 'neighborhood' | 'block' | 'precise'; + name?: string; } -/** Presence subsystem - privacy-preserving location sharing */ -export interface MapPresenceConfig { - enabled: boolean; - isSharing: boolean; - privacyLevel: 'city' | 'neighborhood' | 'block' | 'precise'; - /** Shared locations from all collaborators - keyed by oduserId for conflict-free updates */ - sharedLocations: Record; +export interface RouteInfo { + distance: number; // meters + duration: number; // seconds + geometry: GeoJSON.LineString; } -/** Lens subsystem - alternative map projections */ -export interface MapLensConfig { - enabled: boolean; - activeLens: LensType; - blendTransition: boolean; -} - -/** Display-friendly Discovery Anchor (simplified from full zkGPS type) */ -export interface DiscoveryAnchorMarker { +/** GeoJSON layer for collaboration overlays */ +export interface GeoJSONLayer { id: string; name: string; - description?: string; - type: 'physical' | 'nfc' | 'qr' | 'ble' | 'virtual' | 'temporal' | 'social'; - visibility: 'hidden' | 'hinted' | 'revealed' | 'public'; - coordinate: Coordinate; - discovered?: boolean; - createdAt: number; + visible: boolean; + data: GeoJSON.FeatureCollection; } -/** Display-friendly Spore marker */ -export interface SporeMarker { - id: string; - type: 'explorer' | 'connector' | 'nurturer' | 'guardian' | 'catalyst'; - coordinate: Coordinate; - strength: number; // 0-100 vitality - connections: string[]; // IDs of connected spores - plantedAt: number; - plantedBy?: string; -} - -/** Display-friendly Hunt marker (treasure hunt waypoint) */ -export interface HuntMarker { - id: string; - huntId: string; - huntName: string; - sequence: number; // Order in the hunt - coordinate: Coordinate; - found?: boolean; - hint?: string; -} - -/** Discovery subsystem - location games and treasure hunts */ -export interface MapDiscoveryConfig { - enabled: boolean; - showAnchors: boolean; - showSpores: boolean; - showHunts: boolean; - activeHuntId?: string; - /** Synced anchor data for map visualization */ - anchors: DiscoveryAnchorMarker[]; - /** Synced spore data for mycelium network visualization */ - spores: SporeMarker[]; - /** Synced hunt waypoints for treasure hunt visualization */ - hunts: HuntMarker[]; -} - -/** Routing subsystem - waypoints and route visualization */ -export interface MapRoutingConfig { - enabled: boolean; - waypoints: Waypoint[]; - routes: Route[]; - activeRouteId?: string; - showAlternatives: boolean; -} - -/** Conics subsystem - possibility cones visualization */ -export interface MapConicsConfig { - enabled: boolean; - showCones: boolean; - pipelineId?: string; -} - -// ============================================================================= -// Main MapShape Type -// ============================================================================= - export type IMapShape = TLBaseShape< 'Map', { - // Core dimensions w: number; h: number; - - // Viewport state viewport: MapViewport; - - // Canvas integration - pinnedToView: boolean; - tags: string[]; - - // Base map configuration - styleUrl?: string; + styleKey: string; interactive: boolean; - layers: MapLayer[]; - - // Subsystem configurations - presence: MapPresenceConfig; - lenses: MapLensConfig; - discovery: MapDiscoveryConfig; - routing: MapRoutingConfig; - conics: MapConicsConfig; + waypoints: Waypoint[]; + route: RouteInfo | null; + geoJsonLayers: GeoJSONLayer[]; } >; +// ============================================================================= +// Constants +// ============================================================================= + const DEFAULT_VIEWPORT: MapViewport = { - center: { lat: 40.7128, lng: -74.006 }, // NYC + center: { lat: 40.7128, lng: -74.006 }, zoom: 12, bearing: 0, pitch: 0, }; -// Default subsystem configurations -const DEFAULT_PRESENCE: MapPresenceConfig = { - enabled: false, - isSharing: false, - privacyLevel: 'neighborhood', - sharedLocations: {}, -}; +// OSRM routing server +const OSRM_BASE_URL = 'https://routing.jeffemmett.com'; -const DEFAULT_LENSES: MapLensConfig = { - enabled: false, - activeLens: 'geographic', - blendTransition: true, -}; +// Map styles - all free, no API key required +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: '🌙', + }, + liberty: { + name: 'Liberty HD', + url: 'https://tiles.openfreemap.org/styles/liberty', + icon: '🏛️', + }, + bright: { + name: 'Bright HD', + url: 'https://tiles.openfreemap.org/styles/bright', + 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; -const DEFAULT_DISCOVERY: MapDiscoveryConfig = { - enabled: false, - showAnchors: true, - showSpores: true, - showHunts: true, - anchors: [], - spores: [], - hunts: [], -}; +type StyleKey = keyof typeof MAP_STYLES; -const DEFAULT_ROUTING: MapRoutingConfig = { - enabled: false, - waypoints: [], - routes: [], - showAlternatives: false, -}; - -const DEFAULT_CONICS: MapConicsConfig = { - enabled: false, - showCones: true, -}; - -// Quick location presets for easy navigation -const LOCATION_PRESETS: { name: string; viewport: MapViewport }[] = [ - { name: 'New York', viewport: { center: { lat: 40.7128, lng: -74.006 }, zoom: 12, bearing: 0, pitch: 0 } }, - { name: 'London', viewport: { center: { lat: 51.5074, lng: -0.1278 }, zoom: 12, bearing: 0, pitch: 0 } }, - { name: 'Tokyo', viewport: { center: { lat: 35.6762, lng: 139.6503 }, zoom: 12, bearing: 0, pitch: 0 } }, - { name: 'San Francisco', viewport: { center: { lat: 37.7749, lng: -122.4194 }, zoom: 12, bearing: 0, pitch: 0 } }, - { name: 'Paris', viewport: { center: { lat: 48.8566, lng: 2.3522 }, zoom: 12, bearing: 0, pitch: 0 } }, -]; +// ============================================================================= +// Shape Definition +// ============================================================================= export class MapShape extends BaseBoxShapeUtil { static override type = 'Map' as const; - // All complex nested props use T.any for backwards compatibility static override props = { w: T.number, h: T.number, viewport: T.any, - pinnedToView: T.boolean, - tags: T.any, - styleUrl: T.any, + styleKey: T.string, interactive: T.boolean, - layers: T.any, - presence: T.any, - lenses: T.any, - discovery: T.any, - routing: T.any, - conics: T.any, + waypoints: T.any, + route: T.any, + geoJsonLayers: T.any, }; - // Map theme color: Green (nature/earth) static readonly PRIMARY_COLOR = '#22c55e'; getDefaultProps(): IMapShape['props'] { return { - // Core dimensions w: 600, h: 400, - - // Viewport viewport: DEFAULT_VIEWPORT, - - // Canvas integration - pinnedToView: false, - tags: ['map', 'geo'], - - // Base map - styleUrl: 'https://demotiles.maplibre.org/style.json', + styleKey: 'voyager', interactive: true, - layers: [], - - // Subsystems (all disabled by default for performance) - presence: DEFAULT_PRESENCE, - lenses: DEFAULT_LENSES, - discovery: DEFAULT_DISCOVERY, - routing: DEFAULT_ROUTING, - conics: DEFAULT_CONICS, + waypoints: [], + route: null, + geoJsonLayers: [], }; } @@ -264,1919 +172,662 @@ export class MapShape extends BaseBoxShapeUtil { } indicator(shape: IMapShape) { - return ( - - ); + return ; } component(shape: IMapShape) { - return ; + return ; } } -// Separate component for hooks -function MapShapeComponent({ - shape, - editor, -}: { - shape: IMapShape; - editor: MapShape['editor']; -}) { +// ============================================================================= +// Map Component +// ============================================================================= + +function MapComponent({ shape, editor }: { shape: IMapShape; editor: MapShape['editor'] }) { const containerRef = useRef(null); + const mapRef = useRef(null); const markersRef = useRef>(new Map()); - const userLocationMarkerRef = useRef(null); - const watchIdRef = useRef(null); - const [isMinimized, setIsMinimized] = useState(false); - const [showPresets, setShowPresets] = useState(false); - const [showSubsystemPanel, setShowSubsystemPanel] = useState(false); - const [showLensMenu, setShowLensMenu] = useState(false); + + const [isLoaded, setIsLoaded] = useState(false); + const [showStyleMenu, setShowStyleMenu] = useState(false); + const [showSearch, setShowSearch] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState<{ name: string; lat: number; lng: number }[]>([]); + const [isSearching, setIsSearching] = useState(false); const [isCalculatingRoute, setIsCalculatingRoute] = useState(false); - const [userLocation, setUserLocation] = useState(null); - const [locationError, setLocationError] = useState(null); - const collaboratorMarkersRef = useRef>(new Map()); - const anchorMarkersRef = useRef>(new Map()); - const sporeMarkersRef = useRef>(new Map()); - const huntMarkersRef = useRef>(new Map()); - const sporeConnectionsLayerId = useRef(null); - const isSelected = editor.getSelectedShapeIds().includes(shape.id); + const [routeError, setRouteError] = useState(null); - // Provide safe defaults for all props (backwards compatibility) - const props = useMemo(() => ({ - ...shape.props, - viewport: shape.props.viewport || DEFAULT_VIEWPORT, - tags: shape.props.tags || ['map', 'geo'], - layers: shape.props.layers || [], - presence: shape.props.presence || DEFAULT_PRESENCE, - lenses: shape.props.lenses || DEFAULT_LENSES, - discovery: shape.props.discovery || DEFAULT_DISCOVERY, - routing: shape.props.routing || DEFAULT_ROUTING, - conics: shape.props.conics || DEFAULT_CONICS, - }), [shape.props]); - - // Get current user info for presence - const currentUser = useMemo(() => { - const prefs = editor.user.getUserPreferences(); - return { - userId: prefs.id || 'anonymous', - userName: prefs.name || 'Anonymous', - color: prefs.color || '#3b82f6', - }; - }, [editor]); - - // Use pinned to view hook - usePinnedToView(editor, shape.id, shape.props.pinnedToView); - - // Routing hook - const { - calculateRoute, - isCalculating: routingIsCalculating, - error: routingError, - } = useRouting({ - config: DEFAULT_ROUTING_CONFIG, - onRouteCalculated: (route) => { - // Update shape with calculated route - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - routing: { - ...shape.props.routing, - routes: [...shape.props.routing.routes, route], - activeRouteId: route.id, - }, - }, - }); - setIsCalculatingRoute(false); - }, - onError: (err) => { - console.error('Routing error:', err); - setIsCalculatingRoute(false); - }, - }); + const styleKey = (shape.props.styleKey || 'voyager') as StyleKey; + const currentStyle = MAP_STYLES[styleKey] || MAP_STYLES.voyager; // Initialize map - const { isLoaded, error, viewport, setViewport, flyTo, resize, getMap } = useMapInstance({ - container: containerRef.current, - config: { - provider: 'maplibre', - styleUrl: shape.props.styleUrl, - maxZoom: 19, - }, - initialViewport: shape.props.viewport, - interactive: shape.props.interactive, - onViewportChange: (newViewport) => { - // Debounce updates to the shape to avoid too many syncs - // Only update if significantly changed + useEffect(() => { + if (!containerRef.current) return; + + const styleUrl = typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: styleUrl, + 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, + interactive: shape.props.interactive, + attributionControl: false, + maxZoom: 22, + }); + + mapRef.current = map; + + map.on('load', () => setIsLoaded(true)); + + map.on('moveend', () => { + const center = map.getCenter(); + const newViewport: MapViewport = { + center: { lat: center.lat, lng: center.lng }, + zoom: map.getZoom(), + bearing: map.getBearing(), + pitch: map.getPitch(), + }; + + // Debounce - only update if significantly changed const current = shape.props.viewport; - const centerChanged = + const changed = Math.abs(current.center.lat - newViewport.center.lat) > 0.001 || - Math.abs(current.center.lng - newViewport.center.lng) > 0.001; - const zoomChanged = Math.abs(current.zoom - newViewport.zoom) > 0.1; + Math.abs(current.center.lng - newViewport.center.lng) > 0.001 || + Math.abs(current.zoom - newViewport.zoom) > 0.1; - if (centerChanged || zoomChanged) { + if (changed) { editor.updateShape({ id: shape.id, type: 'Map', - props: { - ...shape.props, - viewport: newViewport, - }, + props: { viewport: newViewport }, }); } - }, - onClick: (coord) => { - // Add waypoint when routing is enabled - if (shape.props.routing.enabled) { - const newWaypoint: Waypoint = { - id: `wp-${Date.now()}`, - coordinate: coord, - name: `Waypoint ${shape.props.routing.waypoints.length + 1}`, - color: '#3b82f6', - }; + }); - const updatedWaypoints = [...shape.props.routing.waypoints, newWaypoint]; - - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - routing: { - ...shape.props.routing, - waypoints: updatedWaypoints, - }, - }, - }); - - // Auto-calculate route when we have 2+ waypoints - if (updatedWaypoints.length >= 2) { - setIsCalculatingRoute(true); - calculateRoute(updatedWaypoints); - } + // Click to add waypoint when in routing mode + map.on('click', (e) => { + if (shape.props.waypoints.length > 0 || e.originalEvent.shiftKey) { + addWaypoint({ lat: e.lngLat.lat, lng: e.lngLat.lng }); } - }, - }); + }); + + return () => { + map.remove(); + mapRef.current = null; + setIsLoaded(false); + }; + }, [containerRef.current]); + + // Handle style changes + useEffect(() => { + if (!mapRef.current || !isLoaded) return; + const styleUrl = typeof currentStyle.url === 'string' ? currentStyle.url : currentStyle.url; + mapRef.current.setStyle(styleUrl); + }, [styleKey, isLoaded]); // Resize map when shape dimensions change useEffect(() => { - resize(); - }, [shape.props.w, shape.props.h, resize]); - - // ========================================================================== - // Routing Layer Management - Waypoint Markers - // ========================================================================== + if (mapRef.current && isLoaded) { + mapRef.current.resize(); + } + }, [shape.props.w, shape.props.h, isLoaded]); + // Render waypoint markers useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; + if (!mapRef.current || !isLoaded) return; - // Get current waypoint IDs - const currentWaypointIds = new Set(shape.props.routing.waypoints.map((w) => w.id)); + const map = mapRef.current; + const currentIds = new Set(shape.props.waypoints.map((w: Waypoint) => w.id)); - // Remove markers that are no longer in waypoints + // Remove old markers markersRef.current.forEach((marker, id) => { - if (!currentWaypointIds.has(id) || !shape.props.routing.enabled) { + if (!currentIds.has(id)) { marker.remove(); markersRef.current.delete(id); } }); - // Add/update markers for current waypoints - if (shape.props.routing.enabled) { - shape.props.routing.waypoints.forEach((waypoint, index) => { - let marker = markersRef.current.get(waypoint.id); - - if (!marker) { - // Create marker element - const el = document.createElement('div'); - el.className = 'waypoint-marker'; - el.style.cssText = ` - width: 32px; - height: 32px; - background: ${waypoint.color || '#3b82f6'}; - border: 3px solid white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - font-weight: bold; - color: white; - box-shadow: 0 2px 8px rgba(0,0,0,0.4); - cursor: grab; - transition: transform 0.15s ease; - `; - el.textContent = String(index + 1); - el.title = waypoint.name || `Waypoint ${index + 1}`; - - // Hover effect - el.addEventListener('mouseenter', () => { - el.style.transform = 'scale(1.15)'; - }); - el.addEventListener('mouseleave', () => { - el.style.transform = 'scale(1)'; - }); - - // Create MapLibre marker - marker = new maplibregl.Marker({ - element: el, - draggable: true, - anchor: 'center', - }) - .setLngLat([waypoint.coordinate.lng, waypoint.coordinate.lat]) - .addTo(map); - - // Handle drag end - update waypoint position - marker.on('dragend', () => { - const lngLat = marker!.getLngLat(); - const updatedWaypoints = shape.props.routing.waypoints.map((wp) => - wp.id === waypoint.id - ? { ...wp, coordinate: { lat: lngLat.lat, lng: lngLat.lng } } - : wp - ); - - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - routing: { - ...shape.props.routing, - waypoints: updatedWaypoints, - }, - }, - }); - - // Recalculate route after drag - if (updatedWaypoints.length >= 2) { - setIsCalculatingRoute(true); - calculateRoute(updatedWaypoints); - } - }); - - markersRef.current.set(waypoint.id, marker); - } else { - // Update existing marker position - marker.setLngLat([waypoint.coordinate.lng, waypoint.coordinate.lat]); - // Update marker content (index may have changed) - const el = marker.getElement(); - if (el) { - el.textContent = String(index + 1); - el.title = waypoint.name || `Waypoint ${index + 1}`; - } - } - }); - } - - // Cleanup on unmount - return () => { - markersRef.current.forEach((marker) => marker.remove()); - markersRef.current.clear(); - }; - }, [shape.props.routing.enabled, shape.props.routing.waypoints, isLoaded, getMap, shape.id, editor, shape.props, calculateRoute]); - - // Add route polylines to the map - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; - - const sourceId = `route-source-${shape.id}`; - const layerId = `route-layer-${shape.id}`; - - // Clean up existing route layers - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - if (map.getSource(sourceId)) { - map.removeSource(sourceId); - } - - // Add routes if routing is enabled and we have routes - if (shape.props.routing.enabled && shape.props.routing.routes.length > 0) { - const activeRoute = shape.props.routing.activeRouteId - ? shape.props.routing.routes.find((r) => r.id === shape.props.routing.activeRouteId) - : shape.props.routing.routes[0]; - - if (activeRoute?.geometry) { - map.addSource(sourceId, { - type: 'geojson', - data: { - type: 'Feature', - properties: {}, - geometry: activeRoute.geometry, - }, - }); - - map.addLayer({ - id: layerId, - type: 'line', - source: sourceId, - layout: { - 'line-join': 'round', - 'line-cap': 'round', - }, - paint: { - 'line-color': MapShape.PRIMARY_COLOR, - 'line-width': 5, - 'line-opacity': 0.8, - }, - }); - } - } - - return () => { - // Cleanup on unmount - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - if (map.getSource(sourceId)) { - map.removeSource(sourceId); - } - }; - }, [shape.props.routing.enabled, shape.props.routing.routes, shape.props.routing.activeRouteId, isLoaded, getMap, shape.id]); - - // ========================================================================== - // Discovery Layer Management - Anchors, Spores, Hunts - // ========================================================================== - - // Get discovery config with safe defaults - const discoveryConfig = useMemo(() => ({ - ...DEFAULT_DISCOVERY, - ...shape.props.discovery, - anchors: shape.props.discovery?.anchors || [], - spores: shape.props.discovery?.spores || [], - hunts: shape.props.discovery?.hunts || [], - }), [shape.props.discovery]); - - // Anchor marker styling based on type and visibility - const getAnchorMarkerStyle = useCallback((anchor: DiscoveryAnchorMarker) => { - const baseColors: Record = { - physical: '#22c55e', // Green - nfc: '#3b82f6', // Blue - qr: '#8b5cf6', // Purple - ble: '#06b6d4', // Cyan - virtual: '#f59e0b', // Amber - temporal: '#ec4899', // Pink - social: '#10b981', // Emerald - }; - const color = baseColors[anchor.type] || '#6b7280'; - const opacity = anchor.visibility === 'hidden' ? 0.4 : anchor.visibility === 'hinted' ? 0.7 : 1; - const icon = anchor.discovered ? '✓' : anchor.type === 'nfc' ? '📡' : anchor.type === 'qr' ? '📱' : '📍'; - return { color, opacity, icon }; - }, []); - - // Spore marker styling based on type and strength - const getSporeMarkerStyle = useCallback((spore: SporeMarker) => { - const sporeColors: Record = { - explorer: '#22c55e', // Green - explores new areas - connector: '#3b82f6', // Blue - connects networks - nurturer: '#f59e0b', // Amber - strengthens network - guardian: '#ef4444', // Red - protects territory - catalyst: '#8b5cf6', // Purple - triggers events - }; - const color = sporeColors[spore.type] || '#6b7280'; - const size = 16 + (spore.strength / 100) * 12; // 16-28px based on strength - return { color, size }; - }, []); - - // Manage anchor markers - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; - - const anchors = discoveryConfig.anchors; - const showAnchors = discoveryConfig.enabled && discoveryConfig.showAnchors; - const currentAnchorIds = new Set(anchors.map(a => a.id)); - - // Remove markers that are no longer in anchors or if anchors are hidden - anchorMarkersRef.current.forEach((marker, id) => { - if (!currentAnchorIds.has(id) || !showAnchors) { - marker.remove(); - anchorMarkersRef.current.delete(id); - } - }); - - // Add/update anchor markers - if (showAnchors) { - anchors.forEach((anchor) => { - // Skip hidden anchors unless discovered - if (anchor.visibility === 'hidden' && !anchor.discovered) return; - - let marker = anchorMarkersRef.current.get(anchor.id); - const style = getAnchorMarkerStyle(anchor); - - if (!marker) { - const el = document.createElement('div'); - el.className = 'anchor-marker'; - el.style.cssText = ` - width: 36px; - height: 36px; - background: ${style.color}; - border: 3px solid white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - box-shadow: 0 3px 10px rgba(0,0,0,0.4); - cursor: pointer; - opacity: ${style.opacity}; - transition: transform 0.15s ease, opacity 0.2s ease; - `; - el.textContent = style.icon; - el.title = anchor.visibility === 'hidden' ? '???' : anchor.name; - - // Hover effect - el.addEventListener('mouseenter', () => { - el.style.transform = 'scale(1.2)'; - }); - el.addEventListener('mouseleave', () => { - el.style.transform = 'scale(1)'; - }); - - marker = new maplibregl.Marker({ - element: el, - anchor: 'center', - }) - .setLngLat([anchor.coordinate.lng, anchor.coordinate.lat]) - .addTo(map); - - anchorMarkersRef.current.set(anchor.id, marker); - } else { - // Update position if changed - marker.setLngLat([anchor.coordinate.lng, anchor.coordinate.lat]); - } - }); - } - - return () => { - anchorMarkersRef.current.forEach((marker) => marker.remove()); - anchorMarkersRef.current.clear(); - }; - }, [discoveryConfig, isLoaded, getMap, getAnchorMarkerStyle]); - - // Manage spore markers and mycelium network connections - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; - - const spores = discoveryConfig.spores; - const showSpores = discoveryConfig.enabled && discoveryConfig.showSpores; - const currentSporeIds = new Set(spores.map(s => s.id)); - - // Remove markers for spores that no longer exist - sporeMarkersRef.current.forEach((marker, id) => { - if (!currentSporeIds.has(id) || !showSpores) { - marker.remove(); - sporeMarkersRef.current.delete(id); - } - }); - - // Remove network layer if not showing - const networkLayerId = `spore-network-${shape.id}`; - const networkSourceId = `spore-network-source-${shape.id}`; - if (!showSpores && map.getLayer(networkLayerId)) { - map.removeLayer(networkLayerId); - map.removeSource(networkSourceId); - sporeConnectionsLayerId.current = null; - } - - if (showSpores && spores.length > 0) { - // Add/update spore markers - spores.forEach((spore) => { - let marker = sporeMarkersRef.current.get(spore.id); - const style = getSporeMarkerStyle(spore); - - if (!marker) { - const el = document.createElement('div'); - el.className = 'spore-marker'; - el.style.cssText = ` - width: ${style.size}px; - height: ${style.size}px; - background: radial-gradient(circle at 30% 30%, ${style.color}, ${style.color}88); - border: 2px solid ${style.color}; - border-radius: 50%; - box-shadow: 0 0 ${spore.strength / 10}px ${style.color}88; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; - animation: spore-pulse 3s ease-in-out infinite; - `; - el.title = `${spore.type} spore (${spore.strength}% strength)`; - - // Add pulsing animation - const keyframes = ` - @keyframes spore-pulse { - 0%, 100% { transform: scale(1); opacity: 0.9; } - 50% { transform: scale(1.1); opacity: 1; } - } - `; - if (!document.getElementById('spore-pulse-keyframes')) { - const style = document.createElement('style'); - style.id = 'spore-pulse-keyframes'; - style.textContent = keyframes; - document.head.appendChild(style); - } - - // Hover effect - el.addEventListener('mouseenter', () => { - el.style.transform = 'scale(1.3)'; - el.style.boxShadow = `0 0 ${spore.strength / 5}px ${style.color}`; - }); - el.addEventListener('mouseleave', () => { - el.style.transform = 'scale(1)'; - el.style.boxShadow = `0 0 ${spore.strength / 10}px ${style.color}88`; - }); - - marker = new maplibregl.Marker({ - element: el, - anchor: 'center', - }) - .setLngLat([spore.coordinate.lng, spore.coordinate.lat]) - .addTo(map); - - sporeMarkersRef.current.set(spore.id, marker); - } else { - marker.setLngLat([spore.coordinate.lng, spore.coordinate.lat]); - } - }); - - // Draw mycelium connections between spores - const connections: GeoJSON.Feature[] = []; - spores.forEach((spore) => { - spore.connections.forEach((connectedId) => { - const connectedSpore = spores.find(s => s.id === connectedId); - if (connectedSpore) { - connections.push({ - type: 'Feature', - properties: { strength: (spore.strength + connectedSpore.strength) / 2 }, - geometry: { - type: 'LineString', - coordinates: [ - [spore.coordinate.lng, spore.coordinate.lat], - [connectedSpore.coordinate.lng, connectedSpore.coordinate.lat], - ], - }, - }); - } - }); - }); - - if (connections.length > 0) { - // Update or add network layer - if (map.getSource(networkSourceId)) { - (map.getSource(networkSourceId) as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: connections, - }); - } else { - map.addSource(networkSourceId, { - type: 'geojson', - data: { - type: 'FeatureCollection', - features: connections, - }, - }); - - map.addLayer({ - id: networkLayerId, - type: 'line', - source: networkSourceId, - paint: { - 'line-color': '#22c55e', - 'line-width': 2, - 'line-opacity': 0.6, - 'line-dasharray': [2, 2], - }, - }); - sporeConnectionsLayerId.current = networkLayerId; - } - } - } - - return () => { - sporeMarkersRef.current.forEach((marker) => marker.remove()); - sporeMarkersRef.current.clear(); - if (map.getLayer(networkLayerId)) { - map.removeLayer(networkLayerId); - } - if (map.getSource(networkSourceId)) { - map.removeSource(networkSourceId); - } - }; - }, [discoveryConfig, isLoaded, getMap, shape.id, getSporeMarkerStyle]); - - // Manage treasure hunt markers - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; - - const hunts = discoveryConfig.hunts; - const showHunts = discoveryConfig.enabled && discoveryConfig.showHunts; - const activeHuntId = discoveryConfig.activeHuntId; - const currentHuntMarkerIds = new Set(hunts.map(h => h.id)); - - // Remove markers that no longer exist - huntMarkersRef.current.forEach((marker, id) => { - if (!currentHuntMarkerIds.has(id) || !showHunts) { - marker.remove(); - huntMarkersRef.current.delete(id); - } - }); - - if (showHunts) { - // Filter by active hunt if set - const visibleHunts = activeHuntId - ? hunts.filter(h => h.huntId === activeHuntId) - : hunts; - - visibleHunts.forEach((hunt) => { - let marker = huntMarkersRef.current.get(hunt.id); - - if (!marker) { - const el = document.createElement('div'); - el.className = 'hunt-marker'; - const isFound = hunt.found; - el.style.cssText = ` - width: 40px; - height: 40px; - background: ${isFound ? '#22c55e' : '#f59e0b'}; - border: 3px solid white; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - font-size: 16px; - font-weight: bold; - color: white; - box-shadow: 0 3px 10px rgba(0,0,0,0.4); - cursor: pointer; - transition: transform 0.15s ease; - `; - el.textContent = isFound ? '✓' : String(hunt.sequence); - el.title = isFound - ? `${hunt.huntName} #${hunt.sequence} (Found!)` - : `${hunt.huntName} #${hunt.sequence}${hunt.hint ? `: ${hunt.hint}` : ''}`; - - // Hover effect - el.addEventListener('mouseenter', () => { - el.style.transform = 'scale(1.15)'; - }); - el.addEventListener('mouseleave', () => { - el.style.transform = 'scale(1)'; - }); - - marker = new maplibregl.Marker({ - element: el, - anchor: 'center', - }) - .setLngLat([hunt.coordinate.lng, hunt.coordinate.lat]) - .addTo(map); - - huntMarkersRef.current.set(hunt.id, marker); - } else { - marker.setLngLat([hunt.coordinate.lng, hunt.coordinate.lat]); - } - }); - } - - return () => { - huntMarkersRef.current.forEach((marker) => marker.remove()); - huntMarkersRef.current.clear(); - }; - }, [discoveryConfig, isLoaded, getMap]); - - // ========================================================================== - // Presence System - Location Tracking - // ========================================================================== - - useEffect(() => { - // Start/stop location watching based on presence sharing state - if (shape.props.presence.enabled && shape.props.presence.isSharing) { - if (!navigator.geolocation) { - setLocationError('Geolocation not supported'); - return; - } - - // Start watching position - const watchId = navigator.geolocation.watchPosition( - (position) => { - const coord: Coordinate = { - lat: position.coords.latitude, - lng: position.coords.longitude, - alt: position.coords.altitude ?? undefined, - }; - setUserLocation(coord); - setLocationError(null); - }, - (error) => { - switch (error.code) { - case error.PERMISSION_DENIED: - setLocationError('Location permission denied'); - break; - case error.POSITION_UNAVAILABLE: - setLocationError('Location unavailable'); - break; - case error.TIMEOUT: - setLocationError('Location request timeout'); - break; - default: - setLocationError('Unknown location error'); - } - }, - { - enableHighAccuracy: shape.props.presence.privacyLevel === 'precise', - timeout: 10000, - maximumAge: shape.props.presence.privacyLevel === 'precise' ? 0 : 30000, - } - ); - - watchIdRef.current = watchId; - - return () => { - navigator.geolocation.clearWatch(watchId); - watchIdRef.current = null; - }; - } else { - // Clear watch when not sharing - if (watchIdRef.current !== null) { - navigator.geolocation.clearWatch(watchIdRef.current); - watchIdRef.current = null; - } - setUserLocation(null); - } - }, [shape.props.presence.enabled, shape.props.presence.isSharing, shape.props.presence.privacyLevel]); - - // User location marker on map - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded) return; - - // Remove marker if not sharing or no location - if (!shape.props.presence.enabled || !shape.props.presence.isSharing || !userLocation) { - if (userLocationMarkerRef.current) { - userLocationMarkerRef.current.remove(); - userLocationMarkerRef.current = null; - } - return; - } - - // Create or update user location marker - if (!userLocationMarkerRef.current) { - const el = document.createElement('div'); - el.className = 'user-location-marker'; - el.style.cssText = ` - width: 24px; - height: 24px; - background: #3b82f6; - border: 4px solid white; - border-radius: 50%; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4), 0 2px 8px rgba(0,0,0,0.3); - animation: pulse 2s ease-in-out infinite; - `; - - // Add pulse animation style if not exists - if (!document.getElementById('presence-marker-styles')) { - const style = document.createElement('style'); - style.id = 'presence-marker-styles'; - style.textContent = ` - @keyframes pulse { - 0%, 100% { box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4), 0 2px 8px rgba(0,0,0,0.3); } - 50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0.2), 0 2px 8px rgba(0,0,0,0.3); } - } - `; - document.head.appendChild(style); - } - - userLocationMarkerRef.current = new maplibregl.Marker({ - element: el, - anchor: 'center', - }) - .setLngLat([userLocation.lng, userLocation.lat]) - .addTo(map); - } else { - userLocationMarkerRef.current.setLngLat([userLocation.lng, userLocation.lat]); - } - - return () => { - if (userLocationMarkerRef.current) { - userLocationMarkerRef.current.remove(); - userLocationMarkerRef.current = null; - } - }; - }, [shape.props.presence.enabled, shape.props.presence.isSharing, userLocation, isLoaded, getMap]); - - // ========================================================================== - // Collaborative Presence - Sync location to sharedLocations (Automerge) - // ========================================================================== - - // Update sharedLocations when user's location changes - useEffect(() => { - if (!shape.props.presence.enabled || !shape.props.presence.isSharing || !userLocation) { - // Remove user's entry from sharedLocations when not sharing - if (shape.props.presence.sharedLocations[currentUser.userId]) { - const updatedLocations = { ...shape.props.presence.sharedLocations }; - delete updatedLocations[currentUser.userId]; - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - presence: { - ...shape.props.presence, - sharedLocations: updatedLocations, - }, - }, - }); - } - return; - } - - // Debounce location updates to avoid excessive syncs - const existingEntry = shape.props.presence.sharedLocations[currentUser.userId]; - const shouldUpdate = !existingEntry || - Math.abs(existingEntry.coordinate.lat - userLocation.lat) > 0.0001 || - Math.abs(existingEntry.coordinate.lng - userLocation.lng) > 0.0001 || - Date.now() - existingEntry.timestamp > 30000; // Update at least every 30s - - if (shouldUpdate) { - const newEntry: SharedLocation = { - userId: currentUser.userId, - userName: currentUser.userName, - color: currentUser.color, - coordinate: userLocation, - timestamp: Date.now(), - privacyLevel: shape.props.presence.privacyLevel, - }; - - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - presence: { - ...shape.props.presence, - sharedLocations: { - ...shape.props.presence.sharedLocations, - [currentUser.userId]: newEntry, - }, - }, - }, - }); - } - }, [ - shape.props.presence.enabled, - shape.props.presence.isSharing, - userLocation, - currentUser, - shape.id, - editor, - shape.props, - ]); - - // Render markers for other collaborators' shared locations - useEffect(() => { - const map = getMap(); - if (!map || !isLoaded || !shape.props.presence.enabled) { - // Clear all collaborator markers when not in presence mode - collaboratorMarkersRef.current.forEach((marker) => marker.remove()); - collaboratorMarkersRef.current.clear(); - return; - } - - const sharedLocations = shape.props.presence.sharedLocations || {}; - const currentLocationIds = new Set(Object.keys(sharedLocations)); - - // Remove markers for users who stopped sharing - collaboratorMarkersRef.current.forEach((marker, oduserId) => { - if (!currentLocationIds.has(oduserId) || oduserId === currentUser.userId) { - marker.remove(); - collaboratorMarkersRef.current.delete(oduserId); - } - }); - - // Add/update markers for other collaborators - Object.entries(sharedLocations).forEach(([oduserId, location]) => { - // Skip our own location (we render that separately with pulsing animation) - if (oduserId === currentUser.userId) return; - - // Skip stale locations (older than 5 minutes) - if (Date.now() - location.timestamp > 5 * 60 * 1000) return; - - let marker = collaboratorMarkersRef.current.get(oduserId); + // Add/update markers + shape.props.waypoints.forEach((waypoint: Waypoint, index: number) => { + let marker = markersRef.current.get(waypoint.id); if (!marker) { - // Create marker element for collaborator const el = document.createElement('div'); - el.className = 'collaborator-location-marker'; el.style.cssText = ` - width: 28px; - height: 28px; - background: ${location.color}; + width: 32px; + height: 32px; + background: #3b82f6; border: 3px solid white; border-radius: 50%; - box-shadow: 0 2px 8px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 14px; font-weight: bold; color: white; - cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + cursor: grab; `; - // Show first letter of username - el.textContent = location.userName.charAt(0).toUpperCase(); - el.title = `${location.userName} (${location.privacyLevel})`; + el.textContent = String(index + 1); + el.title = waypoint.name || `Waypoint ${index + 1}`; - marker = new maplibregl.Marker({ - element: el, - anchor: 'center', - }) - .setLngLat([location.coordinate.lng, location.coordinate.lat]) + marker = new maplibregl.Marker({ element: el, draggable: true, anchor: 'center' }) + .setLngLat([waypoint.coordinate.lng, waypoint.coordinate.lat]) .addTo(map); - collaboratorMarkersRef.current.set(oduserId, marker); + marker.on('dragend', () => { + const lngLat = marker!.getLngLat(); + updateWaypointPosition(waypoint.id, { lat: lngLat.lat, lng: lngLat.lng }); + }); + + markersRef.current.set(waypoint.id, marker); } else { - // Update existing marker position - marker.setLngLat([location.coordinate.lng, location.coordinate.lat]); - // Update tooltip + marker.setLngLat([waypoint.coordinate.lng, waypoint.coordinate.lat]); const el = marker.getElement(); if (el) { - el.title = `${location.userName} (${location.privacyLevel})`; + el.textContent = String(index + 1); + el.title = waypoint.name || `Waypoint ${index + 1}`; } } }); return () => { - // Cleanup on unmount - collaboratorMarkersRef.current.forEach((marker) => marker.remove()); - collaboratorMarkersRef.current.clear(); + markersRef.current.forEach((m) => m.remove()); + markersRef.current.clear(); }; - }, [shape.props.presence.enabled, shape.props.presence.sharedLocations, isLoaded, getMap, currentUser.userId]); + }, [shape.props.waypoints, isLoaded]); - const handleClose = useCallback(() => { - editor.deleteShape(shape.id); - }, [editor, shape.id]); + // Render route + useEffect(() => { + if (!mapRef.current || !isLoaded) return; - const handleMinimize = useCallback(() => { - setIsMinimized(!isMinimized); - }, [isMinimized]); + const map = mapRef.current; + const sourceId = `route-${shape.id}`; + const layerId = `route-line-${shape.id}`; + + // Remove existing + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + + // Add route if exists + if (shape.props.route?.geometry) { + map.addSource(sourceId, { + type: 'geojson', + data: { type: 'Feature', properties: {}, geometry: shape.props.route.geometry }, + }); + + map.addLayer({ + id: layerId, + type: 'line', + source: sourceId, + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': '#22c55e', 'line-width': 5, 'line-opacity': 0.8 }, + }); + } + + return () => { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + }; + }, [shape.props.route, isLoaded, shape.id]); + + // Render GeoJSON layers + useEffect(() => { + if (!mapRef.current || !isLoaded) return; + + const map = mapRef.current; + + shape.props.geoJsonLayers.forEach((layer: GeoJSONLayer) => { + const sourceId = `geojson-${layer.id}`; + const layerId = `geojson-layer-${layer.id}`; + + // Remove if not visible + if (!layer.visible) { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + return; + } + + // Add/update layer + if (map.getSource(sourceId)) { + (map.getSource(sourceId) as maplibregl.GeoJSONSource).setData(layer.data); + } else { + map.addSource(sourceId, { type: 'geojson', data: layer.data }); + map.addLayer({ + id: layerId, + type: 'circle', + source: sourceId, + paint: { + 'circle-radius': 8, + 'circle-color': ['get', 'color'], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + }, + }); + } + }); + }, [shape.props.geoJsonLayers, isLoaded]); + + // ========================================================================== + // Actions + // ========================================================================== + + const addWaypoint = useCallback((coord: Coordinate) => { + const newWaypoint: Waypoint = { + id: `wp-${Date.now()}`, + coordinate: coord, + name: `Waypoint ${shape.props.waypoints.length + 1}`, + }; + + const updatedWaypoints = [...shape.props.waypoints, newWaypoint]; - const handlePinToggle = useCallback(() => { editor.updateShape({ id: shape.id, type: 'Map', - props: { - ...shape.props, - pinnedToView: !shape.props.pinnedToView, - }, + props: { waypoints: updatedWaypoints }, }); - }, [editor, shape.id, shape.props]); - const handleInteractiveToggle = useCallback(() => { + // Auto-calculate route if 2+ waypoints + if (updatedWaypoints.length >= 2) { + calculateRoute(updatedWaypoints); + } + }, [shape.props.waypoints, shape.id, editor]); + + const updateWaypointPosition = useCallback((waypointId: string, coord: Coordinate) => { + const updatedWaypoints = shape.props.waypoints.map((wp: Waypoint) => + wp.id === waypointId ? { ...wp, coordinate: coord } : wp + ); + editor.updateShape({ id: shape.id, type: 'Map', - props: { - ...shape.props, - interactive: !shape.props.interactive, - }, + props: { waypoints: updatedWaypoints }, }); - }, [editor, shape.id, shape.props]); - const handlePresetSelect = useCallback( - (preset: (typeof LOCATION_PRESETS)[0]) => { - flyTo(preset.viewport.center, preset.viewport.zoom); - setShowPresets(false); - }, - [flyTo] - ); + if (updatedWaypoints.length >= 2) { + calculateRoute(updatedWaypoints); + } + }, [shape.props.waypoints, shape.id, editor]); - // ========================================================================== - // Subsystem Toggle Handlers - // ========================================================================== + const calculateRoute = useCallback(async (waypoints: Waypoint[]) => { + if (waypoints.length < 2) return; + + setIsCalculatingRoute(true); + setRouteError(null); + + try { + const coords = waypoints.map((wp) => `${wp.coordinate.lng},${wp.coordinate.lat}`).join(';'); + const url = `${OSRM_BASE_URL}/route/v1/driving/${coords}?overview=full&geometries=geojson`; + + const response = await fetch(url); + const data = await response.json(); + + if (data.code !== 'Ok' || !data.routes?.[0]) { + throw new Error(data.message || 'Route calculation failed'); + } + + const route = data.routes[0]; + const routeInfo: RouteInfo = { + distance: route.distance, + duration: route.duration, + geometry: route.geometry, + }; - const toggleSubsystem = useCallback( - (subsystem: 'presence' | 'lenses' | 'discovery' | 'routing' | 'conics') => { - const config = shape.props[subsystem]; editor.updateShape({ id: shape.id, type: 'Map', - props: { - ...shape.props, - [subsystem]: { - ...config, - enabled: !config.enabled, - }, - }, + props: { route: routeInfo }, }); - }, - [editor, shape.id, shape.props] - ); + } catch (err) { + console.error('Routing error:', err); + setRouteError(err instanceof Error ? err.message : 'Route calculation failed'); + } finally { + setIsCalculatingRoute(false); + } + }, [shape.id, editor]); - const togglePresenceSharing = useCallback(() => { + const clearRoute = useCallback(() => { editor.updateShape({ id: shape.id, type: 'Map', - props: { - ...shape.props, - presence: { - ...shape.props.presence, - isSharing: !shape.props.presence.isSharing, - }, - }, + props: { waypoints: [], route: null }, }); - }, [editor, shape.id, shape.props]); + }, [shape.id, editor]); - const setActiveLens = useCallback( - (lens: LensType) => { - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - lenses: { - ...shape.props.lenses, - activeLens: lens, - }, - }, - }); - }, - [editor, shape.id, shape.props] - ); + const searchLocation = useCallback(async () => { + if (!searchQuery.trim()) return; - // Computed state for UI - const activeSubsystems = useMemo(() => { - const active: string[] = []; - if (shape.props.presence.enabled) active.push('presence'); - if (shape.props.lenses.enabled) active.push('lenses'); - if (shape.props.discovery.enabled) active.push('discovery'); - if (shape.props.routing.enabled) active.push('routing'); - if (shape.props.conics.enabled) active.push('conics'); - return active; - }, [shape.props]); + setIsSearching(true); + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=5`, + { headers: { 'User-Agent': 'CanvasWebsite/1.0' } } + ); + const data = await response.json(); + setSearchResults( + data.map((r: { display_name: string; lat: string; lon: string }) => ({ + name: r.display_name, + lat: parseFloat(r.lat), + lng: parseFloat(r.lon), + })) + ); + } catch (err) { + console.error('Search error:', err); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }, [searchQuery]); - // Subsystem button style helper - const getSubsystemButtonStyle = useCallback( - (enabled: boolean) => ({ - padding: '3px 6px', - fontSize: '10px', - background: enabled ? MapShape.PRIMARY_COLOR : '#555', - color: 'white', - border: 'none', - borderRadius: '3px', - cursor: 'pointer', - opacity: enabled ? 1 : 0.7, - }), - [] - ); + const flyTo = useCallback((coord: Coordinate, zoom?: number) => { + mapRef.current?.flyTo({ + center: [coord.lng, coord.lat], + zoom: zoom ?? mapRef.current.getZoom(), + duration: 1000, + }); + }, []); - // Available lens types for dropdown - const LENS_OPTIONS: { type: LensType; label: string; icon: string }[] = [ - { type: 'geographic', label: 'Geographic', icon: '🗺️' }, - { type: 'temporal', label: 'Temporal', icon: '⏱️' }, - { type: 'attention', label: 'Attention', icon: '👁️' }, - { type: 'incentive', label: 'Incentive', icon: '💰' }, - { type: 'relational', label: 'Relational', icon: '🔗' }, - { type: 'possibility', label: 'Possibility', icon: '🌀' }, - ]; + const changeStyle = useCallback((key: StyleKey) => { + editor.updateShape({ + id: shape.id, + type: 'Map', + props: { styleKey: key }, + }); + setShowStyleMenu(false); + }, [shape.id, editor]); - // Header content with coordinates display and toolbar buttons - const headerContent = ( -
- {/* Primary row: coords + basic controls */} -
- 🗺️ - { + if (meters < 1000) return `${Math.round(meters)}m`; + return `${(meters / 1000).toFixed(1)}km`; + }; + + const formatDuration = (seconds: number) => { + const mins = Math.round(seconds / 60); + if (mins < 60) return `${mins} min`; + const hrs = Math.floor(mins / 60); + const remainingMins = mins % 60; + return `${hrs}h ${remainingMins}m`; + }; + + // ========================================================================== + // Render + // ========================================================================== + + return ( + +
+ {/* Map container */} +
+ + {/* Top toolbar */} +
- {isLoaded - ? `${viewport.center.lat.toFixed(4)}, ${viewport.center.lng.toFixed(4)} z${viewport.zoom.toFixed(1)}` - : 'Loading...'} - + {/* Search */} +
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && searchLocation()} + onFocus={() => setShowSearch(true)} + style={{ + flex: 1, + padding: '8px 12px', + border: 'none', + borderRadius: '6px', + fontSize: '13px', + background: 'white', + boxShadow: '0 2px 6px rgba(0,0,0,0.15)', + }} + /> + +
- {/* Active subsystem indicators */} - {activeSubsystems.length > 0 && ( -
- {shape.props.presence.enabled && 👥} - {shape.props.lenses.enabled && 🔮} - {shape.props.discovery.enabled && 🎮} - {shape.props.routing.enabled && 🛣️} - {shape.props.conics.enabled && 📐} + {/* Search results dropdown */} + {showSearch && searchResults.length > 0 && ( +
+ {searchResults.map((result, i) => ( +
{ + flyTo({ lat: result.lat, lng: result.lng }, 14); + setShowSearch(false); + setSearchQuery(''); + setSearchResults([]); + }} + style={{ + padding: '10px 12px', + cursor: 'pointer', + borderBottom: '1px solid #eee', + fontSize: '12px', + }} + onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')} + onMouseLeave={(e) => (e.currentTarget.style.background = 'white')} + > + {result.name.length > 60 ? result.name.slice(0, 60) + '...' : result.name} +
+ ))} +
+ )} +
+ + {/* Style selector */} +
+ + + {showStyleMenu && ( +
+ {Object.entries(MAP_STYLES).map(([key, style]) => ( +
changeStyle(key as StyleKey)} + style={{ + padding: '10px 12px', + cursor: 'pointer', + fontSize: '13px', + background: key === styleKey ? '#f3f4f6' : 'white', + }} + onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')} + onMouseLeave={(e) => (e.currentTarget.style.background = key === styleKey ? '#f3f4f6' : 'white')} + > + {style.icon} {style.name} +
+ ))} +
+ )} +
+
+ + {/* Route info panel */} + {(shape.props.waypoints.length > 0 || shape.props.route) && ( +
+
+ 🚗 Route + +
+ +
+ {shape.props.waypoints.length} waypoint{shape.props.waypoints.length !== 1 ? 's' : ''} + {isCalculatingRoute && ' • Calculating...'} +
+ + {shape.props.route && ( +
+ 📏 {formatDistance(shape.props.route.distance)} + ⏱️ {formatDuration(shape.props.route.duration)} +
+ )} + + {routeError && ( +
+ ⚠️ {routeError} +
+ )} + +
+ Shift+click to add waypoints +
)} - {/* Interactive toggle */} - - - {/* Subsystem panel toggle */} - - - {/* Location presets */} -
- - {showPresets && ( -
e.stopPropagation()} - > - {LOCATION_PRESETS.map((preset) => ( - - ))} -
- )} -
-
- - {/* Subsystem controls panel (expandable) */} - {showSubsystemPanel && ( + {/* Zoom controls */}
e.stopPropagation()} - > - {/* Presence toggle + share button */} - - {shape.props.presence.enabled && ( - - )} - - {/* Lenses toggle + lens selector */} - - {shape.props.lenses.enabled && ( -
- - {showLensMenu && ( -
- {LENS_OPTIONS.map((lens) => ( - - ))} -
- )} -
- )} - - {/* Discovery toggle */} - - - {/* Routing toggle */} - - - {/* Conics toggle */} - -
- )} -
- ); - - return ( - - { - editor.updateShape({ - id: shape.id, - type: 'Map', - props: { - ...shape.props, - tags: newTags, - }, - }); - }} - tagsEditable={true} - > -
{ - if (shape.props.interactive) { - e.stopPropagation(); - } - }} - onWheel={(e) => { - if (shape.props.interactive) { - e.stopPropagation(); - } + flexDirection: 'column', + gap: 4, + pointerEvents: 'auto', }} > - {error && ( -
- Failed to load map: {error.message} -
- )} - {!isLoaded && !error && ( -
- Loading map... -
- )} - - {/* Routing Panel - Shows when routing is enabled */} - {isLoaded && shape.props.routing.enabled && ( -
e.stopPropagation()} - > -
- 🛣️ - Route Planning -
-
-
- Waypoints: - {shape.props.routing.waypoints.length} -
-
- Routes: - {isCalculatingRoute ? '...' : shape.props.routing.routes.length} -
- {shape.props.routing.routes.length > 0 && shape.props.routing.routes[0]?.summary && ( - <> -
-
- Distance: - {(shape.props.routing.routes[0].summary.distance / 1000).toFixed(1)} km -
-
- Duration: - {Math.round(shape.props.routing.routes[0].summary.duration / 60)} min -
-
- - )} -
- - {/* Route calculation status */} - {isCalculatingRoute && ( -
- ⏳ Calculating route... -
- )} - - {/* Clear route button */} - {shape.props.routing.waypoints.length > 0 && ( - - )} - -
- Click map to add waypoints • Drag to reorder -
-
- )} - - {/* Presence Panel - Shows when presence is enabled */} - {isLoaded && shape.props.presence.enabled && ( -
e.stopPropagation()} - > -
- 👥 - Location Presence -
-
-
- Status: - - {shape.props.presence.isSharing ? '● Sharing' : '○ Not sharing'} - -
-
- Privacy: - {shape.props.presence.privacyLevel} -
- - {/* Show collaborator count */} - {(() => { - const collaboratorCount = Object.keys(shape.props.presence.sharedLocations || {}) - .filter(id => id !== currentUser.userId && Date.now() - (shape.props.presence.sharedLocations[id]?.timestamp || 0) < 5 * 60 * 1000) - .length; - return collaboratorCount > 0 ? ( -
- Online: - {collaboratorCount} collaborator{collaboratorCount !== 1 ? 's' : ''} -
- ) : null; - })()} - - {/* Show location when sharing */} - {shape.props.presence.isSharing && userLocation && ( -
-
- {userLocation.lat.toFixed(4)}, {userLocation.lng.toFixed(4)} -
-
- )} - - {/* Show error if any */} - {locationError && ( -
- ⚠️ {locationError} -
- )} - - {/* Share/Stop button */} - - - {/* Go to my location button */} - {userLocation && ( - - )} -
-
- zkGPS privacy-preserving -
-
- )} - - {/* Discovery Panel - Shows when discovery is enabled */} - {isLoaded && shape.props.discovery.enabled && ( -
e.stopPropagation()} - > -
- 🎮 - Discovery -
-
- - - -
- - {/* Stats display */} -
- 📍 {discoveryConfig.anchors.length} - 🍄 {discoveryConfig.spores.length} - 🏆 {discoveryConfig.hunts.length} -
- - {/* Demo buttons */} -
- - - - - - - {/* Clear all button */} - {(discoveryConfig.anchors.length > 0 || discoveryConfig.spores.length > 0 || discoveryConfig.hunts.length > 0) && ( - - )} -
-
- )} - - {/* Lens Indicator - Shows active lens when not geographic */} - {isLoaded && shape.props.lenses.enabled && shape.props.lenses.activeLens !== 'geographic' && ( -
- 🔮 - {shape.props.lenses.activeLens} Lens -
- )} + +
-
+ + {/* Click outside to close menus */} + {(showStyleMenu || showSearch) && ( +
{ + setShowStyleMenu(false); + setShowSearch(false); + }} + /> + )} +
); } diff --git a/src/ui/components.tsx b/src/ui/components.tsx index 0fb9cd8..975e2b6 100644 --- a/src/ui/components.tsx +++ b/src/ui/components.tsx @@ -406,6 +406,13 @@ function CustomSharePanel() { const actions = useActions() const [showShortcuts, setShowShortcuts] = React.useState(false) + // Helper to extract label string from tldraw label (can be string or {default, menu} object) + const getLabelString = (label: any, fallback: string): string => { + if (typeof label === 'string') return label + if (label && typeof label === 'object' && 'default' in label) return label.default + return fallback + } + // Collect all tools and actions with keyboard shortcuts const allShortcuts = React.useMemo(() => { const shortcuts: { name: string; kbd: string; category: string }[] = [] @@ -416,7 +423,7 @@ function CustomSharePanel() { const tool = tools[toolId] if (tool?.kbd) { shortcuts.push({ - name: tool.label || toolId, + name: getLabelString(tool.label, toolId), kbd: tool.kbd, category: 'Tools' }) @@ -429,7 +436,7 @@ function CustomSharePanel() { const tool = tools[toolId] if (tool?.kbd) { shortcuts.push({ - name: tool.label || toolId, + name: getLabelString(tool.label, toolId), kbd: tool.kbd, category: 'Custom Tools' }) @@ -442,7 +449,7 @@ function CustomSharePanel() { const action = actions[actionId] if (action?.kbd) { shortcuts.push({ - name: action.label || actionId, + name: getLabelString(action.label, actionId), kbd: action.kbd, category: 'Actions' }) @@ -455,7 +462,7 @@ function CustomSharePanel() { const action = actions[actionId] if (action?.kbd) { shortcuts.push({ - name: action.label || actionId, + name: getLabelString(action.label, actionId), kbd: action.kbd, category: 'Custom Actions' }) @@ -595,7 +602,7 @@ function CustomSharePanel() { }} > - {typeof shortcut.name === 'string' ? shortcut.name.replace('tool.', '').replace('action.', '') : shortcut.name} + {shortcut.name.replace('tool.', '').replace('action.', '')}