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

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# npm
node_modules/
package-lock.json
# build output
dist/
dist_*/
# ide
.idea/
.nogit/
# os generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# cache
.cache/
# test output
coverage/
.nyc_output/
.playwright-mcp/

28
html/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!--gitzone element-->
<!-- made by Task Venture Capital GmbH -->
<!-- checkout https://maintainedby.lossless.com for awesome OpenSource projects -->
<html lang="en">
<head>
<!--Lets set some basic meta tags-->
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!--Lets load standard fonts-->
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
<link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
<style>
body {
margin: 0px;
background: #222222;
}
</style>
<script type="module" src="/bundle.js"></script>
</head>
<body>
</body>
</html>

9
html/index.ts Normal file
View File

@@ -0,0 +1,9 @@
// dees tools
import * as deesWccTools from '@design.estate/dees-wcctools';
import * as deesDomTools from '@design.estate/dees-domtools';
// elements
import { DeesGeoMap } from '../ts_web/index.js';
deesWccTools.setupWccTools({ DeesGeoMap } as any, {});
deesDomTools.elementBasic.setup();

21
license.md Normal file
View File

@@ -0,0 +1,21 @@
# MIT License
Copyright (c) 2024 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

51
npmextra.json Normal file
View File

@@ -0,0 +1,51 @@
{
"@git.zone/cli": {
"projectType": "wcc",
"module": {
"githost": "code.foss.global",
"gitscope": "design.estate",
"gitrepo": "dees-catalog-geo",
"description": "A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools",
"npmPackagename": "@design.estate/dees-catalog-geo",
"license": "MIT",
"projectDomain": "design.estate",
"keywords": [
"Web Components",
"Geospatial",
"Maps",
"MapLibre",
"terra-draw",
"Drawing",
"Geographic Data",
"TypeScript",
"LitElement"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_bundle/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild"
}
]
},
"@git.zone/tswatch": {
"preset": "element"
}
}

59
package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "@design.estate/dees-catalog-geo",
"version": "1.0.0",
"private": false,
"description": "A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools",
"main": "dist_ts_web/index.js",
"typings": "dist_ts_web/index.d.ts",
"type": "module",
"scripts": {
"test": "tstest test/ --web --verbose --timeout 30",
"build": "tsbuild tsfolders --allowimplicitany && tsbundle",
"watch": "tswatch",
"buildDocs": "tsdoc"
},
"author": "Lossless GmbH",
"license": "MIT",
"dependencies": {
"@design.estate/dees-catalog": "^3.42.0",
"@design.estate/dees-domtools": "^2.3.8",
"@design.estate/dees-element": "^2.1.6",
"maplibre-gl": "^5.1.1",
"terra-draw": "^1.24.0",
"terra-draw-maplibre-gl-adapter": "^1.0.0"
},
"devDependencies": {
"@design.estate/dees-wcctools": "^3.8.0",
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.8.3",
"@git.zone/tstest": "^3.1.8",
"@git.zone/tswatch": "^3.0.1",
"@push.rocks/projectinfo": "^5.0.2",
"@types/node": "^25.2.0"
},
"files": [
"ts/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_ts/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"npmextra.json",
"readme.md"
],
"browserslist": [
"last 1 chrome versions"
],
"keywords": [
"Web Components",
"Geospatial",
"Maps",
"MapLibre",
"terra-draw",
"Drawing",
"Geographic Data",
"TypeScript",
"LitElement"
]
}

9759
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

151
readme.hints.md Normal file
View File

