Add custom icon support and routing functionality to map adapters

- Add icon parameter to IMapAdapter.addMarker() supporting DivIcon (Leaflet) and HTMLElement (MapLibre)
- Implement routing with leaflet-routing-machine and @maplibre/maplibre-gl-directions
- Add marker management methods: hasMarker, updateMarkerIcon, updateMarkerPopup, addMarkerClickHandler
- Add route management methods: addRoute, updateRoute, removeRoute, removeAllRoutes
- Update MapFacade to proxy all new marker and route management methods
- Add RouteOptions interface with customizable line styles and waypoint options

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: David Nguyen <david.nguyen@goutezplanb.com>
This commit is contained in:
2025-10-30 23:01:58 -04:00
parent 2cc110adc3
commit 30cc53f9ad
8 changed files with 438 additions and 21 deletions
+3 -1
View File
@@ -20,7 +20,9 @@
"@angular/common": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"@angular/core": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"maplibre-gl": "^5.0.0",
"leaflet": "^1.0.0 || ^2.0.0"
"leaflet": "^1.0.0 || ^2.0.0",
"leaflet-routing-machine": "^3.0.0",
"@maplibre/maplibre-gl-directions": "^0.8.0"
},
"dependencies": {
"tslib": "^2.3.0"
@@ -1,16 +1,19 @@
import {
import L,{
Map,
TileLayer,
Marker,
Popup,
polygon
polygon, DivIcon
} from 'leaflet';
import 'leaflet-routing-machine';
import {
getLngLat,
IMapAdapter,
LatLng,
MapOptions, Zone,
MapOptions,
Zone,
RouteOptions,
} from './map-adapter.interface';
export class LeafletAdapter implements IMapAdapter<Marker> {
@@ -18,6 +21,7 @@ export class LeafletAdapter implements IMapAdapter<Marker> {
private deliveryCheckMarker?: Marker;
private markers: Record<string, Marker> = {};
private popup?: Popup;
private routes: Record<string, any> = {};
init(container: HTMLElement, options: MapOptions): void {
this.map = new Map(container).setView(options.center, options.zoom);
@@ -35,22 +39,26 @@ export class LeafletAdapter implements IMapAdapter<Marker> {
this.map.setZoom(zoom);
}
addMarker(latLng: LatLng, options: { id?: string; color?: string }) {
if (options.id) {
addMarker(latLng: LatLng, options?: { id?: string; color?: string ; icon? : DivIcon }) {
const markerOptions: L.MarkerOptions = {};
if (options?.icon) {
markerOptions.icon = options.icon;
}
if (options?.id) {
if (this.markers[options.id]) {
this.map.removeLayer(this.markers[options.id]);
}
const marker = new Marker(latLng)
const marker = new Marker(latLng, markerOptions);
marker.addTo(this.map);
this.markers[options.id] = marker;
} else {
if (this.deliveryCheckMarker) {
this.map.removeLayer(this.deliveryCheckMarker);
}
this.deliveryCheckMarker = new Marker(latLng).addTo(this.map);
this.deliveryCheckMarker = new Marker(latLng, markerOptions).addTo(this.map);
}
}
getMarker(id: string): Marker | undefined {
return this.markers[id];
}
@@ -72,6 +80,31 @@ export class LeafletAdapter implements IMapAdapter<Marker> {
}
}
}
hasMarker(id: string): boolean {
return !!this.markers[id];
}
updateMarkerIcon(id: string, icon: any): void {
const marker = this.markers[id];
if (marker) {
marker.setIcon(icon);
}
}
updateMarkerPopup(id: string, popup: any): void {
const marker = this.markers[id];
if (marker) {
marker.bindPopup(popup);
}
}
addMarkerClickHandler(id: string, handler: (e: any) => void): void {
const marker = this.markers[id];
if (marker) {
marker.on('click', handler);
}
}
addZone(zones: Zone[]): void
{
this.updateZone(zones);
@@ -128,12 +161,67 @@ export class LeafletAdapter implements IMapAdapter<Marker> {
this.popup = undefined;
}
}
addRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.removeRoute(id);
const routeOptions: any = {
waypoints: waypoints.map(wp => L.latLng(wp[0], wp[1])),
lineOptions: {
styles: [
{
color: options?.lineStyle?.color || '#3388ff',
opacity: options?.lineStyle?.opacity ?? 1,
weight: options?.lineStyle?.weight || 4
}
],
extendToWaypoints: false,
addWaypoints: false,
missingRouteTolerance: 10
},
createMarker: options?.showMarkers ? undefined : () => false,
draggableWaypoints: options?.draggableWaypoints ?? false,
routeWhileDragging: false,
summaryTemplate: '',
distanceTemplate: '',
timeTemplate: '',
show: true
};
if (options?.serviceUrl) {
console.log('serviceUrl', options);
routeOptions.router = L.Routing.osrmv1({
serviceUrl: options.serviceUrl
});
}
const control = L.Routing
.control(routeOptions)
.addTo(this.map);
this.routes[id] = control;
}
updateRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.addRoute(id, waypoints, options);
}
removeRoute(id: string): void {
if (this.routes[id]) {
this.routes[id].remove();
delete this.routes[id];
}
}
removeAllRoutes(): void {
Object.keys(this.routes).forEach(id => this.removeRoute(id));
}
on(type: string, event: (e: any) => void): void
{
this.map.on(type, event);
}
destroy(): void {
this.removeAllRoutes();
this.map.remove();
}
}
@@ -1,11 +1,13 @@
import { Map, Marker, NavigationControl, GeoJSONSource, Popup } from 'maplibre-gl';
import {getLngLat, IMapAdapter, LatLng, MapOptions, Zone} from './map-adapter.interface';
import {getLngLat, IMapAdapter, LatLng, MapOptions, Zone, RouteOptions} from './map-adapter.interface';
import MapLibreGlDirections from "@maplibre/maplibre-gl-directions";
export class LibreAdapter implements IMapAdapter<Marker> {
private map!: Map;
private deliveryCheckMarker?: Marker;
private markers: Record<string, Marker> = {};
private popup?: Popup;
private routes: Record<string, any> = {};
private routeLayers: Record<string, [string, string]> = {};
init(container: HTMLElement, options: MapOptions): void {
this.map = new Map({
container,
@@ -25,15 +27,24 @@ export class LibreAdapter implements IMapAdapter<Marker> {
this.map.setZoom(zoom);
}
addMarker(latLng: LatLng, options: { id?: string; color?: string }) {
addMarker(latLng: LatLng, options?: { id?: string; color?: string; icon?: HTMLElement }) {
const coords = getLngLat(latLng);
if (options.id) {
const markerOptions: any = {};
if (options?.icon) {
markerOptions.element = options.icon;
} else if (options?.color) {
markerOptions.color = options.color;
} else {
markerOptions.color = 'red';
}
if (options?.id) {
if (this.markers[options.id]) {
this.markers[options.id].remove();
}
const marker = new Marker({ color: options.color || 'red' })
const marker = new Marker(markerOptions)
.setLngLat(coords)
.addTo(this.map);
this.markers[options.id] = marker;
@@ -41,7 +52,7 @@ export class LibreAdapter implements IMapAdapter<Marker> {
if (this.deliveryCheckMarker) {
this.deliveryCheckMarker.remove();
}
this.deliveryCheckMarker = new Marker({ color: options?.color || 'red' })
this.deliveryCheckMarker = new Marker(markerOptions)
.setLngLat(coords)
.addTo(this.map);
}
@@ -71,6 +82,31 @@ export class LibreAdapter implements IMapAdapter<Marker> {
}
}
}
hasMarker(id: string): boolean {
return !!this.markers[id];
}
updateMarkerIcon(id: string, icon: HTMLElement): void {
const marker = this.markers[id];
if (marker) {
marker.getElement().replaceWith(icon);
}
}
updateMarkerPopup(id: string, popup: Popup): void {
const marker = this.markers[id];
if (marker) {
marker.setPopup(popup);
}
}
addMarkerClickHandler(id: string, handler: (e: any) => void): void {
const marker = this.markers[id];
if (marker) {
marker.getElement().addEventListener('click', handler);
}
}
addZone(zones: Zone[]): void
{
this.updateZone(zones);
@@ -166,11 +202,95 @@ export class LibreAdapter implements IMapAdapter<Marker> {
this.popup = undefined;
}
}
addRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.removeRoute(id);
// Convert waypoints to GeoJSON LineString format [lng, lat]
const coordinates = waypoints.map(wp => getLngLat(wp));
const sourceId = `route-${id}`;
const layerId = `route-layer-${id}`;
// Add the route as a GeoJSON source
this.map.addSource(sourceId, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: coordinates
}
}
});
// Add the route layer
this.map.addLayer({
id: layerId,
type: 'line',
source: sourceId,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': options?.lineStyle?.color || '#3388ff',
'line-width': options?.lineStyle?.weight || 4,
'line-opacity': options?.lineStyle?.opacity ?? 1
}
});
this.routeLayers[id] = [sourceId, layerId];
// Optionally add markers for waypoints
if (options?.showMarkers) {
waypoints.forEach((wp, index) => {
const markerId = `${id}-waypoint-${index}`;
this.addMarker(wp, { id: markerId, color: options.lineStyle?.color || '#3388ff' });
this.routes[markerId] = true;
});
}
}
updateRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.addRoute(id, waypoints, options);
}
removeRoute(id: string): void {
if (this.routeLayers[id]) {
const [sourceId, layerId] = this.routeLayers[id];
if (this.map.getLayer(layerId)) {
this.map.removeLayer(layerId);
}
if (this.map.getSource(sourceId)) {
this.map.removeSource(sourceId);
}
delete this.routeLayers[id];
}
// Remove waypoint markers if they exist
Object.keys(this.routes).forEach(key => {
if (key.startsWith(`${id}-waypoint-`)) {
this.removeMarker(key);
delete this.routes[key];
}
});
}
removeAllRoutes(): void {
Object.keys(this.routeLayers).forEach(id => this.removeRoute(id));
}
on(type: string, event: (e: any) => void): void
{
this.map.on(type, event);
}
destroy(): void {
this.removeAllRoutes();
this.map.remove();
}
}
@@ -24,18 +24,47 @@ export function getLngLat(latLng: LatLng): [number, number] {
return [latLng[1], latLng[0]];
}
export interface RouteLineStyle {
color?: string;
opacity?: number;
weight?: number;
}
export interface RouteOptions {
serviceUrl?: string;
lineStyle?: RouteLineStyle;
showMarkers?: boolean;
draggableWaypoints?: boolean;
}
export interface IMapAdapter<TMarker = any> {
init(container: HTMLElement, options: MapOptions): void;
setCenter(latLng: LatLng): void;
setZoom(zoom: number): void;
addMarker(latLng: LatLng, options?: { color?: string }): void;
// Marker management
addMarker(latLng: LatLng, options?: {id?: string; color?: string; icon?: any}): void;
getMarker(id: string): TMarker | undefined;
getAllMarkers(): Record<string, TMarker>;
removeMarker(id?: string): void;
hasMarker(id: string): boolean;
updateMarkerIcon(id: string, icon: any): void;
updateMarkerPopup(id: string, popup: any): void;
addMarkerClickHandler(id: string, handler: (e: any) => void): void;
// Zone management
addZone(zone: Zone[]): void;
updateZone(zone: Zone[]): void;
openZonePopup(zone: Zone) : void
closePopup(): void;
// Route management
addRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void;
updateRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void;
removeRoute(id: string): void;
removeAllRoutes(): void;
// Utilities
destroy(): void;
on(
type: string,
@@ -1,4 +1,4 @@
import {IMapAdapter, MapOptions, LatLng, getLngLat, Zone} from './map-adapter.interface';
import {IMapAdapter, MapOptions, LatLng, getLngLat, Zone, RouteOptions} from './map-adapter.interface';
import { LibreAdapter } from './libre-adapter';
import { LeafletAdapter } from './leaflet-adapter';
@@ -30,7 +30,7 @@ export class MapFacade implements IMapAdapter<any> {
this.adapter.setZoom(zoom);
}
addMarker(latLng: LatLng, options: { id?: string; color?: string }): void {
addMarker(latLng: LatLng, options: { id?: string; color?: string; icon?: any}): void {
this.adapter.addMarker(latLng, options);
}
@@ -45,6 +45,23 @@ export class MapFacade implements IMapAdapter<any> {
removeMarker(id?: string): void {
this.adapter.removeMarker(id);
}
hasMarker(id: string): boolean {
return this.adapter.hasMarker(id);
}
updateMarkerIcon(id: string, icon: any): void {
this.adapter.updateMarkerIcon(id, icon);
}
updateMarkerPopup(id: string, popup: any): void {
this.adapter.updateMarkerPopup(id, popup);
}
addMarkerClickHandler(id: string, handler: (e: any) => void): void {
this.adapter.addMarkerClickHandler(id, handler);
}
addZone(zones: Zone[]): void
{
this.adapter.addZone(zones);
@@ -61,6 +78,23 @@ export class MapFacade implements IMapAdapter<any> {
{
this.adapter.closePopup();
}
addRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.adapter.addRoute(id, waypoints, options);
}
updateRoute(id: string, waypoints: LatLng[], options?: RouteOptions): void {
this.adapter.updateRoute(id, waypoints, options);
}
removeRoute(id: string): void {
this.adapter.removeRoute(id);
}
removeAllRoutes(): void {
this.adapter.removeAllRoutes();
}
on(type: string, event: (e: any) => void): void
{
this.adapter.on(type, event);