import { demoFunc } from './dees-geo-map.demo.js'; import { customElement, html, DeesElement, property, state, type TemplateResult, cssManager, css, } from '@design.estate/dees-element'; import { DeesContextmenu } from '@design.estate/dees-catalog'; import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles } from '../../00componentstyles.js'; // MapLibre imports import maplibregl from 'maplibre-gl'; // Terra Draw imports import { TerraDraw, TerraDrawPolygonMode, TerraDrawRectangleMode, TerraDrawPointMode, TerraDrawLineStringMode, TerraDrawCircleMode, TerraDrawFreehandMode, TerraDrawSelectMode, TerraDrawRenderMode, } from 'terra-draw'; import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter'; // Modular imports import { renderIcon } from './geo-map.icons.js'; import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js'; import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js'; // Re-export types for external consumers export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js'; export type { TNavigationMode, INavigationState, IOSRMRoute, IOSRMLeg, IOSRMStep, IRouteCalculatedEvent, } from './geo-map.navigation.js'; export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static'; export interface IDrawChangeEvent { ids: string[]; type: string; features: GeoJSON.Feature[]; } export interface IDrawFinishEvent { id: string; context: { action: string; mode: string }; features: GeoJSON.Feature[]; } export interface IDrawSelectEvent { id: string; } declare global { interface HTMLElementTagNameMap { 'dees-geo-map': DeesGeoMap; } } @customElement('dees-geo-map') export class DeesGeoMap extends DeesElement { public static demo = demoFunc; // ─── Properties ───────────────────────────────────────────────────────────── @property({ type: Array }) accessor center: [number, number] = [0, 0]; // [lng, lat] @property({ type: Number }) accessor zoom: number = 2; @property({ type: String }) accessor mapStyle: string = 'osm'; @property({ type: String }) accessor activeTool: TDrawTool = 'static'; @property({ type: Object }) accessor geoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [], }; @property({ type: Boolean }) accessor showToolbar: boolean = true; @property({ type: Boolean }) accessor dragToDraw: boolean = true; // Default to drag behavior for circle/rectangle @property({ type: String }) accessor projection: 'mercator' | 'globe' = 'globe'; @property({ type: Boolean }) accessor showSearch: boolean = false; @property({ type: String }) accessor searchPlaceholder: string = 'Search address...'; @property({ type: Boolean }) accessor showNavigation: boolean = false; @property({ type: String }) accessor navigationMode: TNavigationMode = 'driving'; // ─── State ────────────────────────────────────────────────────────────────── @state() private accessor map: maplibregl.Map | null = null; @state() private accessor draw: TerraDraw | null = null; @state() private accessor isMapReady: boolean = false; // Controllers private searchController: SearchController | null = null; private navigationController: NavigationController | null = null; // ─── Styles ───────────────────────────────────────────────────────────────── public static styles = [ cssManager.defaultStyles, geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, css` :host { display: block; width: 100%; height: 400px; } .maplibregl-map { width: 100%; height: 100%; font-family: inherit; } .maplibregl-ctrl-attrib { font-size: 11px; background: rgba(0, 0, 0, 0.5); color: rgba(255, 255, 255, 0.8); padding: 2px 6px; border-radius: 4px; } .maplibregl-ctrl-attrib a { color: rgba(255, 255, 255, 0.8); } .toolbar { user-select: none; } .toolbar-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255, 255, 255, 0.5); padding: 0 4px 4px; } .tool-button { position: relative; } .tool-button::after { content: attr(title); position: absolute; left: calc(100% + 8px); top: 50%; transform: translateY(-50%); padding: 4px 8px; background: rgba(0, 0, 0, 0.8); color: #fff; font-size: 12px; white-space: nowrap; border-radius: 4px; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 0.15s ease, visibility 0.15s ease; } .tool-button:hover::after { opacity: 1; visibility: visible; } .feature-count { position: absolute; bottom: 12px; left: 12px; z-index: 10; padding: 6px 12px; background: rgba(30, 30, 30, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 6px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); font-size: 12px; color: rgba(255, 255, 255, 0.7); } .zoom-controls { position: absolute; bottom: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; gap: 4px; padding: 4px; background: rgba(30, 30, 30, 0.9); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } `, ]; // ─── Lifecycle ────────────────────────────────────────────────────────────── public async firstUpdated() { this.initializeControllers(); await this.initializeMap(); } public async disconnectedCallback() { await super.disconnectedCallback(); this.cleanup(); } public updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('dragToDraw') && this.draw && this.map) { // Reinitialize terra-draw with new settings const currentFeatures = this.draw.getSnapshot(); this.draw.stop(); this.draw = null; this.initializeTerraDraw(); // Restore features if (currentFeatures.length > 0) { for (const feature of currentFeatures) { this.draw?.addFeatures([feature as GeoJSON.Feature]); } } } if (changedProperties.has('projection') && this.map && this.isMapReady) { this.map.setProjection({ type: this.projection }); } if (changedProperties.has('navigationMode') && this.navigationController) { this.navigationController.navigationMode = this.navigationMode; } } // ─── Controller Initialization ────────────────────────────────────────────── private initializeControllers(): void { // Initialize search controller this.searchController = new SearchController( { placeholder: this.searchPlaceholder }, { onResultSelected: (result, coordinates, zoom) => { this.flyTo(coordinates, zoom); this.dispatchEvent(new CustomEvent('address-selected', { detail: { address: result.display_name, coordinates, boundingBox: [ parseFloat(result.boundingbox[0]), parseFloat(result.boundingbox[1]), parseFloat(result.boundingbox[2]), parseFloat(result.boundingbox[3]), ], placeId: String(result.place_id), type: result.type, }, bubbles: true, composed: true, })); }, onRequestUpdate: () => this.requestUpdate(), } ); // Initialize navigation controller this.navigationController = new NavigationController({ onRouteCalculated: (event) => { this.dispatchEvent(new CustomEvent('route-calculated', { detail: event, bubbles: true, composed: true, })); }, onRequestUpdate: () => this.requestUpdate(), getMap: () => this.map, }); this.navigationController.navigationMode = this.navigationMode; } // ─── Map Initialization ───────────────────────────────────────────────────── private async initializeMap() { const container = this.shadowRoot?.querySelector('.map-wrapper') as HTMLElement; if (!container) return; // Ensure MapLibre CSS is loaded in the document this.ensureMaplibreCssLoaded(); const style = this.getMapStyle(); this.map = new maplibregl.Map({ container, style, center: this.center, zoom: this.zoom, attributionControl: {}, }); this.map.on('load', () => { this.isMapReady = true; // Set projection (globe or mercator) this.map!.setProjection({ type: this.projection }); this.initializeTerraDraw(); this.dispatchEvent(new CustomEvent('map-ready', { detail: { map: this.map } })); }); // Forward map events this.map.on('moveend', () => { this.dispatchEvent(new CustomEvent('map-move', { detail: { center: this.map?.getCenter().toArray(), zoom: this.map?.getZoom(), }, })); }); // Handle clicks for navigation point selection this.map.on('click', (e: maplibregl.MapMouseEvent) => { if (this.showNavigation && this.navigationController?.navClickMode) { this.navigationController.handleMapClickForNavigation(e); } }); } private getMapStyle(): maplibregl.StyleSpecification | string { if (this.mapStyle === 'osm') { return { version: 8, sources: { 'osm-tiles': { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, attribution: '© OpenStreetMap contributors', }, }, layers: [ { id: 'osm-tiles', type: 'raster', source: 'osm-tiles', minzoom: 0, maxzoom: 19, }, ], }; } return this.mapStyle; } // ─── Terra Draw Initialization ────────────────────────────────────────────── private initializeTerraDraw() { if (!this.map || this.draw) return; const adapter = new TerraDrawMapLibreGLAdapter({ map: this.map, }); this.draw = new TerraDraw({ adapter, modes: [ new TerraDrawPointMode(), new TerraDrawLineStringMode(), new TerraDrawPolygonMode(), new TerraDrawRectangleMode({ drawInteraction: this.dragToDraw ? 'click-drag' : 'click-move', }), new TerraDrawCircleMode({ drawInteraction: this.dragToDraw ? 'click-drag' : 'click-move', }), new TerraDrawFreehandMode(), new TerraDrawSelectMode({ flags: { polygon: { feature: { draggable: true, coordinates: { midpoints: true, draggable: true, deletable: true, }, }, }, rectangle: { feature: { draggable: true, coordinates: { draggable: true, deletable: true, }, }, }, point: { feature: { draggable: true, }, }, linestring: { feature: { draggable: true, coordinates: { midpoints: true, draggable: true, deletable: true, }, }, }, circle: { feature: { draggable: true, }, }, freehand: { feature: { draggable: true, }, }, }, }), // Static mode for pan/zoom only (no drawing) new TerraDrawRenderMode({ modeName: 'static', }), ], }); this.draw.start(); // Register event handlers this.draw.on('change', (ids: (string | number)[], type: string) => { const features = this.draw?.getSnapshot() || []; this.dispatchEvent(new CustomEvent('draw-change', { detail: { ids, type, features } as IDrawChangeEvent, })); this.requestUpdate(); }); this.draw.on('finish', (id: string | number, context: { action: string; mode: string }) => { const features = this.draw?.getSnapshot() || []; this.dispatchEvent(new CustomEvent('draw-finish', { detail: { id: String(id), context, features } as IDrawFinishEvent, })); }); // Load initial geoJson if provided if (this.geoJson.features.length > 0) { this.loadGeoJson(this.geoJson); } // Set initial mode (always set a mode, including 'static') this.draw.setMode(this.activeTool); // Set initial drag state based on active tool // Drawing modes need drag disabled; static/select need it enabled const isDrawingMode = !['static', 'select'].includes(this.activeTool); if (isDrawingMode) { this.map.dragPan.disable(); this.map.dragRotate.disable(); } } // ─── Public Methods ───────────────────────────────────────────────────────── /** * Get the current snapshot of all drawn features */ public getFeatures(): GeoJSON.Feature[] { return this.draw?.getSnapshot() || []; } /** * Get features as a GeoJSON FeatureCollection */ public getGeoJson(): GeoJSON.FeatureCollection { return { type: 'FeatureCollection', features: this.getFeatures(), }; } /** * Load GeoJSON features into the map */ public loadGeoJson(geojson: GeoJSON.FeatureCollection) { if (!this.draw) return; // Clear existing features first this.clearAllFeatures(); // Add features from the GeoJSON for (const feature of geojson.features) { if (feature.geometry && feature.properties) { this.draw.addFeatures([feature as GeoJSON.Feature]); } } } /** * Clear all drawn features */ public clearAllFeatures() { if (!this.draw) return; const features = this.draw.getSnapshot(); const ids = features.map((f) => f.id).filter((id): id is string | number => id !== undefined); if (ids.length > 0) { this.draw.removeFeatures(ids); } } /** * Set the active drawing tool */ public setTool(tool: TDrawTool) { this.activeTool = tool; if (this.draw && this.map) { this.draw.setMode(tool); // Manually control map dragging based on mode // Drawing modes need drag disabled; static/select need it enabled const isDrawingMode = !['static', 'select'].includes(tool); if (isDrawingMode) { this.map.dragPan.disable(); this.map.dragRotate.disable(); } else { this.map.dragPan.enable(); this.map.dragRotate.enable(); } } } /** * Get the underlying MapLibre map instance */ public getMap(): maplibregl.Map | null { return this.map; } /** * Get the Terra Draw instance */ public getTerraDraw(): TerraDraw | null { return this.draw; } /** * Fly to a specific location */ public flyTo(center: [number, number], zoom?: number) { this.map?.flyTo({ center, zoom: zoom ?? this.map.getZoom(), duration: 1500, }); } /** * Set the map projection */ public setProjection(projection: 'mercator' | 'globe') { this.projection = projection; } /** * Fit the map to show all drawn features */ public fitToFeatures(padding = 50) { const features = this.getFeatures(); if (features.length === 0 || !this.map) return; const bounds = new maplibregl.LngLatBounds(); for (const feature of features) { const geometry = feature.geometry; if (!geometry) continue; if (geometry.type === 'Point') { bounds.extend(geometry.coordinates as [number, number]); } else if (geometry.type === 'LineString' || geometry.type === 'MultiPoint') { for (const coord of geometry.coordinates) { bounds.extend(coord as [number, number]); } } else if (geometry.type === 'Polygon' || geometry.type === 'MultiLineString') { for (const ring of geometry.coordinates) { for (const coord of ring) { bounds.extend(coord as [number, number]); } } } } if (!bounds.isEmpty()) { this.map.fitBounds(bounds, { padding }); } } // ─── Navigation Public Methods (delegated to controller) ──────────────────── /** * Calculate and display route */ public async calculateRoute(): Promise { await this.navigationController?.calculateRoute(); } /** * Set navigation start point */ public setNavigationStart(coords: [number, number], address?: string): void { this.navigationController?.setNavigationStart(coords, address); } /** * Set navigation end point */ public setNavigationEnd(coords: [number, number], address?: string): void { this.navigationController?.setNavigationEnd(coords, address); } /** * Clear all navigation state */ public clearNavigation(): void { this.navigationController?.clearNavigation(); } /** * Get current navigation state */ public getNavigationState(): INavigationState | null { return this.navigationController?.navigationState ?? null; } // ─── Private Methods ──────────────────────────────────────────────────────── private ensureMaplibreCssLoaded() { const cssId = 'maplibre-gl-css'; if (!document.getElementById(cssId)) { const link = document.createElement('link'); link.id = cssId; link.rel = 'stylesheet'; link.href = 'https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.css'; document.head.appendChild(link); } } private cleanup() { if (this.draw) { this.draw.stop(); this.draw = null; } // Clean up navigation controller this.navigationController?.cleanup(); if (this.map) { this.map.remove(); this.map = null; } } private handleToolClick(tool: TDrawTool) { // If clicking the same tool again, deselect it (switch to static/pan mode) if (this.activeTool === tool) { this.setTool('static'); } else { this.setTool(tool); } this.requestUpdate(); } private handleClearClick() { this.clearAllFeatures(); } private handleZoomIn() { this.map?.zoomIn(); } private handleZoomOut() { this.map?.zoomOut(); } private handleMapContextMenu(e: MouseEvent) { e.preventDefault(); DeesContextmenu.openContextMenuWithOptions(e, [ { name: this.dragToDraw ? '✓ Drag to Draw' : 'Drag to Draw', iconName: 'lucide:move', action: async () => { this.dragToDraw = !this.dragToDraw; }, }, { name: this.projection === 'globe' ? '✓ Globe View' : 'Globe View', iconName: 'lucide:globe', action: async () => { this.projection = this.projection === 'globe' ? 'mercator' : 'globe'; }, }, { divider: true }, { name: 'Clear All Features', iconName: 'lucide:trash2', action: async () => this.clearAllFeatures(), }, { name: 'Fit to Features', iconName: 'lucide:maximize', action: async () => this.fitToFeatures(), }, ]); } // ─── Render ───────────────────────────────────────────────────────────────── public render(): TemplateResult { const featureCount = this.draw?.getSnapshot().length || 0; return html`
this.handleMapContextMenu(e)}>
${this.showToolbar ? this.renderToolbar() : ''} ${this.showSearch && this.searchController ? this.searchController.render() : ''} ${this.showNavigation && this.navigationController ? this.navigationController.render() : ''} ${featureCount > 0 ? html`
${featureCount} feature${featureCount !== 1 ? 's' : ''}
` : ''}
`; } private renderToolbar(): TemplateResult { const tools: { id: TDrawTool; icon: string; label: string }[] = [ { id: 'point', icon: 'point', label: 'Point' }, { id: 'linestring', icon: 'line', label: 'Line' }, { id: 'polygon', icon: 'polygon', label: 'Polygon' }, { id: 'rectangle', icon: 'rectangle', label: 'Rectangle' }, { id: 'circle', icon: 'circle', label: 'Circle' }, { id: 'freehand', icon: 'freehand', label: 'Freehand' }, ]; return html`
Draw
${tools.map(tool => html` `)}
Edit
`; } }