initial
This commit is contained in:
318
ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts
Normal file
318
ts_web/elements/00group-map/dees-geo-map/dees-geo-map.demo.ts
Normal 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>
|
||||
`;
|
||||
844
ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts
Normal file
844
ts_web/elements/00group-map/dees-geo-map/dees-geo-map.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
48
ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts
Normal file
48
ts_web/elements/00group-map/dees-geo-map/geo-map.icons.ts
Normal 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``;
|
||||
};
|
||||
943
ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts
Normal file
943
ts_web/elements/00group-map/dees-geo-map/geo-map.navigation.ts
Normal 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>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
}
|
||||
303
ts_web/elements/00group-map/dees-geo-map/geo-map.search.ts
Normal file
303
ts_web/elements/00group-map/dees-geo-map/geo-map.search.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
7
ts_web/elements/00group-map/dees-geo-map/index.ts
Normal file
7
ts_web/elements/00group-map/dees-geo-map/index.ts
Normal 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';
|
||||
1
ts_web/elements/00group-map/index.ts
Normal file
1
ts_web/elements/00group-map/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './dees-geo-map/index.js';
|
||||
Reference in New Issue
Block a user