This commit is contained in:
2026-02-05 12:03:22 +00:00
commit 1a0fceadc0
21 changed files with 13332 additions and 0 deletions

View File

@@ -0,0 +1,318 @@
import { html, css, cssManager } from '@design.estate/dees-element';
import '@design.estate/dees-wcctools/demotools';
import type { DeesGeoMap } from './dees-geo-map.js';
export const demoFunc = () => html`
<style>
${css`
.demo-container {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.demo-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.demo-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#333', '#fff')};
margin: 0;
}
.demo-description {
font-size: 14px;
color: ${cssManager.bdTheme('#666', '#999')};
margin: 0;
}
.map-wrapper {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
dees-geo-map {
height: 500px;
}
.event-log {
background: ${cssManager.bdTheme('#f5f5f5', '#1a1a1a')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
padding: 16px;
font-family: monospace;
font-size: 12px;
max-height: 200px;
overflow-y: auto;
}
.event-entry {
padding: 4px 0;
border-bottom: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
color: ${cssManager.bdTheme('#555', '#aaa')};
}
.event-entry:last-child {
border-bottom: none;
}
.event-type {
color: ${cssManager.bdTheme('#0066cc', '#66b3ff')};
font-weight: 600;
}
.controls-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.control-button {
padding: 8px 16px;
border: 1px solid ${cssManager.bdTheme('#ccc', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
color: ${cssManager.bdTheme('#333', '#fff')};
font-size: 13px;
cursor: pointer;
transition: all 0.15s ease;
}
.control-button:hover {
background: ${cssManager.bdTheme('#f0f0f0', '#333')};
border-color: ${cssManager.bdTheme('#999', '#666')};
}
.feature-display {
background: ${cssManager.bdTheme('#f9f9f9', '#1e1e1e')};
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')};
border-radius: 8px;
padding: 16px;
}
.feature-json {
font-family: monospace;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
color: ${cssManager.bdTheme('#333', '#ccc')};
}
.locations-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
}
.location-button {
padding: 10px 12px;
border: 1px solid ${cssManager.bdTheme('#ddd', '#444')};
border-radius: 6px;
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
color: ${cssManager.bdTheme('#333', '#fff')};
font-size: 12px;
cursor: pointer;
text-align: center;
transition: all 0.15s ease;
}
.location-button:hover {
background: ${cssManager.bdTheme('#0066cc', '#0084ff')};
color: #fff;
border-color: transparent;
}
`}
</style>
<div class="demo-container">
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
const map = elementArg.querySelector('dees-geo-map') as DeesGeoMap;
const eventLog = elementArg.querySelector('#event-log') as HTMLElement;
const featureJson = elementArg.querySelector('#feature-json') as HTMLElement;
const addLogEntry = (type: string, message: string) => {
const entry = document.createElement('div');
entry.className = 'event-entry';
entry.innerHTML = `<span class="event-type">${type}</span>: ${message}`;
eventLog.insertBefore(entry, eventLog.firstChild);
};
const updateFeatureDisplay = () => {
if (map && featureJson) {
featureJson.textContent = JSON.stringify(map.getGeoJson(), null, 2);
}
};
if (map) {
map.addEventListener('map-ready', () => {
addLogEntry('ready', 'Map initialized successfully');
});
map.addEventListener('draw-change', (e: CustomEvent) => {
addLogEntry('change', `${e.detail.type} - ${e.detail.ids.length} feature(s) affected`);
updateFeatureDisplay();
});
map.addEventListener('draw-finish', (e: CustomEvent) => {
addLogEntry('finish', `${e.detail.context.mode} completed (id: ${e.detail.id})`);
});
map.addEventListener('map-move', (e: CustomEvent) => {
console.log('Map moved:', e.detail);
});
map.addEventListener('address-selected', (e: CustomEvent) => {
addLogEntry('address', `Selected: ${e.detail.address.substring(0, 50)}...`);
console.log('Address selected:', e.detail);
});
map.addEventListener('route-calculated', (e: CustomEvent) => {
const { route, mode } = e.detail;
const distKm = (route.distance / 1000).toFixed(1);
const durationMin = Math.round(route.duration / 60);
addLogEntry('route', `${mode}: ${distKm} km, ${durationMin} min`);
console.log('Route calculated:', e.detail);
});
}
// Set up navigation buttons
const locations: Record<string, [number, number]> = {
paris: [2.3522, 48.8566],
london: [-0.1276, 51.5074],
newyork: [-74.006, 40.7128],
tokyo: [139.6917, 35.6895],
sydney: [151.2093, -33.8688],
rio: [-43.1729, -22.9068],
};
Object.entries(locations).forEach(([name, coords]) => {
const btn = elementArg.querySelector(`#nav-${name}`) as HTMLButtonElement;
if (btn && map) {
btn.addEventListener('click', () => map.flyTo(coords, 13));
}
});
// Set up control buttons
const clearBtn = elementArg.querySelector('#btn-clear') as HTMLButtonElement;
const fitBtn = elementArg.querySelector('#btn-fit') as HTMLButtonElement;
const downloadBtn = elementArg.querySelector('#btn-download') as HTMLButtonElement;
const loadBtn = elementArg.querySelector('#btn-load') as HTMLButtonElement;
if (clearBtn && map) {
clearBtn.addEventListener('click', () => {
map.clearAllFeatures();
updateFeatureDisplay();
});
}
if (fitBtn && map) {
fitBtn.addEventListener('click', () => map.fitToFeatures());
}
if (downloadBtn && map) {
downloadBtn.addEventListener('click', () => {
const geojson = map.getGeoJson();
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'features.geojson';
a.click();
URL.revokeObjectURL(url);
});
}
if (loadBtn && map) {
loadBtn.addEventListener('click', () => {
map.loadGeoJson({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: { mode: 'polygon' },
geometry: {
type: 'Polygon',
coordinates: [[
[8.675, 50.115],
[8.690, 50.115],
[8.690, 50.105],
[8.675, 50.105],
[8.675, 50.115],
]],
},
},
],
});
updateFeatureDisplay();
});
}
}}>
<div class="demo-section">
<h2 class="demo-title">Interactive Map with Drawing Tools</h2>
<p class="demo-description">
Click on the drawing tools in the toolbar to create shapes on the map.
Use the Select tool to edit, move, or delete shapes. All features are
rendered using terra-draw with MapLibre GL JS.
</p>
<div class="map-wrapper">
<dees-geo-map
.center=${[8.6821, 50.1109] as [number, number]}
.zoom=${12}
.showSearch=${true}
.showNavigation=${true}
></dees-geo-map>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Quick Navigation</h2>
<div class="locations-grid">
<button class="location-button" id="nav-paris">Paris</button>
<button class="location-button" id="nav-london">London</button>
<button class="location-button" id="nav-newyork">New York</button>
<button class="location-button" id="nav-tokyo">Tokyo</button>
<button class="location-button" id="nav-sydney">Sydney</button>
<button class="location-button" id="nav-rio">Rio</button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Controls</h2>
<div class="controls-row">
<button class="control-button" id="btn-clear">Clear All Features</button>
<button class="control-button" id="btn-fit">Fit to Features</button>
<button class="control-button" id="btn-download">Download GeoJSON</button>
<button class="control-button" id="btn-load">Load Sample Data</button>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Event Log</h2>
<div class="event-log">
<div id="event-log">
<div class="event-entry"><span class="event-type">init</span>: Waiting for map...</div>
</div>
</div>
</div>
<div class="demo-section">
<h2 class="demo-title">Current Features (GeoJSON)</h2>
<div class="feature-display">
<pre class="feature-json" id="feature-json">{ "type": "FeatureCollection", "features": [] }</pre>
</div>
</div>
</dees-demowrapper>
</div>
`;

View File

@@ -0,0 +1,844 @@
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>
`;
}
}

