978 lines
29 KiB
TypeScript
978 lines
29 KiB
TypeScript
import { demoFunc } from './dees-geo-map.demo.js';
|
|
import {
|
|
customElement,
|
|
html,
|
|
DeesElement,
|
|
property,
|
|
state,
|
|
type TemplateResult,
|
|
cssManager,
|
|
css,
|
|
} from '@design.estate/dees-element';
|
|
import { DeesContextmenu } from '@design.estate/dees-catalog';
|
|
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles, trafficStyles, headerToolbarStyles } from '../../00componentstyles.js';
|
|
|
|
// MapLibre imports
|
|
import maplibregl from 'maplibre-gl';
|
|
|
|
// Terra Draw imports
|
|
import {
|
|
TerraDraw,
|
|
TerraDrawPolygonMode,
|
|
TerraDrawRectangleMode,
|
|
TerraDrawPointMode,
|
|
TerraDrawLineStringMode,
|
|
TerraDrawCircleMode,
|
|
TerraDrawFreehandMode,
|
|
TerraDrawSelectMode,
|
|
TerraDrawRenderMode,
|
|
} from 'terra-draw';
|
|
import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
|
|
|
|
// Modular imports
|
|
import { renderIcon } from './geo-map.icons.js';
|
|
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js';
|
|
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js';
|
|
import { TrafficController } from './geo-map.traffic.js';
|
|
import { HereTrafficProvider, type ITrafficProvider } from './geo-map.traffic.providers.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 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 = '';
|
|
|
|
// ─── 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;
|
|
|
|
// Controllers
|
|
private searchController: SearchController | null = null;
|
|
private navigationController: NavigationController | null = null;
|
|
private trafficController: TrafficController | null = null;
|
|
|
|
// ─── Styles ─────────────────────────────────────────────────────────────────
|
|
|
|
public static styles = [
|
|
cssManager.defaultStyles,
|
|
geoComponentStyles,
|
|
mapContainerStyles,
|
|
toolbarStyles,
|
|
searchStyles,
|
|
navigationStyles,
|
|
trafficStyles,
|
|
headerToolbarStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
width: 100%;
|
|
height: 400px;
|
|
}
|
|
|
|
.maplibregl-map {
|
|
width: 100%;
|
|
height: 100%;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.maplibregl-ctrl-attrib {
|
|
font-size: 11px;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
color: rgba(255, 255, 255, 0.8);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.maplibregl-ctrl-attrib a {
|
|
color: rgba(255, 255, 255, 0.8);
|
|
}
|
|
|
|
.feature-count {
|
|
padding: 6px 12px;
|
|
background: rgba(30, 30, 30, 0.9);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 6px;
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
font-size: 12px;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
`,
|
|
];
|
|
|
|
// ─── Lifecycle ──────────────────────────────────────────────────────────────
|
|
|
|
public async firstUpdated() {
|
|
this.initializeControllers();
|
|
await this.initializeMap();
|
|
}
|
|
|
|
public async disconnectedCallback() {
|
|
await super.disconnectedCallback();
|
|
this.cleanup();
|
|
}
|
|
|
|
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.trafficController?.supportsTrafficRouting()) {
|
|
return this.trafficController.fetchRouteWithTraffic(start, end, mode);
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ─── 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') {
|
|
return {
|
|
version: 8,
|
|
sources: {
|
|
'osm-tiles': {
|
|
type: 'raster',
|
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
|
tileSize: 256,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
},
|
|
},
|
|
layers: [
|
|
{
|
|
id: 'osm-tiles',
|
|
type: 'raster',
|
|
source: 'osm-tiles',
|
|
minzoom: 0,
|
|
maxzoom: 19,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
return this.mapStyle;
|
|
}
|
|
|
|
// ─── Terra Draw Initialization ──────────────────────────────────────────────
|
|
|
|
private initializeTerraDraw() {
|
|
if (!this.map || this.draw) return;
|
|
|
|
const adapter = new TerraDrawMapLibreGLAdapter({
|
|
map: this.map,
|
|
});
|
|
|
|
this.draw = new TerraDraw({
|
|
adapter,
|
|
modes: [
|
|
new TerraDrawPointMode(),
|
|
new TerraDrawLineStringMode(),
|
|
new TerraDrawPolygonMode(),
|
|
new TerraDrawRectangleMode({
|
|
drawInteraction: this.dragToDraw ? 'click-drag' : 'click-move',
|
|
}),
|
|
new TerraDrawCircleMode({
|
|
drawInteraction: this.dragToDraw ? 'click-drag' : 'click-move',
|
|
}),
|
|
new TerraDrawFreehandMode(),
|
|
new TerraDrawSelectMode({
|
|
flags: {
|
|
polygon: {
|
|
feature: {
|
|
draggable: true,
|
|
coordinates: {
|
|
midpoints: true,
|
|
draggable: true,
|
|
deletable: true,
|
|
},
|
|
},
|
|
},
|
|
rectangle: {
|
|
feature: {
|
|
draggable: true,
|
|
coordinates: {
|
|
draggable: true,
|
|
deletable: true,
|
|
},
|
|
},
|
|
},
|
|
point: {
|
|
feature: {
|
|
draggable: true,
|
|
},
|
|
},
|
|
linestring: {
|
|
feature: {
|
|
draggable: true,
|
|
coordinates: {
|
|
midpoints: true,
|
|
draggable: true,
|
|
deletable: true,
|
|
},
|
|
},
|
|
},
|
|
circle: {
|
|
feature: {
|
|
draggable: true,
|
|
},
|
|
},
|
|
freehand: {
|
|
feature: {
|
|
draggable: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
// Static mode for pan/zoom only (no drawing)
|
|
new TerraDrawRenderMode({
|
|
modeName: 'static',
|
|
}),
|
|
],
|
|
});
|
|
|
|
this.draw.start();
|
|
|
|
// Register event handlers
|
|
this.draw.on('change', (ids: (string | number)[], type: string) => {
|
|
const features = this.draw?.getSnapshot() || [];
|
|
this.dispatchEvent(new CustomEvent('draw-change', {
|
|
detail: { ids, type, features } as IDrawChangeEvent,
|
|
}));
|
|
this.requestUpdate();
|
|
});
|
|
|
|
this.draw.on('finish', (id: string | number, context: { action: string; mode: string }) => {
|
|
const features = this.draw?.getSnapshot() || [];
|
|
this.dispatchEvent(new CustomEvent('draw-finish', {
|
|
detail: { id: String(id), context, features } as IDrawFinishEvent,
|
|
}));
|
|
});
|
|
|
|
// Load initial geoJson if provided
|
|
if (this.geoJson.features.length > 0) {
|
|
this.loadGeoJson(this.geoJson);
|
|
}
|
|
|
|
// Set initial mode (always set a mode, including 'static')
|
|
this.draw.setMode(this.activeTool);
|
|
|
|
// Set initial drag state based on active tool
|
|
// Drawing modes need drag disabled; static/select need it enabled
|
|
const isDrawingMode = !['static', 'select'].includes(this.activeTool);
|
|
if (isDrawingMode) {
|
|
this.map.dragPan.disable();
|
|
this.map.dragRotate.disable();
|
|
}
|
|
}
|
|
|
|
// ─── Public Methods ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the current snapshot of all drawn features
|
|
*/
|
|
public getFeatures(): GeoJSON.Feature[] {
|
|
return this.draw?.getSnapshot() || [];
|
|
}
|
|
|
|
/**
|
|
* Get features as a GeoJSON FeatureCollection
|
|
*/
|
|
public getGeoJson(): GeoJSON.FeatureCollection {
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: this.getFeatures(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load GeoJSON features into the map
|
|
*/
|
|
public loadGeoJson(geojson: GeoJSON.FeatureCollection) {
|
|
if (!this.draw) return;
|
|
|
|
// Clear existing features first
|
|
this.clearAllFeatures();
|
|
|
|
// Add features from the GeoJSON
|
|
for (const feature of geojson.features) {
|
|
if (feature.geometry && feature.properties) {
|
|
this.draw.addFeatures([feature as GeoJSON.Feature<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;
|
|
}
|
|
|
|
// ─── 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();
|
|
|
|
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;
|
|
}
|
|
|
|
// ─── 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);
|
|
|
|
return html`
|
|
<div class="geo-component">
|
|
<!-- Header Toolbar Above Map -->
|
|
${this.renderHeaderToolbar(showTrafficControls)}
|
|
|
|
<!-- Map Container -->
|
|
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
|
|
<div class="map-wrapper"></div>
|
|
|
|
<div class="map-overlay">
|
|
<!-- Top Left: Navigation Panel (toggleable) -->
|
|
<div class="overlay-top-left">
|
|
${this.showNavigation && this.isNavigationOpen && this.navigationController
|
|
? this.navigationController.render()
|
|
: ''}
|
|
</div>
|
|
|
|
<!-- Top Right: Empty now (controls moved to 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 now (zoom moved to header) -->
|
|
<div class="overlay-bottom-right"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderHeaderToolbar(showTrafficControls: boolean): TemplateResult {
|
|
return html`
|
|
<div class="header-toolbar">
|
|
<!-- Left: Draw Tools -->
|
|
<div class="toolbar-left">
|
|
${this.showToolbar ? html`
|
|
${this.renderDrawTools()}
|
|
<div class="toolbar-divider"></div>
|
|
` : ''}
|
|
</div>
|
|
|
|
<!-- Center: Search Bar -->
|
|
<div class="toolbar-center">
|
|
${this.showSearch && this.searchController
|
|
? this.searchController.render()
|
|
: ''}
|
|
</div>
|
|
|
|
<!-- Right: Navigation Toggle + Traffic Toggle + Zoom Controls -->
|
|
<div class="toolbar-right">
|
|
${this.showNavigation ? html`
|
|
<button
|
|
class="tool-button ${this.isNavigationOpen ? 'active' : ''}"
|
|
title="Navigation"
|
|
@click=${() => this.toggleNavigation()}
|
|
>
|
|
${renderIcon('navigation')}
|
|
</button>
|
|
` : ''}
|
|
${showTrafficControls && this.trafficController
|
|
? 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 renderDrawTools(): 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`
|
|
${tools.map(tool => html`
|
|
<button
|
|
class="tool-button ${this.activeTool === tool.id ? 'active' : ''}"
|
|
title="${tool.label}"
|
|
@click=${() => this.handleToolClick(tool.id)}
|
|
?disabled=${!this.isMapReady}
|
|
>
|
|
${renderIcon(tool.icon)}
|
|
</button>
|
|
`)}
|
|
<div class="toolbar-divider"></div>
|
|
<button
|
|
class="tool-button ${this.activeTool === 'select' ? 'active' : ''}"
|
|
title="Select & Edit"
|
|
@click=${() => this.handleToolClick('select')}
|
|
?disabled=${!this.isMapReady}
|
|
>
|
|
${renderIcon('select')}
|
|
</button>
|
|
<button
|
|
class="tool-button"
|
|
title="Clear All"
|
|
@click=${this.handleClearClick}
|
|
?disabled=${!this.isMapReady}
|
|
>
|
|
${renderIcon('trash')}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
}
|