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

845 lines
24 KiB
TypeScript
Raw Normal View History

2026-02-05 12:03:22 +00:00
import { demoFunc } from './dees-geo-map.demo.js';
import {
customElement,
html,
DeesElement,
property,
state,
type TemplateResult,
cssManager,
css,
} from '@design.estate/dees-element';
import { DeesContextmenu } from '@design.estate/dees-catalog';
import { geoComponentStyles, mapContainerStyles, toolbarStyles, searchStyles, navigationStyles } from '../../00componentstyles.js';
// MapLibre imports
import maplibregl from 'maplibre-gl';
// Terra Draw imports
import {
TerraDraw,
TerraDrawPolygonMode,
TerraDrawRectangleMode,
TerraDrawPointMode,
TerraDrawLineStringMode,
TerraDrawCircleMode,
TerraDrawFreehandMode,
TerraDrawSelectMode,
TerraDrawRenderMode,
} from 'terra-draw';
import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter';
// Modular imports
import { renderIcon } from './geo-map.icons.js';
import { SearchController, type INominatimResult, type IAddressSelectedEvent } from './geo-map.search.js';
import { NavigationController, type TNavigationMode, type INavigationState, type IRouteCalculatedEvent } from './geo-map.navigation.js';
// Re-export types for external consumers
export type { INominatimResult, IAddressSelectedEvent } from './geo-map.search.js';
export type {
TNavigationMode,
INavigationState,
IOSRMRoute,
IOSRMLeg,
IOSRMStep,
IRouteCalculatedEvent,
} from './geo-map.navigation.js';
export type TDrawTool = 'polygon' | 'rectangle' | 'point' | 'linestring' | 'circle' | 'freehand' | 'select' | 'static';
export interface IDrawChangeEvent {
ids: string[];
type: string;
features: GeoJSON.Feature[];
}
export interface IDrawFinishEvent {
id: string;
context: { action: string; mode: string };
features: GeoJSON.Feature[];
}
export interface IDrawSelectEvent {
id: string;
}
declare global {
interface HTMLElementTagNameMap {
'dees-geo-map': DeesGeoMap;
}
}
@customElement('dees-geo-map')
export class DeesGeoMap extends DeesElement {
public static demo = demoFunc;
// ─── Properties ─────────────────────────────────────────────────────────────
@property({ type: Array })
accessor center: [number, number] = [0, 0]; // [lng, lat]
@property({ type: Number })
accessor zoom: number = 2;
@property({ type: String })
accessor mapStyle: string = 'osm';
@property({ type: String })
accessor activeTool: TDrawTool = 'static';
@property({ type: Object })
accessor geoJson: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [],
};
@property({ type: Boolean })
accessor showToolbar: boolean = true;
@property({ type: Boolean })
accessor dragToDraw: boolean = true; // Default to drag behavior for circle/rectangle
@property({ type: String })
accessor projection: 'mercator' | 'globe' = 'globe';
@property({ type: Boolean })
accessor showSearch: boolean = false;
@property({ type: String })
accessor searchPlaceholder: string = 'Search address...';
@property({ type: Boolean })
accessor showNavigation: boolean = false;
@property({ type: String })
accessor navigationMode: TNavigationMode = 'driving';
// ─── State ──────────────────────────────────────────────────────────────────
@state()
private accessor map: maplibregl.Map | null = null;
@state()
private accessor draw: TerraDraw | null = null;
@state()
private accessor isMapReady: boolean = false;
// Controllers
private searchController: SearchController | null = null;
private navigationController: NavigationController | null = null;
// ─── Styles ─────────────────────────────────────────────────────────────────
public static styles = [
cssManager.defaultStyles,
geoComponentStyles,
mapContainerStyles,
toolbarStyles,
searchStyles,
navigationStyles,
css`
:host {
display: block;
width: 100%;
height: 400px;
}
.maplibregl-map {
width: 100%;
height: 100%;
font-family: inherit;
}
.maplibregl-ctrl-attrib {
font-size: 11px;
background: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.8);
padding: 2px 6px;
border-radius: 4px;
}
.maplibregl-ctrl-attrib a {
color: rgba(255, 255, 255, 0.8);
}
.toolbar {
user-select: none;
}
.toolbar-title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.5);
padding: 0 4px 4px;
}
.tool-button {
position: relative;
}
.tool-button::after {
content: attr(title);
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
padding: 4px 8px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
font-size: 12px;
white-space: nowrap;
border-radius: 4px;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.15s ease, visibility 0.15s ease;
}
.tool-button:hover::after {
opacity: 1;
visibility: visible;
}
.feature-count {
position: absolute;
bottom: 12px;
left: 12px;
z-index: 10;
padding: 6px 12px;
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: rgba(30, 30, 30, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
`,
];
// ─── Lifecycle ──────────────────────────────────────────────────────────────
public async firstUpdated() {
this.initializeControllers();
await this.initializeMap();
}
public async disconnectedCallback() {
await super.disconnectedCallback();
this.cleanup();
}
public updated(changedProperties: Map<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;
}
}
// ─── 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,
});
this.navigationController.navigationMode = this.navigationMode;
}
// ─── Map Initialization ─────────────────────────────────────────────────────
private async initializeMap() {
const container = this.shadowRoot?.querySelector('.map-wrapper') as HTMLElement;
if (!container) return;
// Ensure MapLibre CSS is loaded in the document
this.ensureMaplibreCssLoaded();
const style = this.getMapStyle();
this.map = new maplibregl.Map({
container,
style,
center: this.center,
zoom: this.zoom,
attributionControl: {},
});
this.map.on('load', () => {
this.isMapReady = true;
// Set projection (globe or mercator)
this.map!.setProjection({ type: this.projection });
this.initializeTerraDraw();
this.dispatchEvent(new CustomEvent('map-ready', { detail: { map: this.map } }));
});
// Forward map events
this.map.on('moveend', () => {
this.dispatchEvent(new CustomEvent('map-move', {
detail: {
center: this.map?.getCenter().toArray(),
zoom: this.map?.getZoom(),
},
}));
});
// Handle clicks for navigation point selection
this.map.on('click', (e: maplibregl.MapMouseEvent) => {
if (this.showNavigation && this.navigationController?.navClickMode) {
this.navigationController.handleMapClickForNavigation(e);
}
});
}
private getMapStyle(): maplibregl.StyleSpecification | string {
if (this.mapStyle === 'osm') {
return {
version: 8,
sources: {
'osm-tiles': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© <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;
}
// ─── Private Methods ────────────────────────────────────────────────────────
private ensureMaplibreCssLoaded() {
const cssId = 'maplibre-gl-css';
if (!document.getElementById(cssId)) {
const link = document.createElement('link');
link.id = cssId;
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/maplibre-gl@5.1.1/dist/maplibre-gl.css';
document.head.appendChild(link);
}
}
private cleanup() {
if (this.draw) {
this.draw.stop();
this.draw = null;
}
// Clean up navigation controller
this.navigationController?.cleanup();
if (this.map) {
this.map.remove();
this.map = null;
}
}
private handleToolClick(tool: TDrawTool) {
// If clicking the same tool again, deselect it (switch to static/pan mode)
if (this.activeTool === tool) {
this.setTool('static');
} else {
this.setTool(tool);
}
this.requestUpdate();
}
private handleClearClick() {
this.clearAllFeatures();
}
private handleZoomIn() {
this.map?.zoomIn();
}
private handleZoomOut() {
this.map?.zoomOut();
}
private handleMapContextMenu(e: MouseEvent) {
e.preventDefault();
DeesContextmenu.openContextMenuWithOptions(e, [
{
name: this.dragToDraw ? '✓ Drag to Draw' : 'Drag to Draw',
iconName: 'lucide:move',
action: async () => {
this.dragToDraw = !this.dragToDraw;
},
},
{
name: this.projection === 'globe' ? '✓ Globe View' : 'Globe View',
iconName: 'lucide:globe',
action: async () => {
this.projection = this.projection === 'globe' ? 'mercator' : 'globe';
},
},
{ divider: true },
{
name: 'Clear All Features',
iconName: 'lucide:trash2',
action: async () => this.clearAllFeatures(),
},
{
name: 'Fit to Features',
iconName: 'lucide:maximize',
action: async () => this.fitToFeatures(),
},
]);
}
// ─── Render ─────────────────────────────────────────────────────────────────
public render(): TemplateResult {
const featureCount = this.draw?.getSnapshot().length || 0;
return html`
<div class="map-container" @contextmenu=${(e: MouseEvent) => this.handleMapContextMenu(e)}>
<div class="map-wrapper"></div>
${this.showToolbar ? this.renderToolbar() : ''}
${this.showSearch && this.searchController ? this.searchController.render() : ''}
${this.showNavigation && this.navigationController ? this.navigationController.render() : ''}
${featureCount > 0 ? html`
<div class="feature-count">
${featureCount} feature${featureCount !== 1 ? 's' : ''}
</div>
` : ''}
<div class="zoom-controls">
<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 renderToolbar(): TemplateResult {
const tools: { id: TDrawTool; icon: string; label: string }[] = [
{ id: 'point', icon: 'point', label: 'Point' },
{ id: 'linestring', icon: 'line', label: 'Line' },
{ id: 'polygon', icon: 'polygon', label: 'Polygon' },
{ id: 'rectangle', icon: 'rectangle', label: 'Rectangle' },
{ id: 'circle', icon: 'circle', label: 'Circle' },
{ id: 'freehand', icon: 'freehand', label: 'Freehand' },
];
return html`
<div class="toolbar">
<div class="toolbar-title">Draw</div>
<div class="toolbar-group">
${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>
<div class="toolbar-divider"></div>
<div class="toolbar-title">Edit</div>
<div class="toolbar-group">
<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>
</div>
</div>
`;
}
}