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:
Jeff Emmett 2025-12-04 21:32:46 -08:00
parent e2a9f3ba54
commit e778e20bae
8 changed files with 1280 additions and 2032 deletions

View File

@ -1,4 +1,4 @@
export { useMapInstance } from './useMapInstance'; export { useMapInstance, MAP_STYLES } 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';

View File

@ -43,27 +43,113 @@ const DEFAULT_VIEWPORT: MapViewport = {
pitch: 0, pitch: 0,
}; };
// Default style using OpenStreetMap tiles via MapLibre // Available map styles - all free, no API key required
const DEFAULT_STYLE: maplibregl.StyleSpecification = { export const MAP_STYLES = {
version: 8, // Carto Voyager - clean, modern look (default)
sources: { voyager: {
'osm-raster': { name: 'Voyager',
type: 'raster', url: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], icon: '🗺️',
tileSize: 256, maxZoom: 20,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
}, },
layers: [ // Carto Positron - light, minimal
{ positron: {
id: 'osm-raster-layer', name: 'Light',
type: 'raster', url: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
source: 'osm-raster', icon: '☀️',
minzoom: 0, maxZoom: 20,
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: '&copy; 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: '&copy; Protomaps &copy; 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: '&copy; 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,
@ -103,7 +189,7 @@ export function useMapInstance({
pitch: initialViewport.pitch, pitch: initialViewport.pitch,
interactive, interactive,
attributionControl: false, attributionControl: false,
maxZoom: config.maxZoom ?? 19, maxZoom: config.maxZoom ?? 22,
}); });
mapRef.current = map; mapRef.current = map;

View File

@ -56,3 +56,6 @@ 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';

View File

@ -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;

View File

@ -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

View File

@ -406,6 +406,13 @@ 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 }[] = []
@ -416,7 +423,7 @@ function CustomSharePanel() {
const tool = tools[toolId] const tool = tools[toolId]
if (tool?.kbd) { if (tool?.kbd) {
shortcuts.push({ shortcuts.push({
name: tool.label || toolId, name: getLabelString(tool.label, toolId),
kbd: tool.kbd, kbd: tool.kbd,
category: 'Tools' category: 'Tools'
}) })
@ -429,7 +436,7 @@ function CustomSharePanel() {
const tool = tools[toolId] const tool = tools[toolId]
if (tool?.kbd) { if (tool?.kbd) {
shortcuts.push({ shortcuts.push({
name: tool.label || toolId, name: getLabelString(tool.label, toolId),
kbd: tool.kbd, kbd: tool.kbd,
category: 'Custom Tools' category: 'Custom Tools'
}) })
@ -442,7 +449,7 @@ function CustomSharePanel() {
const action = actions[actionId] const action = actions[actionId]
if (action?.kbd) { if (action?.kbd) {
shortcuts.push({ shortcuts.push({
name: action.label || actionId, name: getLabelString(action.label, actionId),
kbd: action.kbd, kbd: action.kbd,
category: 'Actions' category: 'Actions'
}) })
@ -455,7 +462,7 @@ function CustomSharePanel() {
const action = actions[actionId] const action = actions[actionId]
if (action?.kbd) { if (action?.kbd) {
shortcuts.push({ shortcuts.push({
name: action.label || actionId, name: getLabelString(action.label, actionId),
kbd: action.kbd, kbd: action.kbd,
category: 'Custom Actions' category: 'Custom Actions'
}) })
@ -595,7 +602,7 @@ function CustomSharePanel() {
}} }}
> >
<span style={{ color: 'var(--color-text)' }}> <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> </span>
<kbd style={{ <kbd style={{
background: 'var(--color-muted-2)', background: 'var(--color-muted-2)',

View File

@ -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 = "placeholder-will-be-created" database_id = "35fbe755-0e7c-4b9a-a454-34f945e5f7cc"
[observability] [observability]
enabled = true enabled = true