import { demoFunc } from './dees-geo-map.demo.js'; import { customElement, html, DeesElement, property, state, type TemplateResult, cssManager, css, domtools, } from '@design.estate/dees-element'; import { DeesContextmenu } from '@design.estate/dees-catalog'; import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles, guidanceStyles, maplibreMarkerStyles } 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, type IOSRMRoute } from './geo-map.navigation.js'; import { TrafficController } from './geo-map.traffic.js'; import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.js'; import { NavigationGuideController, type IGPSPosition, type IGuidanceEvent, type INavigationGuideState, type INavigationCameraConfig } from './geo-map.navigation-guide.js'; import { MockGPSSimulator, type TSimulationSpeed, type IMockGPSConfig } from './geo-map.mock-gps.js'; import type { IVoiceConfig } from './geo-map.voice.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 { ITrafficProvider, ITrafficFlowData, ITrafficAwareRoute } from './geo-map.traffic.providers.js'; export type { IGPSPosition, IGuidanceEvent, INavigationGuideState, TGuidanceEventType, INavigationCameraConfig } from './geo-map.navigation-guide.js'; export type { IVoiceConfig } from './geo-map.voice.js'; export type { TSimulationSpeed, IMockGPSConfig } from './geo-map.mock-gps.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'; // Traffic properties @property({ type: Boolean }) accessor showTraffic: boolean = false; @property({ type: Object }) accessor trafficProvider: ITrafficProvider | null = null; @property({ type: String }) accessor trafficApiKey: string = ''; // Guidance properties @property({ type: Boolean }) accessor enableGuidance: boolean = false; @property({ type: Object }) accessor voiceConfig: Partial = {}; // ─── State ────────────────────────────────────────────────────────────────── @state() private accessor map: maplibregl.Map | null = null; @state() private accessor draw: TerraDraw | null = null; @state() private accessor isMapReady: boolean = false; @state() private accessor isNavigationOpen: boolean = true; @state() private accessor isDrawPanelOpen: boolean = true; // Controllers private searchController: SearchController | null = null; private navigationController: NavigationController | null = null; private trafficController: TrafficController | null = null; private guidanceController: NavigationGuideController | null = null; private mockGPSSimulator: MockGPSSimulator | null = null; // ─── Styles ───────────────────────────────────────────────────────────────── public static styles = [ cssManager.defaultStyles, geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles, guidanceStyles, maplibreMarkerStyles, css` :host { display: block; width: 100%; height: 400px; } .maplibregl-map { width: 100%; height: 100%; font-family: inherit; } .maplibregl-ctrl-attrib { font-size: 11px; background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.8)', 'rgba(0, 0, 0, 0.5)')}; color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(255, 255, 255, 0.8)')}; padding: 2px 6px; border-radius: 4px; } .maplibregl-ctrl-attrib a { color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.7)', 'rgba(255, 255, 255, 0.8)')}; } .feature-count { padding: 6px 12px; background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.95)', 'rgba(30, 30, 30, 0.9)')}; border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')}; border-radius: 6px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); font-size: 12px; color: ${cssManager.bdTheme('rgba(0, 0, 0, 0.6)', 'rgba(255, 255, 255, 0.7)')}; } `, ]; // Map theme subscription for cleanup private mapThemeSubscription: { unsubscribe: () => void } | null = null; // ─── Lifecycle ────────────────────────────────────────────────────────────── public async firstUpdated() { this.initializeControllers(); await this.initializeMap(); // Subscribe to theme changes to update map style this.subscribeToThemeChanges(); } public async disconnectedCallback() { await super.disconnectedCallback(); if (this.mapThemeSubscription) { this.mapThemeSubscription.unsubscribe(); this.mapThemeSubscription = null; } this.cleanup(); } /** * Subscribe to theme changes via domtools */ private async subscribeToThemeChanges(): Promise { const domtoolsInstance = await domtools.DomTools.setupDomTools(); this.mapThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe( (_goBright: boolean) => { this.updateMapStyleForTheme(); } ); } 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; } // Traffic property changes if (changedProperties.has('showTraffic') && this.trafficController) { if (this.showTraffic) { this.trafficController.enable(); } else { this.trafficController.disable(); } } if (changedProperties.has('trafficProvider') && this.trafficController && this.trafficProvider) { this.trafficController.setProvider(this.trafficProvider); } if (changedProperties.has('trafficApiKey') && this.trafficController && this.trafficApiKey) { // Auto-configure HERE provider if API key is provided const hereProvider = new HereTrafficProvider(); hereProvider.configure({ apiKey: this.trafficApiKey }); this.trafficController.setProvider(hereProvider); } } // ─── 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, // Connect traffic controller for traffic-aware routing getTrafficRoute: async (start, end, mode) => { if (this.showTraffic && this.trafficController?.supportsTrafficRouting()) { return this.trafficController.fetchRouteWithTraffic(start, end, mode); } return null; }, // Traffic toggle callbacks onTrafficToggle: (enabled) => { if (enabled) { this.trafficController?.enable(); } else { this.trafficController?.disable(); } this.showTraffic = enabled; }, getTrafficEnabled: () => this.showTraffic, }); this.navigationController.navigationMode = this.navigationMode; // Initialize traffic controller this.trafficController = new TrafficController({ onRequestUpdate: () => this.requestUpdate(), getMap: () => this.map, }); // Configure traffic provider if API key or provider is set if (this.trafficProvider) { this.trafficController.setProvider(this.trafficProvider); } else if (this.trafficApiKey) { const hereProvider = new HereTrafficProvider(); hereProvider.configure({ apiKey: this.trafficApiKey }); this.trafficController.setProvider(hereProvider); } // Initialize guidance controller this.guidanceController = new NavigationGuideController( { onGuidanceEvent: (event) => { this.dispatchEvent(new CustomEvent('guidance-event', { detail: event, bubbles: true, composed: true, })); }, onRequestUpdate: () => this.requestUpdate(), getMap: () => this.map, }, this.voiceConfig ); } // ─── 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(); // Enable traffic if configured if (this.showTraffic && this.trafficController) { this.trafficController.enable(); } 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(), }, })); // Refresh traffic data when map moves if (this.trafficController) { this.trafficController.handleMapMoveEnd(); } }); // 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') { // Check current theme and use appropriate tiles const isDarkTheme = !cssManager.goBright; if (isDarkTheme) { // CartoDB Dark Matter GL vector style - high quality dark theme return 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json'; } else { // CartoDB Voyager GL vector style - high quality light theme return 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'; } } return this.mapStyle; } /** * Update the map style when theme changes */ private updateMapStyleForTheme(): void { if (!this.map || !this.isMapReady || this.mapStyle !== 'osm') return; const newStyle = this.getMapStyle(); this.map.setStyle(newStyle as maplibregl.StyleSpecification); } // ─── 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; } // ─── Traffic Public Methods ──────────────────────────────────────────────── /** * Enable traffic visualization */ public enableTraffic(): void { this.showTraffic = true; this.trafficController?.enable(); } /** * Disable traffic visualization */ public disableTraffic(): void { this.showTraffic = false; this.trafficController?.disable(); } /** * Toggle traffic visualization */ public toggleTraffic(): void { this.showTraffic = !this.showTraffic; this.trafficController?.toggle(); } /** * Refresh traffic data */ public async refreshTraffic(): Promise { await this.trafficController?.refresh(); } /** * Set traffic provider */ public setTrafficProvider(provider: ITrafficProvider): void { this.trafficProvider = provider; this.trafficController?.setProvider(provider); } /** * Check if traffic-aware routing is available */ public supportsTrafficRouting(): boolean { return this.trafficController?.supportsTrafficRouting() ?? false; } /** * Get traffic controller for advanced usage */ public getTrafficController(): TrafficController | null { return this.trafficController; } // ─── Guidance Public Methods ──────────────────────────────────────────────── /** * Set current GPS position for navigation guidance * @param coords - [lng, lat] coordinates * @param heading - Optional heading in degrees (0 = North) * @param speed - Optional speed in meters/second */ public setPosition(coords: [number, number], heading?: number, speed?: number): void { this.guidanceController?.setPosition(coords[0], coords[1], heading, speed); } /** * Start voice-guided navigation for the current route */ public startGuidance(): void { const route = this.navigationController?.navigationState?.route; if (route && this.guidanceController) { this.guidanceController.startGuidance(route); } else { console.warn('[dees-geo-map] Cannot start guidance: no route calculated'); } } /** * Stop voice-guided navigation */ public stopGuidance(): void { this.guidanceController?.stopGuidance(); this.mockGPSSimulator?.stop(); } /** * Enable or disable voice guidance */ public setVoiceEnabled(enabled: boolean): void { this.guidanceController?.setVoiceEnabled(enabled); } /** * Check if voice guidance is enabled */ public isVoiceEnabled(): boolean { return this.guidanceController?.isVoiceEnabled() ?? false; } /** * Get current guidance state */ public getGuidanceState(): INavigationGuideState | null { return this.guidanceController?.state ?? null; } /** * Check if actively navigating */ public isNavigating(): boolean { return this.guidanceController?.isNavigating() ?? false; } /** * Create a mock GPS simulator for testing/demo * The simulator emits position updates along the current route */ public createMockGPSSimulator(config?: Partial): MockGPSSimulator { // Clean up existing simulator if (this.mockGPSSimulator) { this.mockGPSSimulator.cleanup(); } this.mockGPSSimulator = new MockGPSSimulator( { onPositionUpdate: (position: IGPSPosition) => { if (this.guidanceController) { this.guidanceController.updatePosition(position); } }, onSimulationStart: () => { console.log('[MockGPSSimulator] Simulation started'); }, onSimulationPause: () => { console.log('[MockGPSSimulator] Simulation paused'); }, onSimulationStop: () => { console.log('[MockGPSSimulator] Simulation stopped'); }, onSimulationComplete: () => { console.log('[MockGPSSimulator] Simulation complete'); }, }, config ); // Set the route if available const route = this.navigationController?.navigationState?.route; if (route) { this.mockGPSSimulator.setRoute(route); } return this.mockGPSSimulator; } /** * Get the mock GPS simulator (if created) */ public getMockGPSSimulator(): MockGPSSimulator | null { return this.mockGPSSimulator; } /** * Get the guidance controller for advanced usage */ public getGuidanceController(): NavigationGuideController | null { return this.guidanceController; } // ─── Navigation Camera Control Methods ───────────────────────────────────── /** * Enable or disable camera following the GPS position during navigation * @param enabled - Whether the camera should follow the position */ public setNavigationFollowPosition(enabled: boolean): void { this.guidanceController?.setFollowPosition(enabled); } /** * Check if camera is following position during navigation */ public isNavigationFollowingPosition(): boolean { return this.guidanceController?.isFollowingPosition() ?? true; } /** * Enable or disable camera rotating with heading during navigation * @param enabled - Whether the camera should rotate with heading */ public setNavigationFollowBearing(enabled: boolean): void { this.guidanceController?.setFollowBearing(enabled); } /** * Check if camera is following bearing during navigation */ public isNavigationFollowingBearing(): boolean { return this.guidanceController?.isFollowingBearing() ?? true; } /** * Set the navigation camera pitch (3D tilt angle) * @param pitch - Angle in degrees (0 = flat, 60 = tilted for 3D view) */ public setNavigationPitch(pitch: number): void { this.guidanceController?.setPitch(pitch); } /** * Get current navigation pitch setting */ public getNavigationPitch(): number { return this.guidanceController?.getPitch() ?? 60; } /** * Set the navigation zoom level * @param zoom - Zoom level (typically 15-19 for street-level navigation) */ public setNavigationZoom(zoom: number): void { this.guidanceController?.setZoom(zoom); } /** * Get current navigation zoom setting */ public getNavigationZoom(): number { return this.guidanceController?.getZoom() ?? 17; } /** * Get the full navigation camera configuration */ public getNavigationCameraConfig(): INavigationCameraConfig | null { return this.guidanceController?.getCameraConfig() ?? null; } /** * Set navigation camera configuration * @param config - Partial camera configuration to apply */ public setNavigationCameraConfig(config: Partial): void { this.guidanceController?.setCameraConfig(config); } // ─── 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 controllers this.navigationController?.cleanup(); this.trafficController?.cleanup(); this.guidanceController?.cleanup(); this.mockGPSSimulator?.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(); const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false; 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: this.showTraffic ? '✓ Show Traffic' : 'Show Traffic', iconName: 'lucide:traffic-cone', action: async () => { if (hasTrafficProvider) { this.toggleTraffic(); } else { console.warn('[dees-geo-map] No traffic provider configured. Set trafficApiKey or trafficProvider property.'); } }, disabled: !hasTrafficProvider, }, { divider: true }, { name: 'Clear All Features', iconName: 'lucide:trash2', action: async () => this.clearAllFeatures(), }, { name: 'Fit to Features', iconName: 'lucide:maximize', action: async () => this.fitToFeatures(), }, ]); } private toggleNavigation(): void { this.isNavigationOpen = !this.isNavigationOpen; } private toggleDrawPanel(): void { this.isDrawPanelOpen = !this.isDrawPanelOpen; } // ─── Render ───────────────────────────────────────────────────────────────── public render(): TemplateResult { const featureCount = this.draw?.getSnapshot().length || 0; const hasTrafficProvider = this.trafficController?.provider?.isConfigured ?? false; const showTrafficControls = Boolean(hasTrafficProvider || this.trafficApiKey || this.trafficProvider); // Calculate panel widths for CSS Grid const leftPanelWidth = this.showNavigation && this.isNavigationOpen ? '300px' : '0px'; const rightPanelWidth = this.showToolbar && this.isDrawPanelOpen ? '180px' : '0px'; return html`
${this.renderHeaderToolbar(showTrafficControls)}
this.handleMapContextMenu(e)}>
${this.showTraffic && this.trafficController ? this.trafficController.renderLegend() : ''} ${featureCount > 0 ? html`
${featureCount} feature${featureCount !== 1 ? 's' : ''}
` : ''}
${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
`; } private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult { return html`
${this.showNavigation ? html` ` : ''}
${this.showSearch && this.searchController ? this.searchController.render() : ''}
${this.showToolbar ? html` ` : ''} ${this.trafficController?.render()}
`; } private renderDrawPanel(): 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`
${renderIcon('polygon')}
Draw Tools
${tools.map(tool => html` `)}
`; } }