View File

@@ -0,0 +1,48 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
/**
* Icon definitions for the geo-map component
* All icons are SVG templates using Lucide-style design
*/
export const GEO_MAP_ICONS: Record<string, TemplateResult> = {
// Drawing tools
point: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><circle cx="12" cy="12" r="8" stroke-dasharray="2 4"/></svg>`,
line: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20L20 4"/><circle cx="4" cy="20" r="2" fill="currentColor"/><circle cx="20" cy="4" r="2" fill="currentColor"/></svg>`,
polygon: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3L20 9L17 19H7L4 9L12 3Z"/></svg>`,
rectangle: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="6" width="16" height="12" rx="1"/></svg>`,
circle: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/></svg>`,
freehand: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17C8 13 10 19 14 15C18 11 20 17 20 17"/></svg>`,
// Edit tools
select: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4L10 20L13 13L20 10L4 4Z"/></svg>`,
trash: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6H21"/><path d="M8 6V4C8 3 9 2 10 2H14C15 2 16 3 16 4V6"/><path d="M19 6V20C19 21 18 22 17 22H7C6 22 5 21 5 20V6"/><path d="M10 11V17"/><path d="M14 11V17"/></svg>`,
// Zoom controls
plus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5V19"/><path d="M5 12H19"/></svg>`,
minus: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12H19"/></svg>`,
// Search
search: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21L16.65 16.65"/></svg>`,
spinner: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>`,
close: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18"/><path d="M6 6L18 18"/></svg>`,
// Navigation
navigation: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>`,
car: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.5 2.8C1.4 11.3 1 12.2 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`,
walk: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><path d="m9 20 3-6 3 6"/><path d="m6 8 3 3v6"/><path d="m18 8-3 3v6"/></svg>`,
bike: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18.5" cy="17.5" r="3.5"/><circle cx="5.5" cy="17.5" r="3.5"/><circle cx="15" cy="5" r="1"/><path d="M12 17.5V14l-3-3 4-3 2 3h2"/></svg>`,
mapPin: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>`,
route: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6v12"/><path d="M15 6v12"/><path d="M5 18h14"/><path d="M5 6h14"/></svg>`,
clock: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
ruler: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3c-1 1-2.5 1-3.4 0l-2.6-2.6c-1-1-1-2.5 0-3.4L15.3 2.7c1-1 2.5-1 3.4 0l2.6 2.6c1 1 1 2.5 0 3.4Z"/><path d="m7.5 10.5 2 2"/><path d="m10.5 7.5 2 2"/><path d="m13.5 4.5 2 2"/><path d="m4.5 13.5 2 2"/></svg>`,
error: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>`,
};
/**
* Render an icon by name
* @param name - The icon name from GEO_MAP_ICONS
* @returns The icon SVG template, or empty template if not found
*/
export const renderIcon = (name: string): TemplateResult => {
return GEO_MAP_ICONS[name] || html``;
};

View File

@@ -0,0 +1,943 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import maplibregl from 'maplibre-gl';
import { renderIcon } from './geo-map.icons.js';
import { type INominatimResult } from './geo-map.search.js';
// ─── Navigation/Routing Types ────────────────────────────────────────────────
export type TNavigationMode = 'driving' | 'walking' | 'cycling';
export interface IOSRMRoute {
geometry: GeoJSON.LineString;
distance: number; // meters
duration: number; // seconds
legs: IOSRMLeg[];
}
export interface IOSRMLeg {
steps: IOSRMStep[];
distance: number;
duration: number;
}
export interface IOSRMStep {
geometry: GeoJSON.LineString;
maneuver: {
type: string; // 'turn', 'depart', 'arrive', etc.
modifier?: string; // 'left', 'right', 'straight', etc.
location: [number, number];
};
name: string; // Street name
distance: number;
duration: number;
driving_side: string;
}
export interface INavigationState {
startPoint: [number, number] | null;
endPoint: [number, number] | null;
startAddress: string;
endAddress: string;
route: IOSRMRoute | null;
isLoading: boolean;
error: string | null;
}
export interface IRouteCalculatedEvent {
route: IOSRMRoute;
startPoint: [number, number];
endPoint: [number, number];
mode: TNavigationMode;
}
/**
* Callbacks for NavigationController events
*/
export interface INavigationControllerCallbacks {
onRouteCalculated: (event: IRouteCalculatedEvent) => void;
onRequestUpdate: () => void;
getMap: () => maplibregl.Map | null;
}
/**
* Controller for A-to-B navigation functionality
* Handles routing, markers, search inputs, and turn-by-turn directions
*/
export class NavigationController {
// State
public navigationState: INavigationState = {
startPoint: null,
endPoint: null,
startAddress: '',
endAddress: '',
route: null,
isLoading: false,
error: null,
};
// Navigation search state
public navStartSearchQuery: string = '';
public navEndSearchQuery: string = '';
public navStartSearchResults: INominatimResult[] = [];
public navEndSearchResults: INominatimResult[] = [];
public navActiveInput: 'start' | 'end' | null = null;
public navClickMode: 'start' | 'end' | null = null;
public navHighlightedIndex: number = -1;
// Mode
public navigationMode: TNavigationMode = 'driving';
// Internal
private callbacks: INavigationControllerCallbacks;
private navSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private startMarker: maplibregl.Marker | null = null;
private endMarker: maplibregl.Marker | null = null;
constructor(callbacks: INavigationControllerCallbacks) {
this.callbacks = callbacks;
}
// ─── Routing ────────────────────────────────────────────────────────────────
/**
* Fetch a route from OSRM API
*/
public async fetchRoute(
start: [number, number],
end: [number, number],
mode: TNavigationMode
): Promise<IOSRMRoute | null> {
const profile = mode === 'cycling' ? 'bike' : mode === 'walking' ? 'foot' : 'car';
const coords = `${start[0]},${start[1]};${end[0]},${end[1]}`;
const url = `https://router.project-osrm.org/route/v1/${profile}/${coords}?geometries=geojson&steps=true&overview=full`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`OSRM API error: ${response.status}`);
}
const data = await response.json();
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error(data.message || 'No route found');
}
const route = data.routes[0];
return {
geometry: route.geometry,
distance: route.distance,
duration: route.duration,
legs: route.legs,
};
} catch (error) {
console.error('Route fetch error:', error);
throw error;
}
}
/**
* Calculate and display route
*/
public async calculateRoute(): Promise<void> {
const { startPoint, endPoint } = this.navigationState;
if (!startPoint || !endPoint) {
this.navigationState = {
...this.navigationState,
error: 'Please set both start and end points',
};
this.callbacks.onRequestUpdate();
return;
}
this.navigationState = {
...this.navigationState,
isLoading: true,
error: null,
};
this.callbacks.onRequestUpdate();
try {
const route = await this.fetchRoute(startPoint, endPoint, this.navigationMode);
if (route) {
this.navigationState = {
...this.navigationState,
route,
isLoading: false,
};
this.renderRouteOnMap(route);
// Dispatch route-calculated event
this.callbacks.onRouteCalculated({
route,
startPoint,
endPoint,
mode: this.navigationMode,
});
// Fit map to route bounds
this.fitToRoute(route);
}
} catch (error) {
this.navigationState = {
...this.navigationState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to calculate route',
};
}
this.callbacks.onRequestUpdate();
}
// ─── Point Management ───────────────────────────────────────────────────────
/**
* Set navigation start point
*/
public setNavigationStart(coords: [number, number], address?: string): void {
this.navigationState = {
...this.navigationState,
startPoint: coords,
startAddress: address || `${coords[1].toFixed(5)}, ${coords[0].toFixed(5)}`,
error: null,
};
this.navStartSearchQuery = this.navigationState.startAddress;
this.navStartSearchResults = [];
this.updateNavigationMarkers();
this.callbacks.onRequestUpdate();
// Auto-calculate if both points are set
if (this.navigationState.endPoint) {
this.calculateRoute();
}
}
/**
* Set navigation end point
*/
public setNavigationEnd(coords: [number, number], address?: string): void {
this.navigationState = {
...this.navigationState,
endPoint: coords,
endAddress: address || `${coords[1].toFixed(5)}, ${coords[0].toFixed(5)}`,
error: null,
};
this.navEndSearchQuery = this.navigationState.endAddress;
this.navEndSearchResults = [];
this.updateNavigationMarkers();
this.callbacks.onRequestUpdate();
// Auto-calculate if both points are set
if (this.navigationState.startPoint) {
this.calculateRoute();
}
}
/**
* Clear all navigation state
*/
public clearNavigation(): void {
this.navigationState = {
startPoint: null,
endPoint: null,
startAddress: '',
endAddress: '',
route: null,
isLoading: false,
error: null,
};
this.navStartSearchQuery = '';
this.navEndSearchQuery = '';
this.navStartSearchResults = [];
this.navEndSearchResults = [];
this.navClickMode = null;
// Remove markers
if (this.startMarker) {
this.startMarker.remove();
this.startMarker = null;
}
if (this.endMarker) {
this.endMarker.remove();
this.endMarker = null;
}
// Remove route layer and source
const map = this.callbacks.getMap();
if (map) {
if (map.getLayer('route-layer')) {
map.removeLayer('route-layer');
}
if (map.getLayer('route-outline-layer')) {
map.removeLayer('route-outline-layer');
}
if (map.getSource('route-source')) {
map.removeSource('route-source');
}
}
this.callbacks.onRequestUpdate();
}
/**
* Clear a specific navigation point
*/
public clearNavPoint(pointType: 'start' | 'end'): void {
if (pointType === 'start') {
this.navigationState = {
...this.navigationState,
startPoint: null,
startAddress: '',
route: null,
};
this.navStartSearchQuery = '';
if (this.startMarker) {
this.startMarker.remove();
this.startMarker = null;
}
} else {
this.navigationState = {
...this.navigationState,
endPoint: null,
endAddress: '',
route: null,
};
this.navEndSearchQuery = '';
if (this.endMarker) {
this.endMarker.remove();
this.endMarker = null;
}
}
// Remove route display
const map = this.callbacks.getMap();
if (map) {
if (map.getLayer('route-layer')) {
map.removeLayer('route-layer');
}
if (map.getLayer('route-outline-layer')) {
map.removeLayer('route-outline-layer');
}
if (map.getSource('route-source')) {
map.removeSource('route-source');
}
}
this.callbacks.onRequestUpdate();
}
// ─── Map Interaction ────────────────────────────────────────────────────────
/**
* Handle map click for navigation point selection
*/
public handleMapClickForNavigation(e: maplibregl.MapMouseEvent): void {
if (!this.navClickMode) return;
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
if (this.navClickMode === 'start') {
this.setNavigationStart(coords);
} else if (this.navClickMode === 'end') {
this.setNavigationEnd(coords);
}
// Exit click mode
this.navClickMode = null;
// Re-enable map interactions
const map = this.callbacks.getMap();
if (map) {
map.getCanvas().style.cursor = '';
}
}
/**
* Toggle map click mode for setting navigation points
*/
public toggleNavClickMode(mode: 'start' | 'end'): void {
const map = this.callbacks.getMap();
if (this.navClickMode === mode) {
// Cancel click mode
this.navClickMode = null;
if (map) {
map.getCanvas().style.cursor = '';
}
} else {
this.navClickMode = mode;
if (map) {
map.getCanvas().style.cursor = 'crosshair';
}
}
this.callbacks.onRequestUpdate();
}
/**
* Update navigation markers on the map
*/
public updateNavigationMarkers(): void {
const map = this.callbacks.getMap();
if (!map) return;
// Update start marker
if (this.navigationState.startPoint) {
if (!this.startMarker) {
const el = document.createElement('div');
el.className = 'nav-marker nav-marker-start';
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#22c55e" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
this.startMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat(this.navigationState.startPoint)
.addTo(map);
} else {
this.startMarker.setLngLat(this.navigationState.startPoint);
}
} else if (this.startMarker) {
this.startMarker.remove();
this.startMarker = null;
}
// Update end marker
if (this.navigationState.endPoint) {
if (!this.endMarker) {
const el = document.createElement('div');
el.className = 'nav-marker nav-marker-end';
el.innerHTML = `<svg viewBox="0 0 24 24" fill="#ef4444" stroke="#fff" stroke-width="2"><circle cx="12" cy="12" r="8"/></svg>`;
el.style.cssText = 'width: 24px; height: 24px; cursor: pointer;';
this.endMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat(this.navigationState.endPoint)
.addTo(map);
} else {
this.endMarker.setLngLat(this.navigationState.endPoint);
}
} else if (this.endMarker) {
this.endMarker.remove();
this.endMarker = null;
}
}
/**
* Render route on the map
*/
public renderRouteOnMap(route: IOSRMRoute): void {
const map = this.callbacks.getMap();
if (!map) return;
const sourceId = 'route-source';
const layerId = 'route-layer';
const outlineLayerId = 'route-outline-layer';
// Remove existing layers/source
if (map.getLayer(layerId)) {
map.removeLayer(layerId);
}
if (map.getLayer(outlineLayerId)) {
map.removeLayer(outlineLayerId);
}
if (map.getSource(sourceId)) {
map.removeSource(sourceId);
}
// Add route source
map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: route.geometry,
},
});
// Add outline layer (for border effect)
map.addLayer({
id: outlineLayerId,
type: 'line',
source: sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#1e40af',
'line-width': 8,
'line-opacity': 0.8,
},
});
// Add main route layer
map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#3b82f6',
'line-width': 5,
'line-opacity': 1,
},
});
}
/**
* Fit map to show the entire route
*/
public fitToRoute(route: IOSRMRoute): void {
const map = this.callbacks.getMap();
if (!map || !route.geometry.coordinates.length) return;
const bounds = new maplibregl.LngLatBounds();
for (const coord of route.geometry.coordinates) {
bounds.extend(coord as [number, number]);
}
map.fitBounds(bounds, { padding: 80 });
}
// ─── Search within Navigation ───────────────────────────────────────────────
/**
* Search Nominatim API for addresses
*/
private async searchNominatim(query: string): Promise<INominatimResult[]> {
if (query.length < 3) return [];
const params = new URLSearchParams({
q: query,
format: 'json',
limit: '5',
addressdetails: '1',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: {
'User-Agent': 'dees-geo-map/1.0',
},
});
if (!response.ok) return [];
return response.json();
}
/**
* Handle navigation input search
*/
public handleNavSearchInput(event: Event, inputType: 'start' | 'end'): void {
const input = event.target as HTMLInputElement;
const query = input.value;
if (inputType === 'start') {
this.navStartSearchQuery = query;
} else {
this.navEndSearchQuery = query;
}
this.navActiveInput = inputType;
this.navHighlightedIndex = -1;
// Clear previous debounce
if (this.navSearchDebounceTimer) {
clearTimeout(this.navSearchDebounceTimer);
}
if (query.length < 3) {
if (inputType === 'start') {
this.navStartSearchResults = [];
} else {
this.navEndSearchResults = [];
}
this.callbacks.onRequestUpdate();
return;
}
// Debounce API calls
this.navSearchDebounceTimer = setTimeout(async () => {
const results = await this.searchNominatim(query);
if (inputType === 'start') {
this.navStartSearchResults = results;
} else {
this.navEndSearchResults = results;
}
this.callbacks.onRequestUpdate();
}, 500);
}
/**
* Handle navigation search result selection
*/
public selectNavSearchResult(result: INominatimResult, inputType: 'start' | 'end'): void {
const lng = parseFloat(result.lon);
const lat = parseFloat(result.lat);
const coords: [number, number] = [lng, lat];
const address = result.display_name;
if (inputType === 'start') {
this.setNavigationStart(coords, address);
this.navStartSearchResults = [];
} else {
this.setNavigationEnd(coords, address);
this.navEndSearchResults = [];
}
this.navActiveInput = null;
}
/**
* Handle keyboard navigation in search results
*/
public handleNavSearchKeydown(event: KeyboardEvent, inputType: 'start' | 'end'): void {
const results = inputType === 'start' ? this.navStartSearchResults : this.navEndSearchResults;
if (results.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.navHighlightedIndex = Math.min(this.navHighlightedIndex + 1, results.length - 1);
this.callbacks.onRequestUpdate();
break;
case 'ArrowUp':
event.preventDefault();
this.navHighlightedIndex = Math.max(this.navHighlightedIndex - 1, -1);
this.callbacks.onRequestUpdate();
break;
case 'Enter':
event.preventDefault();
if (this.navHighlightedIndex >= 0 && results[this.navHighlightedIndex]) {
this.selectNavSearchResult(results[this.navHighlightedIndex], inputType);
}
break;
case 'Escape':
event.preventDefault();
if (inputType === 'start') {
this.navStartSearchResults = [];
} else {
this.navEndSearchResults = [];
}
this.navActiveInput = null;
this.callbacks.onRequestUpdate();
break;
}
}
// ─── Formatting Utilities ───────────────────────────────────────────────────
/**
* Format distance for display
*/
public formatDistance(meters: number): string {
if (meters < 1000) {
return `${Math.round(meters)} m`;
}
return `${(meters / 1000).toFixed(1)} km`;
}
/**
* Format duration for display
*/
public formatDuration(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)} sec`;
}
if (seconds < 3600) {
return `${Math.round(seconds / 60)} min`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return mins > 0 ? `${hours} hr ${mins} min` : `${hours} hr`;
}
/**
* Get maneuver icon for turn type
*/
public getManeuverIcon(type: string, modifier?: string): string {
const icons: Record<string, string> = {
'depart': '⬆️',
'arrive': '🏁',
'turn-left': '↰',
'turn-right': '↱',
'turn-slight left': '↖',
'turn-slight right': '↗',
'turn-sharp left': '⬅',
'turn-sharp right': '➡',
'continue-straight': '⬆️',
'continue': '⬆️',
'roundabout': '🔄',
'rotary': '🔄',
'merge': '⤵️',
'fork-left': '↖',
'fork-right': '↗',
'end of road-left': '↰',
'end of road-right': '↱',
'new name': '⬆️',
'notification': '',
};
const key = modifier ? `${type}-${modifier}` : type;
return icons[key] || icons[type] || '➡';
}
/**
* Format step instruction for display
*/
public formatStepInstruction(step: IOSRMStep): string {
const { type, modifier } = step.maneuver;
const name = step.name || 'unnamed road';
switch (type) {
case 'depart':
return `Head ${modifier || 'forward'} on ${name}`;
case 'arrive':
return modifier === 'left'
? `Arrive at your destination on the left`
: modifier === 'right'
? `Arrive at your destination on the right`
: `Arrive at your destination`;
case 'turn':
return `Turn ${modifier || ''} onto ${name}`;
case 'continue':
return `Continue on ${name}`;
case 'merge':
return `Merge ${modifier || ''} onto ${name}`;
case 'fork':
return `Take the ${modifier || ''} fork onto ${name}`;
case 'roundabout':
case 'rotary':
return `At the roundabout, take the exit onto ${name}`;
case 'end of road':
return `At the end of the road, turn ${modifier || ''} onto ${name}`;
case 'new name':
return `Continue onto ${name}`;
default:
return `${type} ${modifier || ''} on ${name}`.trim();
}
}
// ─── Mode ───────────────────────────────────────────────────────────────────
/**
* Change navigation mode and recalculate route if exists
*/
public setNavigationMode(mode: TNavigationMode): void {
this.navigationMode = mode;
this.callbacks.onRequestUpdate();
// Recalculate route if we have both points
if (this.navigationState.startPoint && this.navigationState.endPoint) {
this.calculateRoute();
}
}
// ─── Cleanup ────────────────────────────────────────────────────────────────
/**
* Clean up markers and resources
*/
public cleanup(): void {
if (this.startMarker) {
this.startMarker.remove();
this.startMarker = null;
}
if (this.endMarker) {
this.endMarker.remove();
this.endMarker = null;
}
if (this.navSearchDebounceTimer) {
clearTimeout(this.navSearchDebounceTimer);
this.navSearchDebounceTimer = null;
}
}
// ─── Rendering ──────────────────────────────────────────────────────────────
/**
* Render the navigation panel
*/
public render(): TemplateResult {
const { route, isLoading, error, startPoint, endPoint } = this.navigationState;
const canCalculate = startPoint && endPoint && !isLoading;
return html`
<div class="navigation-panel">
<div class="nav-header">
<div class="nav-header-icon">${renderIcon('navigation')}</div>
<span class="nav-header-title">Navigation</span>
</div>
<div class="nav-mode-selector">
<button
class="nav-mode-btn ${this.navigationMode === 'driving' ? 'active' : ''}"
@click=${() => this.setNavigationMode('driving')}
title="Driving"
>
${renderIcon('car')}
</button>
<button
class="nav-mode-btn ${this.navigationMode === 'walking' ? 'active' : ''}"
@click=${() => this.setNavigationMode('walking')}
title="Walking"
>
${renderIcon('walk')}
</button>
<button
class="nav-mode-btn ${this.navigationMode === 'cycling' ? 'active' : ''}"
@click=${() => this.setNavigationMode('cycling')}
title="Cycling"
>
${renderIcon('bike')}
</button>
</div>
<div class="nav-inputs">
${this.renderNavInput('start', 'Start point', this.navStartSearchQuery, this.navStartSearchResults)}
${this.renderNavInput('end', 'End point', this.navEndSearchQuery, this.navEndSearchResults)}
</div>
<div class="nav-actions">
<button
class="nav-action-btn primary"
?disabled=${!canCalculate}
@click=${() => this.calculateRoute()}
>
Get Route
</button>
<button
class="nav-action-btn secondary"
@click=${() => this.clearNavigation()}
>
Clear
</button>
</div>
${error ? html`
<div class="nav-error">
${renderIcon('error')}
<span>${error}</span>
</div>
` : ''}
${isLoading ? html`
<div class="nav-loading">
${renderIcon('spinner')}
<span>Calculating route...</span>
</div>
` : ''}
${route && !isLoading ? html`
<div class="nav-summary">
<div class="nav-summary-item">
${renderIcon('ruler')}
<span>${this.formatDistance(route.distance)}</span>
</div>
<div class="nav-summary-item">
${renderIcon('clock')}
<span>${this.formatDuration(route.duration)}</span>
</div>
</div>
<div class="nav-steps">
${this.renderTurnByTurn(route)}
</div>
` : ''}
</div>
`;
}
/**
* Render a navigation input field
*/
private renderNavInput(
inputType: 'start' | 'end',
placeholder: string,
query: string,
results: INominatimResult[]
): TemplateResult {
const hasValue = inputType === 'start'
? this.navigationState.startPoint !== null
: this.navigationState.endPoint !== null;
const isClickMode = this.navClickMode === inputType;
return html`
<div class="nav-input-group">
<div class="nav-input-marker ${inputType}"></div>
<div class="nav-input-wrapper">
<input
type="text"
class="nav-input ${hasValue ? 'has-value' : ''}"
placeholder="${placeholder}"
.value=${query}
@input=${(e: Event) => this.handleNavSearchInput(e, inputType)}
@keydown=${(e: KeyboardEvent) => this.handleNavSearchKeydown(e, inputType)}
@focus=${() => { this.navActiveInput = inputType; this.callbacks.onRequestUpdate(); }}
/>
${hasValue ? html`
<button
class="nav-input-clear"
@click=${() => this.clearNavPoint(inputType)}
title="Clear"
>
${renderIcon('close')}
</button>
` : ''}
<button
class="nav-set-map-btn ${isClickMode ? 'active' : ''}"
@click=${() => this.toggleNavClickMode(inputType)}
title="Click on map"
>
${renderIcon('mapPin')}
</button>
${results.length > 0 && this.navActiveInput === inputType ? html`
<div class="nav-search-results">
${results.map((result, index) => html`
<div
class="nav-search-result ${index === this.navHighlightedIndex ? 'highlighted' : ''}"
@click=${() => this.selectNavSearchResult(result, inputType)}
@mouseenter=${() => { this.navHighlightedIndex = index; this.callbacks.onRequestUpdate(); }}
>
<span class="nav-search-result-name">${result.display_name}</span>
<span class="nav-search-result-type">${result.type}</span>
</div>
`)}
</div>
` : ''}
</div>
</div>
`;
}
/**
* Render turn-by-turn directions
*/
private renderTurnByTurn(route: IOSRMRoute): TemplateResult {
if (!route.legs || route.legs.length === 0) {
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
}
const steps = route.legs.flatMap(leg => leg.steps);
if (steps.length === 0) {
return html`<div class="nav-empty-steps">No turn-by-turn directions available</div>`;
}
return html`
${steps.map(step => {
const icon = this.getManeuverIcon(step.maneuver.type, step.maneuver.modifier);
const instruction = this.formatStepInstruction(step);
const distance = this.formatDistance(step.distance);
return html`
<div class="nav-step">
<div class="nav-step-icon">${icon}</div>
<div class="nav-step-content">
<div class="nav-step-instruction">${instruction}</div>
<div class="nav-step-distance">${distance}</div>
</div>
</div>
`;
})}
`;
}
}

View File

@@ -0,0 +1,303 @@
import { html, type TemplateResult } from '@design.estate/dees-element';
import { renderIcon } from './geo-map.icons.js';
/**
* Result from Nominatim geocoding API
*/
export interface INominatimResult {
place_id: number;
licence: string;
osm_type: string;
osm_id: number;
boundingbox: [string, string, string, string]; // [south, north, west, east]
lat: string;
lon: string;
display_name: string;
class: string;
type: string;
importance: number;
}
/**
* Event fired when an address is selected from search results
*/
export interface IAddressSelectedEvent {
address: string;
coordinates: [number, number]; // [lng, lat]
boundingBox: [number, number, number, number]; // [south, north, west, east]
placeId: string;
type: string;
}
/**
* Configuration for SearchController
*/
export interface ISearchControllerConfig {
placeholder?: string;
debounceMs?: number;
minQueryLength?: number;
maxResults?: number;
}
/**
* Callback interface for SearchController events
*/
export interface ISearchControllerCallbacks {
onResultSelected: (result: INominatimResult, coordinates: [number, number], zoom: number) => void;
onRequestUpdate: () => void;
}
/**
* Reusable search controller for Nominatim geocoding
* Can be used for standalone search or within navigation inputs
*/
export class SearchController {
// State
public query: string = '';
public results: INominatimResult[] = [];
public isOpen: boolean = false;
public highlightedIndex: number = -1;
public isSearching: boolean = false;
// Config
private placeholder: string;
private debounceMs: number;
private minQueryLength: number;
private maxResults: number;
// Internal
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private callbacks: ISearchControllerCallbacks;
private boundClickOutsideHandler: ((e: MouseEvent) => void) | null = null;
constructor(config: ISearchControllerConfig, callbacks: ISearchControllerCallbacks) {
this.placeholder = config.placeholder ?? 'Search address...';
this.debounceMs = config.debounceMs ?? 500;
this.minQueryLength = config.minQueryLength ?? 3;
this.maxResults = config.maxResults ?? 5;
this.callbacks = callbacks;
}
/**
* Search Nominatim API for addresses
*/
public async search(query: string): Promise<INominatimResult[]> {
if (query.length < this.minQueryLength) return [];
const params = new URLSearchParams({
q: query,
format: 'json',
limit: String(this.maxResults),
addressdetails: '1',
});
const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
headers: {
'User-Agent': 'dees-geo-map/1.0',
},
});
if (!response.ok) return [];
return response.json();
}
/**
* Handle input event from search input
*/
public handleInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.query = input.value;
this.highlightedIndex = -1;
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.query.length < this.minQueryLength) {
this.results = [];
this.isOpen = false;
this.isSearching = false;
this.callbacks.onRequestUpdate();
return;
}
this.isSearching = true;
this.callbacks.onRequestUpdate();
this.debounceTimer = setTimeout(async () => {
const results = await this.search(this.query);
this.results = results;
this.isOpen = results.length > 0 || this.query.length >= this.minQueryLength;
this.isSearching = false;
this.callbacks.onRequestUpdate();
}, this.debounceMs);
}
/**
* Handle keyboard navigation in search results
*/
public handleKeydown(event: KeyboardEvent): void {
if (!this.isOpen) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.highlightedIndex = Math.min(
this.highlightedIndex + 1,
this.results.length - 1
);
this.callbacks.onRequestUpdate();
break;
case 'ArrowUp':
event.preventDefault();
this.highlightedIndex = Math.max(this.highlightedIndex - 1, -1);
this.callbacks.onRequestUpdate();
break;
case 'Enter':
event.preventDefault();
if (this.highlightedIndex >= 0 && this.results[this.highlightedIndex]) {
this.selectResult(this.results[this.highlightedIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.close();
break;
}
}
/**
* Select a search result
*/
public selectResult(result: INominatimResult): void {
const lng = parseFloat(result.lon);
const lat = parseFloat(result.lat);
const coordinates: [number, number] = [lng, lat];
const zoom = this.calculateZoomForResult(result);
this.callbacks.onResultSelected(result, coordinates, zoom);
this.close();
}
/**
* Handle focus on search input
*/
public handleFocus(): void {
if (this.results.length > 0 || this.query.length >= this.minQueryLength) {
this.isOpen = true;
this.callbacks.onRequestUpdate();
}
}
/**
* Clear search state
*/
public clear(): void {
this.close();
}
/**
* Close search dropdown
*/
public close(): void {
this.isOpen = false;
this.results = [];
this.query = '';
this.highlightedIndex = -1;
this.callbacks.onRequestUpdate();
}
/**
* Set query without triggering search (for external updates)
*/
public setQuery(query: string): void {
this.query = query;
this.results = [];
this.isOpen = false;
this.callbacks.onRequestUpdate();
}
/**
* Calculate appropriate zoom level based on result type
*/
public calculateZoomForResult(result: INominatimResult): number {
const type = result.type;
const osmClass = result.class;
// Zoom levels based on place type
if (osmClass === 'boundary' && type === 'administrative') {
// Use importance to determine administrative level
if (result.importance > 0.8) return 5; // Country
if (result.importance > 0.6) return 7; // State/Region
if (result.importance > 0.4) return 10; // County
return 12; // City/Town
}
const zoomByType: Record<string, number> = {
country: 5,
state: 7,
region: 7,
county: 10,
city: 12,
town: 13,
village: 14,
suburb: 14,
neighbourhood: 15,
street: 16,
road: 16,
house: 18,
building: 18,
};
return zoomByType[type] ?? 15;
}
/**
* Render the search component
*/
public render(containerRef?: Element | null): TemplateResult {
return html`
<div class="search-container">
<div class="search-input-wrapper">
<div class="search-icon">
${renderIcon('search')}
</div>
<input
type="text"
class="search-input"
placeholder="${this.placeholder}"
.value=${this.query}
@input=${(e: Event) => this.handleInput(e)}
@keydown=${(e: KeyboardEvent) => this.handleKeydown(e)}
@focus=${() => this.handleFocus()}
/>
${this.isSearching ? html`
<div class="search-spinner">
${renderIcon('spinner')}
</div>
` : this.query ? html`
<button class="search-clear" @click=${() => this.clear()} title="Clear">
${renderIcon('close')}
</button>
` : ''}
</div>
${this.isOpen ? html`
<div class="search-results">
${this.results.length > 0 ? this.results.map((result, index) => html`
<div
class="search-result ${index === this.highlightedIndex ? 'highlighted' : ''}"
@click=${() => this.selectResult(result)}
@mouseenter=${() => { this.highlightedIndex = index; this.callbacks.onRequestUpdate(); }}
>
<span class="search-result-name">${result.display_name}</span>
<span class="search-result-type">${result.type}</span>
</div>
`) : this.query.length >= this.minQueryLength && !this.isSearching ? html`
<div class="search-no-results">No results found</div>
` : ''}
</div>
` : ''}
</div>
`;
}
}

View File

@@ -0,0 +1,7 @@
// Main component
export * from './dees-geo-map.js';
// Modular exports for external use
export { renderIcon, GEO_MAP_ICONS } from './geo-map.icons.js';
export { SearchController, type INominatimResult, type IAddressSelectedEvent, type ISearchControllerConfig, type ISearchControllerCallbacks } from './geo-map.search.js';
export { NavigationController, type TNavigationMode, type INavigationState, type IOSRMRoute, type IOSRMLeg, type IOSRMStep, type IRouteCalculatedEvent, type INavigationControllerCallbacks } from './geo-map.navigation.js';

View File

@@ -0,0 +1 @@
export * from './dees-geo-map/index.js';