1282 lines
39 KiB
TypeScript
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>
|
|
`;
|
|
}
|
|
|
|
}
|