Files
dees-catalog-geo/ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts

1282 lines
39 KiB
TypeScript

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<IVoiceConfig> = {};
// ─── 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<void> {
const domtoolsInstance = await domtools.DomTools.setupDomTools();
this.mapThemeSubscription = domtoolsInstance.themeManager.themeObservable.subscribe(
(_goBright: boolean) => {
this.updateMapStyleForTheme();
}
);
}
public updated(changedProperties: Map<string, unknown>) {
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<GeoJSON.Geometry, { mode: string }>]);
}
}
}
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<IAddressSelectedEvent>('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<IRouteCalculatedEvent>('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<IGuidanceEvent>('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<GeoJSON.Geometry, { mode: string }>]);
}
}
}
/**
* 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<void> {
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<void> {
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<IMockGPSConfig>): 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<INavigationCameraConfig>): 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`
<div class="geo-component" style="--left-panel-width: ${leftPanelWidth}; --right-panel-width: ${rightPanelWidth};">
<!-- Header Toolbar Above Map -->
${this.renderHeaderToolbar(showTrafficControls)}
<!-- Left Sidebar: Navigation Panel -->
<div class="left-sidebar ${!this.showNavigation || !this.isNavigationOpen ? 'collapsed' : ''}">
${this.showNavigation && this.isNavigationOpen && this.navigationController
? this.navigationController.render()
: ''}
</div>
<!-- Map Container -->
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
<div class="map-wrapper"></div>
<div class="map-overlay">
<!-- Top Left: Empty (navigation moved to sidebar) -->
<div class="overlay-top-left"></div>
<!-- Top Right: Empty (controls in header) -->
<div class="overlay-top-right"></div>
<!-- Bottom Left: Traffic Legend + Feature Count -->
<div class="overlay-bottom-left">
${this.showTraffic && this.trafficController ? this.trafficController.renderLegend() : ''}
${featureCount > 0 ? html`
<div class="feature-count">
${featureCount} feature${featureCount !== 1 ? 's' : ''}
</div>
` : ''}
</div>
<!-- Bottom Right: Empty (zoom in header) -->
<div class="overlay-bottom-right"></div>
</div>
<!-- Guidance Panel (shown during active navigation) -->
${this.guidanceController?.isNavigating() ? this.guidanceController.render() : ''}
</div>
<!-- Right Sidebar: Draw Panel -->
<div class="right-sidebar ${!this.showToolbar || !this.isDrawPanelOpen ? 'collapsed' : ''}">
${this.showToolbar && this.isDrawPanelOpen ? this.renderDrawPanel() : ''}
</div>
</div>
`;
}
private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult {
return html`
<div class="header-toolbar">
<!-- Left: Navigation Panel Toggle -->
<div class="toolbar-left">
${this.showNavigation ? html`
<button
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
title="Toggle Navigation Panel"
@click=${() => this.toggleNavigation()}
>
${renderIcon('navigation')}
</button>
` : ''}
</div>
<!-- Center: Search Bar -->
<div class="toolbar-center">
${this.showSearch && this.searchController
? this.searchController.render()
: ''}
</div>
<!-- Right: Draw Panel Toggle + Traffic Toggle + Zoom Controls -->
<div class="toolbar-right">
${this.showToolbar ? html`
<button
class="tool-button ${this.isDrawPanelOpen ? 'active' : ''}"
title="Toggle Draw Tools"
@click=${() => this.toggleDrawPanel()}
>
${renderIcon('polygon')}
</button>
` : ''}
${this.trafficController?.render()}
<div class="toolbar-divider"></div>
<button
class="tool-button"
title="Zoom in"
@click=${this.handleZoomIn}
>
${renderIcon('plus')}
</button>
<button
class="tool-button"
title="Zoom out"
@click=${this.handleZoomOut}
>
${renderIcon('minus')}
</button>
</div>
</div>
`;
}
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`
<div class="draw-panel">
<div class="draw-panel-header">
<div class="draw-panel-header-icon">
${renderIcon('polygon')}
</div>
<div class="draw-panel-header-title">Draw Tools</div>
</div>
<div class="draw-tools-grid">
${tools.map(tool => html`
<button
class="draw-tool-button ${this.activeTool === tool.id ? 'active' : ''}"
title="${tool.label}"
@click=${() => this.handleToolClick(tool.id)}
?disabled=${!this.isMapReady}
>
${renderIcon(tool.icon)}
<span class="draw-tool-button-label">${tool.label}</span>
</button>
`)}
</div>
<div class="draw-panel-divider"></div>
<div class="draw-panel-actions">
<button
class="draw-action-button ${this.activeTool === 'select' ? 'active' : ''}"
title="Select & Edit"
@click=${() => this.handleToolClick('select')}
?disabled=${!this.isMapReady}
>
${renderIcon('select')}
<span>Select & Edit</span>
</button>
<button
class="draw-action-button danger"
title="Clear All"
@click=${this.handleClearClick}
?disabled=${!this.isMapReady}
>
${renderIcon('trash')}
<span>Clear All</span>
</button>
</div>
</div>
`;
}
}