@@ -0,0 +1,151 @@
# Project Hints - dees-catalog-geo
## Overview
Geospatial web components library using MapLibre GL JS for map rendering and terra-draw for drawing capabilities.
## Key Dependencies
- **maplibre-gl** (v5.x): WebGL-based vector map library
- **terra-draw** (v1.24.0): Modern drawing library with support for multiple map libraries
- **terra-draw-maplibre-gl-adapter** (v1.x): Adapter connecting terra-draw with MapLibre
## Component: dees-geo-map
### Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `center` | `[number, number]` | `[0, 0]` | Map center as [lng, lat] |
| `zoom` | `number` | `2` | Initial zoom level |
| `mapStyle` | `string` | `'osm'` | Map style ('osm' or custom URL) |
| `activeTool` | `TDrawTool` | `'static'` | Active drawing tool |
| `geoJson` | `GeoJSON.FeatureCollection` | `{...}` | Initial features |
| `showToolbar` | `boolean` | `true` | Show/hide drawing toolbar |
| `projection` | `'mercator' \| 'globe'` | `'mercator'` | Map projection type |
| `showSearch` | `boolean` | `false` | Show address search input |
| `showNavigation` | `boolean` | `false` | Show A-to-B navigation panel |
| `navigationMode` | `'driving' \| 'walking' \| 'cycling'` | `'driving'` | Transport mode for routing |
### Drawing Tools (TDrawTool)
- `point` - Draw points
- `linestring` - Draw lines
- `polygon` - Draw polygons
- `rectangle` - Draw rectangles
- `circle` - Draw circles
- `freehand` - Freehand drawing
- `select` - Select and edit features
- `static` - Pan/zoom only (no drawing)
### Events
- `map-ready` - Fired when map is initialized
- `map-move` - Fired on pan/zoom with center and zoom
- `draw-change` - Fired on any feature change
- `draw-finish` - Fired when a shape is completed
- `address-selected` - Fired when a search result is selected
- `route-calculated` - Fired when a navigation route is calculated (includes route, startPoint, endPoint, mode)
### Public Methods
- `getFeatures()` - Get all drawn features
- `getGeoJson()` - Get features as FeatureCollection
- `loadGeoJson(geojson)` - Load features from GeoJSON
- `clearAllFeatures()` - Remove all features
- `setTool(tool)` - Set active drawing tool
- `flyTo(center, zoom?)` - Animate to location
- `fitToFeatures(padding?)` - Fit view to all features
- `setProjection(projection)` - Set map projection ('mercator' or 'globe')
- `getMap()` - Get underlying MapLibre instance
- `getTerraDraw()` - Get TerraDraw instance
- `calculateRoute()` - Calculate route between start and end points
- `setNavigationStart(coords, address?)` - Set navigation start point
- `setNavigationEnd(coords, address?)` - Set navigation end point
- `clearNavigation()` - Clear all navigation state
### Context Menu
Right-click on the map to access a context menu with the following options:
- **Drag to Draw** - Toggle between drag mode (click-drag for circles/rectangles) and two-click mode
- **Globe View** - Toggle between globe (3D sphere) and Mercator (flat) projection
- **Clear All Features** - Remove all drawn features from the map
- **Fit to Features** - Zoom and pan to show all drawn features
### Navigation Feature
The navigation panel (`showNavigation={true}`) provides A-to-B routing using OSRM (Open Source Routing Machine):
- **Transport modes**: Driving, Walking, Cycling
- **Point selection**: Type an address or click on the map
- **Route display**: Blue line overlay with turn-by-turn directions
- **API**: Uses free OSRM API (https://router.project-osrm.org) with fair-use rate limit
## Development
- `pnpm install` - Install dependencies
- `pnpm watch` - Start development server (port 3002)
- `pnpm build` - Build for production
## File Structure
```
ts_web/
├── index.ts # Main exports
├── 00_commitinfo_data.ts # Auto-generated
└── elements/
├── index.ts # Elements barrel
├── 00colors.ts # Color definitions
├── 00componentstyles.ts # Shared styles
└── 00group-map/
├── index.ts
└── dees-geo-map/
├── index.ts # Exports main + modules
├── dees-geo-map.ts # Main component (~550 lines)
├── dees-geo-map.demo.ts # Demo function
├── geo-map.icons.ts # Icon SVG definitions (~60 lines)
├── geo-map.search.ts # SearchController class (~180 lines)
└── geo-map.navigation.ts # NavigationController class (~530 lines)
```
## Modular Architecture
The component was refactored for better maintainability:
### geo-map.icons.ts
Contains all SVG icon definitions as a `GEO_MAP_ICONS` record and a `renderIcon(name)` helper function.
### geo-map.search.ts
`SearchController` class encapsulating Nominatim geocoding search:
- Reusable for standalone search or within navigation
- Debounced API calls
- Keyboard navigation support
- Customizable via `ISearchControllerConfig`
### geo-map.navigation.ts
`NavigationController` class for A-to-B routing:
- OSRM routing API integration
- Start/end point management with markers
- Map click mode for point selection
- Turn-by-turn directions rendering
- Route overlay on map
### Usage of Controllers
```typescript
// SearchController is reusable
const search = new SearchController(
{ placeholder: 'Search...' },
{
onResultSelected: (result, coords, zoom) => { /* handle */ },
onRequestUpdate: () => this.requestUpdate(),
}
);
// NavigationController manages all navigation state
const nav = new NavigationController({
onRouteCalculated: (event) => { /* dispatch */ },
onRequestUpdate: () => this.requestUpdate(),
getMap: () => this.map,
});
```
## Notes
- MapLibre CSS is loaded dynamically from CDN
- Terra-draw requires the separate maplibre-gl-adapter package
- The component uses Shadow DOM for style encapsulation
### Shadow DOM & Terra-Draw Drawing Fix
Terra-draw's event listeners normally intercept map events through MapLibre's canvas element. In Shadow DOM contexts, these events are scoped locally and don't propagate correctly, causing terra-draw handlers to fail while MapLibre's drag handlers continue working.
**Solution**: Manual drag coordination in `setTool()`:
- When a drawing tool is active (`polygon`, `rectangle`, `point`, `linestring`, `circle`, `freehand`), MapLibre's `dragPan` and `dragRotate` are disabled
- When `static` or `select` mode is active, dragging is re-enabled
- The `TerraDrawMapLibreGLAdapter` does NOT accept a `lib` parameter - only `map` is required

View File

@@ -0,0 +1,9 @@
/**
* This file is auto-generated by git.zone build tools.
* Do not modify manually.
*/
export const commitinfo = {
name: '@design.estate/dees-catalog-geo',
version: '1.0.0',
description: 'A geospatial web components library with MapLibre GL JS maps and terra-draw drawing tools',
};

View File

@@ -0,0 +1,43 @@
/**
* Color palette for geo components
*/
export const geoColors = {
// Map UI colors
toolbarBackground: 'rgba(30, 30, 30, 0.9)',
toolbarBackgroundLight: 'rgba(255, 255, 255, 0.95)',
toolbarBorder: 'rgba(255, 255, 255, 0.1)',
toolbarBorderLight: 'rgba(0, 0, 0, 0.1)',
// Tool button states
toolActive: '#0084ff',
toolHover: 'rgba(255, 255, 255, 0.1)',
toolHoverLight: 'rgba(0, 0, 0, 0.05)',
// Drawing colors
drawPolygon: '#3b82f6',
drawRectangle: '#10b981',
drawPoint: '#ef4444',
drawLine: '#f59e0b',
drawCircle: '#8b5cf6',
drawFreehand: '#ec4899',
// Feature states
featureSelected: '#0084ff',
featureHover: '#60a5fa',
// Attribution
attributionText: 'rgba(255, 255, 255, 0.6)',
attributionTextLight: 'rgba(0, 0, 0, 0.6)',
};
export const dark = {
...geoColors,
};
export const bright = {
...geoColors,
toolbarBackground: geoColors.toolbarBackgroundLight,
toolbarBorder: geoColors.toolbarBorderLight,
toolHover: geoColors.toolHoverLight,
attributionText: geoColors.attributionTextLight,
};

View File

@@ -0,0 +1,689 @@
import { css } from '@design.estate/dees-element';
/**
* Shared component styles for geo components
*/
export const geoComponentStyles = css`
:host {
display: block;
box-sizing: border-box;
}
:host([hidden]) {
display: none;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
`;
/**
* Map container styles
*/
export const mapContainerStyles = css`
.map-container {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
overflow: hidden;
border-radius: 8px;
}
.map-wrapper {
position: absolute;
inset: 0;
}
`;
/**
* Toolbar styles
*/
export const toolbarStyles = css`
.toolbar {
position: absolute;
top: 12px;
left: 12px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.toolbar-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.toolbar-divider {
height: 1px;
margin: 4px 0;
background: var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
}
.tool-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--geo-text, #fff);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.tool-button:hover {
background: var(--geo-tool-hover, rgba(255, 255, 255, 0.1));
}
.tool-button.active {
background: var(--geo-tool-active, #0084ff);
color: #fff;
}
.tool-button svg {
width: 20px;
height: 20px;
}
.tool-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
/**
* Search styles for address search functionality
*/
export const searchStyles = css`
.search-container {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
width: 280px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.9));
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
overflow: hidden;
}
.search-input-wrapper:focus-within {
border-color: var(--geo-tool-active, #0084ff);
}
.search-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.search-icon svg {
width: 16px;
height: 16px;
}
.search-input {
flex: 1;
height: 36px;
padding: 0 12px 0 0;
border: none;
background: transparent;
color: var(--geo-text, #fff);
font-size: 13px;
font-family: inherit;
outline: none;
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.search-spinner {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
}
.search-spinner svg {
width: 16px;
height: 16px;
color: rgba(255, 255, 255, 0.5);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-right: 4px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease, color 0.15s ease;
}
.search-clear:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.search-clear svg {
width: 14px;
height: 14px;
}
.search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 300px;
overflow-y: auto;
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.95));
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.search-results:empty {
display: none;
}
.search-result {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 12px;
cursor: pointer;
transition: background-color 0.1s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.search-result:last-child {
border-bottom: none;
}
.search-result:hover,
.search-result.highlighted {
background: rgba(255, 255, 255, 0.1);
}
.search-result-name {
font-size: 13px;
color: var(--geo-text, #fff);
line-height: 1.3;
}
.search-result-type {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
text-transform: capitalize;
}
.search-no-results {
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
`;
/**
* Navigation panel styles for A-to-B routing
*/
export const navigationStyles = css`
.navigation-panel {
position: absolute;
top: 12px;
left: 60px;
z-index: 10;
width: 300px;
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.95));
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
overflow: hidden;
}
.nav-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--geo-tool-active, #0084ff);
}
.nav-header-icon svg {
width: 18px;
height: 18px;
}
.nav-header-title {
font-size: 13px;
font-weight: 600;
color: var(--geo-text, #fff);
}
.nav-mode-selector {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-mode-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.nav-mode-btn.active {
background: var(--geo-tool-active, #0084ff);
color: #fff;
}
.nav-mode-btn svg {
width: 16px;
height: 16px;
}
.nav-inputs {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.nav-input-group {
display: flex;
align-items: center;
gap: 8px;
}
.nav-input-marker {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.nav-input-marker.start {
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.3);
}
.nav-input-marker.end {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.3);
}
.nav-input-wrapper {
flex: 1;
position: relative;
}
.nav-input {
width: 100%;
height: 36px;
padding: 0 36px 0 12px;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
background: rgba(0, 0, 0, 0.2);
color: var(--geo-text, #fff);
font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color 0.15s ease;
}
.nav-input:focus {
border-color: var(--geo-tool-active, #0084ff);
}
.nav-input::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.nav-input.has-value {
padding-right: 60px;
}
.nav-set-map-btn {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-set-map-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.nav-set-map-btn.active {
background: var(--geo-tool-active, #0084ff);
color: #fff;
}
.nav-set-map-btn svg {
width: 14px;
height: 14px;
}
.nav-input-clear {
position: absolute;
right: 32px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: rgba(255, 255, 255, 0.4);
cursor: pointer;
transition: color 0.15s ease;
}
.nav-input-clear:hover {
color: rgba(255, 255, 255, 0.8);
}
.nav-input-clear svg {
width: 12px;
height: 12px;
}
.nav-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: var(--geo-toolbar-bg, rgba(30, 30, 30, 0.98));
border: 1px solid var(--geo-toolbar-border, rgba(255, 255, 255, 0.1));
border-radius: 6px;
z-index: 100;
}
.nav-search-result {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.1s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.nav-search-result:last-child {
border-bottom: none;
}
.nav-search-result:hover,
.nav-search-result.highlighted {
background: rgba(255, 255, 255, 0.1);
}
.nav-search-result-name {
font-size: 12px;
color: var(--geo-text, #fff);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-search-result-type {
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
text-transform: capitalize;
}
.nav-actions {
display: flex;
gap: 8px;
padding: 0 12px 12px;
}
.nav-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.nav-action-btn.primary {
background: var(--geo-tool-active, #0084ff);
color: #fff;
}
.nav-action-btn.primary:hover:not(:disabled) {
background: #0073e6;
}
.nav-action-btn.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nav-action-btn.secondary {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
.nav-action-btn.secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.nav-action-btn svg {
width: 16px;
height: 16px;
}
.nav-summary {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 12px;
background: rgba(0, 132, 255, 0.1);
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-summary-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: var(--geo-text, #fff);
}
.nav-summary-item svg {
width: 16px;
height: 16px;
color: var(--geo-tool-active, #0084ff);
}
.nav-steps {
max-height: 250px;
overflow-y: auto;
}
.nav-step {
display: flex;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: background-color 0.15s ease;
}
.nav-step:last-child {
border-bottom: none;
}
.nav-step:hover {
background: rgba(255, 255, 255, 0.05);
}
.nav-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
flex-shrink: 0;
}
.nav-step-content {
flex: 1;
min-width: 0;
}
.nav-step-instruction {
font-size: 13px;
color: var(--geo-text, #fff);
line-height: 1.4;
margin-bottom: 2px;
}
.nav-step-distance {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
.nav-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(239, 68, 68, 0.15);
border-top: 1px solid rgba(239, 68, 68, 0.3);
color: #fca5a5;
font-size: 12px;
}
.nav-error svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.nav-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.nav-loading svg {
width: 18px;
height: 18px;
animation: spin 1s linear infinite;
}
.nav-empty-steps {
padding: 16px 12px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
}
`;

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';

4
ts_web/elements/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './00componentstyles.js';
// Map Components
export * from './00group-map/index.js';

4
ts_web/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './elements/index.js';
import * as colors from './elements/00colors.js';
export { colors };
export { commitinfo } from './00_commitinfo_data.js';

13
tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"exclude": [
"dist_*/**/*.d.ts"
]
}