chore: add D1 database ID and refactor MapShape
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
e9fef27f82
commit
a1bef4174a
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
|
|
@ -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';
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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() {
|
|||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text)' }}>
|
||||
{typeof shortcut.name === 'string' ? shortcut.name.replace('tool.', '').replace('action.', '') : shortcut.name}
|
||||
{shortcut.name.replace('tool.', '').replace('action.', '')}
|
||||
</span>
|
||||
<kbd style={{
|
||||
background: 'var(--color-muted-2)',
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ bucket_name = 'board-backups'
|
|||
[[d1_databases]]
|
||||
binding = "CRYPTID_DB"
|
||||
database_name = "cryptid-auth"
|
||||
database_id = "placeholder-will-be-created"
|
||||
database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
|
|
|
|||
Loading…
Reference in New Issue