Compare commits
No commits in common. "e778e20bae255569b2ecdea13fc688e42a4dd7c3" and "9b06bfadb379a58fd2a9dc6adda258e6df1dab6b" have entirely different histories.
e778e20bae
...
9b06bfadb3
|
|
@ -4,7 +4,7 @@ title: 'Open Mapping: Collaborative Route Planning Module'
|
||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2025-12-04 14:30'
|
created_date: '2025-12-04 14:30'
|
||||||
updated_date: '2025-12-05 05:29'
|
updated_date: '2025-12-05 03:45'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- mapping
|
- mapping
|
||||||
|
|
@ -25,9 +25,9 @@ Implement an open-source mapping and routing layer for the canvas that provides
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [x] #1 MapLibre GL JS integrated with tldraw canvas
|
- [ ] #1 MapLibre GL JS integrated with tldraw canvas
|
||||||
- [x] #2 OSRM routing backend deployed to Netcup
|
- [ ] #2 OSRM routing backend deployed to Netcup
|
||||||
- [x] #3 Waypoint placement and route calculation working
|
- [ ] #3 Waypoint placement and route calculation working
|
||||||
- [ ] #4 Multi-route comparison UI implemented
|
- [ ] #4 Multi-route comparison UI implemented
|
||||||
- [ ] #5 Y.js collaboration for shared route editing
|
- [ ] #5 Y.js collaboration for shared route editing
|
||||||
- [ ] #6 Layer management panel with basemap switching
|
- [ ] #6 Layer management panel with basemap switching
|
||||||
|
|
@ -90,32 +90,4 @@ Pushed to feature/open-mapping branch:
|
||||||
- Mycelium network visualization
|
- Mycelium network visualization
|
||||||
- Discovery system (spores, hunts, collectibles)
|
- Discovery system (spores, hunts, collectibles)
|
||||||
- Privacy system with ZK-GPS protocol concepts
|
- Privacy system with ZK-GPS protocol concepts
|
||||||
|
|
||||||
**Merged to dev branch (2025-12-05):**
|
|
||||||
- All subsystem TypeScript implementations merged
|
|
||||||
- MapShapeUtil integrated with canvas
|
|
||||||
- ConnectionStatusIndicator added
|
|
||||||
- Merged with PrivateWorkspace feature (no conflicts)
|
|
||||||
- Ready for staging/production testing
|
|
||||||
|
|
||||||
**Remaining work:**
|
|
||||||
- MapLibre GL JS full canvas integration
|
|
||||||
- OSRM backend deployment to Netcup
|
|
||||||
- UI polish and testing
|
|
||||||
|
|
||||||
**OSRM Backend Deployed (2025-12-05):**
|
|
||||||
- Docker container running on Netcup RS 8000
|
|
||||||
- Location: /opt/apps/osrm-routing/
|
|
||||||
- Public URL: https://routing.jeffemmett.com
|
|
||||||
- Uses Traefik for routing via Docker network
|
|
||||||
- Currently loaded with Monaco OSM data (for testing)
|
|
||||||
- MapShapeUtil updated to use self-hosted OSRM
|
|
||||||
- Verified working: curl returns valid route responses
|
|
||||||
|
|
||||||
Map refactoring completed:
|
|
||||||
- Created simplified MapShapeUtil.tsx (836 lines) with MapLibre + search + routing
|
|
||||||
- Created GPSCollaborationLayer.ts as standalone module for GPS sharing
|
|
||||||
- Added layers/index.ts and updated open-mapping exports
|
|
||||||
- Server running without compilation errors
|
|
||||||
- Architecture now follows layer pattern: Base Map → Collaboration Layers
|
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ title: Implement proper Automerge CRDT sync for offline-first support
|
||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2025-12-04 21:06'
|
created_date: '2025-12-04 21:06'
|
||||||
updated_date: '2025-12-05 03:53'
|
updated_date: '2025-12-05 03:42'
|
||||||
labels:
|
labels:
|
||||||
- offline-sync
|
- offline-sync
|
||||||
- crdt
|
- crdt
|
||||||
|
|
@ -57,10 +57,4 @@ Solution: Use Automerge's native binary sync protocol with proper CRDT merge sem
|
||||||
- JSON sync fallback works for backward compatibility
|
- JSON sync fallback works for backward compatibility
|
||||||
- Binary sync infrastructure is in place
|
- Binary sync infrastructure is in place
|
||||||
- Needs production testing with multi-client sync and delete operations
|
- Needs production testing with multi-client sync and delete operations
|
||||||
|
|
||||||
**Merged to dev branch (2025-12-05):**
|
|
||||||
- All Automerge CRDT infrastructure merged
|
|
||||||
- WASM initialization, sync manager, R2 storage
|
|
||||||
- Integration fixes for getDocument(), handleBinaryMessage(), schedulePersistToR2()
|
|
||||||
- Ready for production testing
|
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ title: zkGPS Location Games and Discovery System
|
||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2025-12-05 00:49'
|
created_date: '2025-12-05 00:49'
|
||||||
updated_date: '2025-12-05 03:52'
|
updated_date: '2025-12-05 01:41'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- open-mapping
|
- open-mapping
|
||||||
|
|
@ -99,16 +99,4 @@ Moving to In Progress - core TypeScript implementation complete, still needs:
|
||||||
- Real IoT hardware testing (NFC/BLE)
|
- Real IoT hardware testing (NFC/BLE)
|
||||||
- Backend persistence layer
|
- Backend persistence layer
|
||||||
- Multiplayer sync via Automerge
|
- Multiplayer sync via Automerge
|
||||||
|
|
||||||
**Merged to dev branch (2025-12-05):**
|
|
||||||
- Complete discovery game system TypeScript merged
|
|
||||||
- Anchor, collectible, spore, and hunt systems in place
|
|
||||||
- All type definitions and core logic implemented
|
|
||||||
|
|
||||||
**Still needs for production:**
|
|
||||||
- React UI components for discovery/hunt interfaces
|
|
||||||
- Canvas map visualization integration
|
|
||||||
- IoT hardware testing (NFC/BLE)
|
|
||||||
- Backend persistence layer
|
|
||||||
- Multiplayer sync testing
|
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { useMapInstance, MAP_STYLES } from './useMapInstance';
|
export { useMapInstance } from './useMapInstance';
|
||||||
export { useRouting } from './useRouting';
|
export { useRouting } from './useRouting';
|
||||||
export { useCollaboration } from './useCollaboration';
|
export { useCollaboration } from './useCollaboration';
|
||||||
export { useLayers } from './useLayers';
|
export { useLayers } from './useLayers';
|
||||||
|
|
|
||||||
|
|
@ -43,113 +43,27 @@ const DEFAULT_VIEWPORT: MapViewport = {
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Available map styles - all free, no API key required
|
// Default style using OpenStreetMap tiles via MapLibre
|
||||||
export const MAP_STYLES = {
|
const DEFAULT_STYLE: maplibregl.StyleSpecification = {
|
||||||
// Carto Voyager - clean, modern look (default)
|
version: 8,
|
||||||
voyager: {
|
sources: {
|
||||||
name: 'Voyager',
|
'osm-raster': {
|
||||||
url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
|
type: 'raster',
|
||||||
icon: '🗺️',
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||||
maxZoom: 20,
|
tileSize: 256,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Carto Positron - light, minimal
|
layers: [
|
||||||
positron: {
|
{
|
||||||
name: 'Light',
|
id: 'osm-raster-layer',
|
||||||
url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
|
type: 'raster',
|
||||||
icon: '☀️',
|
source: 'osm-raster',
|
||||||
maxZoom: 20,
|
minzoom: 0,
|
||||||
},
|
maxzoom: 19,
|
||||||
// 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({
|
export function useMapInstance({
|
||||||
container,
|
container,
|
||||||
|
|
@ -189,7 +103,7 @@ export function useMapInstance({
|
||||||
pitch: initialViewport.pitch,
|
pitch: initialViewport.pitch,
|
||||||
interactive,
|
interactive,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
maxZoom: config.maxZoom ?? 22,
|
maxZoom: config.maxZoom ?? 19,
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,3 @@ export * as discovery from './discovery';
|
||||||
|
|
||||||
// Real-Time Location Presence with Privacy Controls
|
// Real-Time Location Presence with Privacy Controls
|
||||||
export * as presence from './presence';
|
export * as presence from './presence';
|
||||||
|
|
||||||
// Reusable Map Layers (GPS, Collaboration, etc.)
|
|
||||||
export * as layers from './layers';
|
|
||||||
|
|
|
||||||
|
|
@ -1,492 +0,0 @@
|
||||||
/**
|
|
||||||
* 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<MarkerStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkerStyle {
|
|
||||||
size: number;
|
|
||||||
borderWidth: number;
|
|
||||||
showAccuracy: boolean;
|
|
||||||
showHeading: boolean;
|
|
||||||
pulseAnimation: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_OPTIONS: Required<GPSLayerOptions> = {
|
|
||||||
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<GPSLayerOptions>;
|
|
||||||
private markers: Map<string, maplibregl.Marker> = new Map();
|
|
||||||
private accuracyCircles: Map<string, string> = new Map(); // layerId
|
|
||||||
private watchId: number | null = null;
|
|
||||||
private currentUser: GPSUser | null = null;
|
|
||||||
private peers: Map<string, GPSUser> = 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<GPSUser, 'userId' | 'userName' | 'color'>): Promise<GPSUser> {
|
|
||||||
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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* 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';
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -406,13 +406,6 @@ function CustomSharePanel() {
|
||||||
const actions = useActions()
|
const actions = useActions()
|
||||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
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
|
// Collect all tools and actions with keyboard shortcuts
|
||||||
const allShortcuts = React.useMemo(() => {
|
const allShortcuts = React.useMemo(() => {
|
||||||
const shortcuts: { name: string; kbd: string; category: string }[] = []
|
const shortcuts: { name: string; kbd: string; category: string }[] = []
|
||||||
|
|
@ -423,7 +416,7 @@ function CustomSharePanel() {
|
||||||
const tool = tools[toolId]
|
const tool = tools[toolId]
|
||||||
if (tool?.kbd) {
|
if (tool?.kbd) {
|
||||||
shortcuts.push({
|
shortcuts.push({
|
||||||
name: getLabelString(tool.label, toolId),
|
name: tool.label || toolId,
|
||||||
kbd: tool.kbd,
|
kbd: tool.kbd,
|
||||||
category: 'Tools'
|
category: 'Tools'
|
||||||
})
|
})
|
||||||
|
|
@ -436,7 +429,7 @@ function CustomSharePanel() {
|
||||||
const tool = tools[toolId]
|
const tool = tools[toolId]
|
||||||
if (tool?.kbd) {
|
if (tool?.kbd) {
|
||||||
shortcuts.push({
|
shortcuts.push({
|
||||||
name: getLabelString(tool.label, toolId),
|
name: tool.label || toolId,
|
||||||
kbd: tool.kbd,
|
kbd: tool.kbd,
|
||||||
category: 'Custom Tools'
|
category: 'Custom Tools'
|
||||||
})
|
})
|
||||||
|
|
@ -449,7 +442,7 @@ function CustomSharePanel() {
|
||||||
const action = actions[actionId]
|
const action = actions[actionId]
|
||||||
if (action?.kbd) {
|
if (action?.kbd) {
|
||||||
shortcuts.push({
|
shortcuts.push({
|
||||||
name: getLabelString(action.label, actionId),
|
name: action.label || actionId,
|
||||||
kbd: action.kbd,
|
kbd: action.kbd,
|
||||||
category: 'Actions'
|
category: 'Actions'
|
||||||
})
|
})
|
||||||
|
|
@ -462,7 +455,7 @@ function CustomSharePanel() {
|
||||||
const action = actions[actionId]
|
const action = actions[actionId]
|
||||||
if (action?.kbd) {
|
if (action?.kbd) {
|
||||||
shortcuts.push({
|
shortcuts.push({
|
||||||
name: getLabelString(action.label, actionId),
|
name: action.label || actionId,
|
||||||
kbd: action.kbd,
|
kbd: action.kbd,
|
||||||
category: 'Custom Actions'
|
category: 'Custom Actions'
|
||||||
})
|
})
|
||||||
|
|
@ -602,7 +595,7 @@ function CustomSharePanel() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ color: 'var(--color-text)' }}>
|
<span style={{ color: 'var(--color-text)' }}>
|
||||||
{shortcut.name.replace('tool.', '').replace('action.', '')}
|
{typeof shortcut.name === 'string' ? shortcut.name.replace('tool.', '').replace('action.', '') : shortcut.name}
|
||||||
</span>
|
</span>
|
||||||
<kbd style={{
|
<kbd style={{
|
||||||
background: 'var(--color-muted-2)',
|
background: 'var(--color-muted-2)',
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ bucket_name = 'board-backups'
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "CRYPTID_DB"
|
binding = "CRYPTID_DB"
|
||||||
database_name = "cryptid-auth"
|
database_name = "cryptid-auth"
|
||||||
database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
|
database_id = "placeholder-will-be-created"
|
||||||
|
|
||||||
[observability]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue