UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1 lines • 114 kB
{"version":3,"file":"c8y-ngx-components-map.mjs","sources":["../../map/map.model.ts","../../map/map.service.ts","../../map/cluster-map.ts","../../map/map-popup.directive.ts","../../map/map.component.ts","../../map/map.component.html","../../map/cluster-map.component.ts","../../map/cluster-map.component.html","../../map/map-status.component.ts","../../map/map-status.component.html","../../map/map.module.ts","../../map/c8y-ngx-components-map.ts"],"sourcesContent":["import { InjectionToken } from '@angular/core';\nimport { IEvent, IManagedObject } from '@c8y/client';\nimport type { MapDefaultConfig, MapTileLayer } from '@c8y/options';\nimport type * as L from 'leaflet';\nimport { Observable } from 'rxjs';\nimport { GlobalAutoRefreshWidgetConfig } from '@c8y/ngx-components';\n\n/**\n * Utility function to assign asset and event information to a Leaflet marker.\n * @param marker The Leaflet marker instance.\n * @param asset The managed object representing the asset (optional).\n * @param event The event associated with the marker (optional).\n * @returns The marker with asset and/or event information attached.\n */\nexport function getC8yMarker(marker: L.Marker, asset?: PositionManagedObject, event?: IEvent) {\n (marker as C8yMarker).asset = asset;\n (marker as C8yMarker).event = event;\n return marker as C8yMarker;\n}\n\n/**\n * Injection token for providing map tile layers as an observable.\n */\nexport const MAP_TILE_LAYER = new InjectionToken<Observable<MapTileLayer[]>>('MAP_TILE_LAYER');\n\n/**\n * Utility type to require only one of the specified keys in a type.\n * @ignore\n */\ntype RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &\n { [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>> }[Keys];\n\n/**\n * Attributes that can be attached to a Cumulocity marker.\n */\ninterface C8yMarkerAttr {\n asset: PositionManagedObject;\n event: IEvent;\n}\n\n/**\n * Type representing the attribute keys for a Cumulocity marker.\n */\nexport type C8yMarkerAttributes = keyof C8yMarkerAttr;\n\n/**\n * Leaflet marker extended with either asset or event information.\n */\nexport type C8yMarker = L.Marker & RequireOnlyOne<C8yMarkerAttr, 'asset' | 'event'>;\n\n/**\n * Enum for supported cluster sizes on the map.\n */\nexport enum ClusterSize {\n /** No clustering. */\n NONE = 0,\n /** Cluster of 4. */\n FOUR = 1,\n /** Cluster of 16. */\n SIXTEEN = 2\n}\n\n/**\n * Enum for map tenant option keys used in configuration.\n */\nexport enum MapTenantOptionKeys {\n /** Map configuration key. */\n CONFIG = 'map-config',\n /** Map layers key. */\n LAYERS = 'map-layers'\n}\n\n/**\n * Managed object with position and optional alarm status and icon for map display.\n */\nexport interface PositionManagedObject extends IManagedObject {\n /**\n * Position information (latitude, longitude, optional altitude).\n */\n c8y_Position: {\n lat: number;\n lng: number;\n alt?: number;\n };\n\n /**\n * Optional active alarms status for the asset.\n */\n c8y_ActiveAlarmsStatus?: {\n minor: number;\n major: number;\n warning: number;\n critical: number;\n };\n\n /**\n * Optional icon information for the asset.\n */\n icon?: {\n name: string;\n };\n}\n\n/**\n * Configuration for the cluster map, including center and refresh interval.\n */\nexport type ClusterMapConfig = MapConfig & {\n /**\n * Center coordinates of the map [latitude, longitude].\n */\n center: [number, number];\n /**\n * Optional refresh interval in milliseconds (null for no auto-refresh).\n */\n refreshInterval?: number | null;\n};\n\n/**\n * General map configuration, including options for following, real-time, icon, color, zoom, pan, and bounds.\n */\nexport type MapConfig = MapDefaultConfig & {\n /** Whether the map should follow the selected asset. */\n follow?: boolean;\n /** Whether the map should update in real-time. */\n realtime?: boolean;\n /** Optional icon name for the map. */\n icon?: string;\n /** Optional color for the map or marker. */\n color?: string;\n /** Disable zoom controls. */\n disableZoom?: boolean;\n /** Disable pan controls. */\n disablePan?: boolean;\n /** Optional map bounds. */\n bounds?: L.LatLngBounds;\n /** Optional fit bounds options. */\n fitBoundsOptions?: L.FitBoundsOptions;\n} & GlobalAutoRefreshWidgetConfig;\n\n/**\n * Injection token for providing the default map configuration as an observable.\n */\nexport const MAP_DEFAULT_CONFIG = new InjectionToken<Observable<MapDefaultConfig>>(\n 'MAP_DEFAULT_CONFIG'\n);\n\n/**\n * Default map tile layer configuration (OpenStreetMap).\n */\nexport const defaultLayer: MapTileLayer = {\n layerUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',\n label: 'OpenStreetMap',\n priority: 1000,\n options: {\n maxZoom: 18,\n minZoom: 0,\n attribution:\n '&copy;<a href=\"http://www.openstreetmap.org/copyright\" rel=\"noreferrer nofollow\">OpenStreetMap</a>',\n noWrap: false\n }\n};\n\n/**\n * Default map configuration (centered on Düsseldorf, zoom level 2).\n */\nexport const defaultMapConfig: MapDefaultConfig = {\n center: [51.23544, 6.79599], // Düsseldorf\n zoomLevel: 2\n};\n\n/**\n * Default options for fitting map bounds (padding).\n */\nexport const defaultFitBoundsOptions: L.FitBoundsOptions = {\n padding: [50, 50]\n};\n\n/**\n * Configuration for the status buttons shown on the map UI.\n */\nexport type MapStatusButtonsConfig = {\n /** Real-time button configuration. */\n realtime: { show: boolean; disabled?: boolean };\n /** Fit to bounds button configuration. */\n fitToBounds: { show: boolean; disabled?: boolean };\n /** Center button configuration. */\n center: { show: boolean; disabled?: boolean };\n};\n","import { Injectable } from '@angular/core';\nimport { IIdentified, IManagedObject, InventoryService, IResultList, Paging } from '@c8y/client';\nimport { OptionsService, ServiceRegistry } from '@c8y/ngx-components';\nimport type { MapDefaultConfig, MapTileLayer } from '@c8y/options';\nimport type * as L from 'leaflet';\nimport { latLng, latLngBounds } from 'leaflet';\nimport { get } from 'lodash-es';\nimport { combineLatest, defer, Observable, of } from 'rxjs';\nimport { map } from 'rxjs/operators';\nimport {\n ClusterSize,\n defaultLayer,\n defaultMapConfig,\n MapTenantOptionKeys,\n PositionManagedObject\n} from './map.model';\n\n@Injectable({\n providedIn: 'root'\n})\nexport class MapService {\n /**\n * Returns asset icon status for highest alarm severity found in device object.\n * @param device Device that contains alarms information.\n * @returns Status string according to alarm severity\n */\n static getStatus(device: PositionManagedObject) {\n if (!device.c8y_ActiveAlarmsStatus) {\n return 'text-muted';\n }\n if (device.c8y_ActiveAlarmsStatus.critical) {\n return 'status critical';\n }\n if (device.c8y_ActiveAlarmsStatus.major) {\n return 'status major';\n }\n if (device.c8y_ActiveAlarmsStatus.minor) {\n return 'status minor';\n }\n if (device.c8y_ActiveAlarmsStatus.warning) {\n return 'status warning';\n }\n return 'text-muted';\n }\n /**\n * The devices that are maximal displayed in one cluster.\n */\n MAX_DEVICE_PER_CLUSTER = 200;\n /**\n * The count until the cluster is sized. There are a maximum of\n * three clusters: 1, 4 or 16.\n */\n CLUSTER_LEVEL_THRESHOLD = 500;\n\n /**\n * @ignore: Only DI.\n */\n constructor(\n private inventory: InventoryService,\n private options: OptionsService,\n private serviceRegistry: ServiceRegistry\n ) {}\n\n /**\n * Returns the leaflet instance used by the cumulocity core.\n */\n async getLeaflet() {\n const originalLeflet = window.L;\n const c8yLeafletInstance = (await import('leaflet')).default;\n c8yLeafletInstance.noConflict();\n window.L = originalLeflet;\n return c8yLeafletInstance;\n }\n\n /**\n * Verifies if a given managed object is a device with a position fragment.\n * @param mo The given managed object.\n */\n isPositionedDevice(mo: IIdentified) {\n return !!(mo?.c8y_IsDevice && this.hasPosition(mo));\n }\n\n /**\n * Verifies if a given managed object has a position fragment.\n * @param mo The given managed object.\n */\n hasPosition(mo: IIdentified) {\n return mo?.c8y_Position;\n }\n\n getMapTileLayerProviders(): CumulocityServiceRegistry.MapTileLayerProvider[] {\n const layerProviders = this.serviceRegistry.get('mapTileLayerHook');\n\n return layerProviders;\n }\n\n getMapTileLayersFromHookedProviders$(\n layerProviders: CumulocityServiceRegistry.MapTileLayerProvider[]\n ): Observable<MapTileLayer[]> {\n if (!layerProviders.length) {\n return of([]);\n }\n const layers = combineLatest(layerProviders.map(provider => provider.getMapTileLayers$()));\n return layers.pipe(map(layers => layers.flat()));\n }\n\n /**\n * Returns the layers available in this application.\n * Layers are taken from plugins installed to this application.\n * In case none of the plugins override the default layers, the default layers are also considered.\n * @returns The layers.\n */\n getMapTileLayers$(): Observable<MapTileLayer[]> {\n return defer(() => {\n const layerProviders = this.getMapTileLayerProviders();\n const overridesDefaultLayer = layerProviders.some(provider =>\n provider.overridesDefaultLayer?.()\n );\n const layersFromProviders = defer(() =>\n this.getMapTileLayersFromHookedProviders$(layerProviders)\n );\n if (overridesDefaultLayer) {\n return layersFromProviders;\n }\n\n return combineLatest([this.getDefaultLayers(), layersFromProviders]).pipe(\n map(layers => {\n return layers.flat();\n })\n );\n });\n }\n\n /**\n * Returns the layers configured in the current platform via tenant options.\n * @returns The layers. If not set in tenant options the default layers.\n */\n getDefaultLayers(): Observable<MapTileLayer[]> {\n return this.getMapOption<MapTileLayer[]>(\n MapTenantOptionKeys.LAYERS,\n this.options.mapLayers || [defaultLayer]\n );\n }\n\n /**\n * Returns the map configuration configured on the tenant.\n * @returns The configuration. If not set in tenant options the default configuration.\n */\n getDefaultConfig(): Observable<MapDefaultConfig> {\n return this.getMapOption<MapDefaultConfig>(\n MapTenantOptionKeys.CONFIG,\n this.options.mapConfig || defaultMapConfig\n );\n }\n\n /**\n * Counts all managed objects in a given bound with a c8y_Position fragment.\n * @param bound The lat lng bound to request the managed objects for.\n * @param byGroupIdMO The group managed object of which direct children should be searched for.\n * @returns The number of all position managed objects in the given bound (and group).\n */\n async getPositionMOsFromBoundCount(\n bound: L.LatLngBounds,\n byGroupIdMO?: IManagedObject\n ): Promise<number> {\n return this.getPositionMOsFromBound(bound, byGroupIdMO, true) as Promise<number>;\n }\n\n /**\n * Returns all managed objects with a c8y_Position fragment in a certain boundary.\n * @param bound The lat lng bound to request the managed objects for.\n * @returns All position managed objects in the given bound.\n */\n async getPositionMOsFromBound(bound: L.LatLngBounds): Promise<PositionManagedObject[]>;\n /**\n * Returns all managed objects with a c8y_Position fragment in a certain boundary that belongs to a certain group.\n * @param bound The lat lng bound to request the managed objects for.\n * @param byGroupIdMO The group managed object of which direct children should be searched for.\n * @returns All position managed objects in the given bound that are children of the given group.\n */\n async getPositionMOsFromBound(\n bound: L.LatLngBounds,\n byGroupIdMO: IManagedObject\n ): Promise<PositionManagedObject[]>;\n /**\n * Counts the managed objects in a certain boundary belonging to a group.\n * @param bound The lat lng bound to request the managed objects for.\n * @param byGroupIdMO The group managed object of which direct children should be searched for.\n * @return The count of the managed objects.\n */\n async getPositionMOsFromBound(\n bound: L.LatLngBounds,\n byGroupIdMO: IManagedObject,\n count: true\n ): Promise<number>;\n async getPositionMOsFromBound(\n bound: L.LatLngBounds,\n byGroupIdMO?: IManagedObject,\n count = false\n ): Promise<PositionManagedObject[] | number> {\n const { lat: latMin, lng: lngMinRaw } = bound.getSouthWest();\n const { lat: latMax, lng: lngMaxRaw } = bound.getNorthEast();\n\n const lngMin = lngMaxRaw - lngMinRaw > 360 ? -180 : this.normalizeLongitude(lngMinRaw);\n const lngMax = lngMaxRaw - lngMinRaw > 360 ? 180 : this.normalizeLongitude(lngMaxRaw);\n\n const byGroupIdFilter = byGroupIdMO\n ? `(bygroupid(${byGroupIdMO.id}) or id eq '${byGroupIdMO.id}') and `\n : '';\n let boundFilter = `$filter=${byGroupIdFilter}has(c8y_Position) and c8y_Position.lat gt ${latMin}d and c8y_Position.lat lt ${latMax}d`;\n\n if (lngMin < lngMax) {\n boundFilter = `${boundFilter} and c8y_Position.lng gt ${lngMin}d and c8y_Position.lng lt ${lngMax}d`;\n } else {\n boundFilter = `${boundFilter} and (c8y_Position.lng gt ${lngMin}d or c8y_Position.lng lt ${lngMax}d)`;\n }\n\n const { paging, data } = await this.inventory.list({\n pageSize: count ? 1 : this.MAX_DEVICE_PER_CLUSTER,\n withTotalPages: count,\n query: boundFilter\n });\n if (count) {\n return paging.totalPages;\n }\n return data.map((pmo: PositionManagedObject) =>\n bound.contains(latLng(pmo.c8y_Position.lat, pmo.c8y_Position.lng))\n ? pmo\n : this.denormalizePMO(pmo, bound)\n ) as PositionManagedObject[];\n }\n\n /**\n * Returns all devices with c8y_Position.\n */\n async getPositionDevices(): Promise<PositionManagedObject[]>;\n /**\n * Returns all devices with c8y_Position.\n * @param pageSize The page size to return.\n */\n async getPositionDevices(pageSize: number): Promise<PositionManagedObject[]>;\n /**\n * Returns all devices with c8y_Position.\n * @param pageSize The page size to return.\n * @param count Counting is disabled\n */\n async getPositionDevices(pageSize: number, count: false): Promise<PositionManagedObject[]>;\n /**\n * Returns the number of all devices with c8y_Position.\n * @param pageSize The page size to return.\n * @param count Counting is enabled\n */\n async getPositionDevices(pageSize: number, count: true): Promise<number>;\n /**\n * Returns all devices with c8y_Position.\n * @param pageSize The page size to return.\n * @param count Switches to counting only.\n * @returns All devices or the device count with a c8y_Position fragment.\n */\n async getPositionDevices(\n pageSize = this.MAX_DEVICE_PER_CLUSTER,\n count?: boolean\n ): Promise<number | PositionManagedObject[]> {\n const { paging, data } = await this.inventory.list({\n pageSize: count ? 1 : pageSize,\n withTotalPages: !!count,\n query: '$filter=has(c8y_Position) and has(c8y_IsDevice)'\n });\n if (count) {\n return paging.totalPages;\n }\n return data as PositionManagedObject[];\n }\n\n /**\n * Returns all managed object with a c8y_Position fragment.\n * @param byGroupIdMO The group managed object of which direct children should be searched for.\n * @param pageSize Defines how many results should be returned.\n * @returns The managed objects with position.\n */\n async getAllPositionMOs(\n byGroupIdMO?: IIdentified,\n pageSize = 500\n ): Promise<IResultList<PositionManagedObject>> {\n const filter: { pageSize: number; withTotalPages: boolean; query: string } = {\n pageSize,\n withTotalPages: true,\n query: 'has(c8y_Position)'\n };\n\n if (byGroupIdMO) {\n filter.query = `$filter=(bygroupid(${byGroupIdMO.id}) or id eq '${byGroupIdMO.id}') and has(c8y_Position)`;\n }\n\n const { paging, data, res } = await this.inventory.list(filter);\n\n return {\n res,\n paging: paging as Paging<PositionManagedObject>,\n data: data as PositionManagedObject[]\n };\n }\n\n /**\n * Determines a rectangular geographical area based on the positions of all devices.\n *\n * @returns A [[LatLngBounds]] object fitting all devices' geo positions.\n */\n async getAllDevicesBounds(): Promise<L.LatLngBounds> {\n const filter = (coord: 'lat' | 'lng', order: 'asc' | 'desc') => ({\n pageSize: 1,\n q: `$filter=has(c8y_Position) $orderby=c8y_Position.${coord} ${order}`\n });\n\n const filterReverse = (op: 'lt' | 'gt', order: 'asc' | 'desc') => ({\n pageSize: 1,\n q: `$filter=has(c8y_Position) and c8y_Position.lng ${op} 0d $orderby=c8y_Position.lng ${order}`\n });\n\n const [latMin, latMax, lngMin, lngMax, lngRevMin, lngRevMax] = await Promise.all([\n this.inventory.list(filter('lat', 'asc')),\n this.inventory.list(filter('lat', 'desc')),\n this.inventory.list(filter('lng', 'asc')),\n this.inventory.list(filter('lng', 'desc')),\n this.inventory.list(filterReverse('gt', 'asc')),\n this.inventory.list(filterReverse('lt', 'desc'))\n ]).then(result => result.map(r => get(r.data, '[0].c8y_Position')));\n\n const shiftWorld = (lngRevMin?.lng ?? 0) - (lngRevMax?.lng ?? 0) > 180;\n\n return latLngBounds(\n latLng(latMin?.lat, shiftWorld ? lngRevMin?.lng : lngMin?.lng),\n latLng(latMax?.lat, shiftWorld ? lngRevMax?.lng + 360 : lngMax?.lng)\n );\n }\n\n async getAssetsBounds(assets: PositionManagedObject[]): Promise<L.LatLngBounds | undefined> {\n const leaflet = await this.getLeaflet();\n const bounds = leaflet.latLngBounds([]);\n let hasValidPositions = false;\n assets.forEach(asset => {\n const position = asset.c8y_Position;\n if (position && typeof position.lat === 'number' && typeof position.lng === 'number') {\n bounds.extend([position.lat, position.lng]);\n hasValidPositions = true;\n }\n });\n if (!hasValidPositions || !bounds.isValid()) {\n return;\n }\n return bounds;\n }\n\n /**\n * Returns the cluster size for clustered maps. Counting the position MOs in a bounding\n * and if it reach a threshold, returning a [[ClusterSize]].\n * @param bound The bounding to check for cluster size.\n * @returns The cluster size, can be NONE, FOUR or SIXTEEN.\n */\n async getClusterSize(bound: L.LatLngBounds) {\n const count = await this.getPositionMOsFromBoundCount(bound);\n let clusterSize = ClusterSize.NONE;\n if (count > this.CLUSTER_LEVEL_THRESHOLD) {\n clusterSize = ClusterSize.SIXTEEN;\n } else if (count > this.MAX_DEVICE_PER_CLUSTER) {\n clusterSize = ClusterSize.FOUR;\n }\n return clusterSize;\n }\n\n private getMapOption<T>(key: MapTenantOptionKeys, defaultValue: T) {\n return defer(() =>\n this.options.getTenantOption<T | string>('configuration', key, defaultValue)\n ).pipe(\n map(config => {\n if (typeof config === 'string') {\n console.error(\n `The tenant option for maps 'configuration.${key}' is not a valid JSON structure.`\n );\n return defaultValue;\n }\n return config;\n })\n );\n }\n\n /**\n * Shifts longitudes received from Leaflet.js in the [-180 - k*360; 180 + k*360] rangewhen\n * `noWrap` is enabled to the [-180; 180] range expected for values of the c8y_Position fragment.\n *\n * @param lng Longitude to shift.\n * @returns Longitude value in the [-180; 180] range\n */\n private normalizeLongitude(lng: number): number {\n return ((((lng + 180) % 360) + 360) % 360) - 180;\n }\n\n /**\n * Shifts longitudes in the [-180; 180] range expected for values of the c8y_Position fragment\n * the the [-180 - k*360; 180 + k*360] range expected from Leaflet.js when `noWrap` is enabled.\n *\n * The method naively adds/subtracts 360 degrees to the original value until the position fits in the expected bounds.\n *\n * @param pmo A managed object with a `c8y_Position` fragment\n * @param bounds The bounds where the position should fit\n * @returns A managed object whose `c8y_Position`'s `lng` values has been shifted to fit in bounds\n */\n private denormalizePMO(\n pmo: PositionManagedObject,\n bounds: L.LatLngBounds\n ): PositionManagedObject {\n let { lng } = pmo.c8y_Position;\n const shiftFactor = lng > bounds.getEast() ? -1 : 1;\n\n while (!bounds.contains(latLng(pmo.c8y_Position.lat, lng))) {\n lng += shiftFactor * 360;\n }\n\n pmo.c8y_Position.lng = lng;\n return pmo;\n }\n}\n","import { IterableChangeRecord, IterableDiffer, IterableDiffers } from '@angular/core';\nimport { TranslateService } from '@ngx-translate/core';\nimport type * as L from 'leaflet';\nimport { gettext } from '@c8y/ngx-components';\nimport { C8yMarker, PositionManagedObject } from './map.model';\nimport { MapService } from './map.service';\n\nexport class ClusterMap {\n markers: C8yMarker[] = [];\n positions: PositionManagedObject[] = [];\n\n set clusterMarker(item: L.Layer) {\n this.removeClusterToBigMarker();\n this._clusterMarker = item;\n }\n\n get clusterMarker() {\n return this._clusterMarker;\n }\n\n set rect(item: L.Rectangle) {\n if (this._rect) {\n this._rect.remove();\n }\n this._rect = item;\n }\n\n get rect() {\n return this._rect;\n }\n\n private _clusterMarker: L.Layer;\n private _rect: L.Rectangle;\n private iterableDiffer: IterableDiffer<PositionManagedObject> | null;\n\n constructor(\n private iterable: IterableDiffers,\n private addAssetCallback: (asset: PositionManagedObject) => C8yMarker,\n private translateService: TranslateService\n ) {\n this.iterableDiffer = this.iterable.find(this.positions).create(this.trackBy);\n }\n\n render(map: L.Map) {\n if (this._rect) {\n this._rect.addTo(map);\n }\n this.updateChanges(map);\n if (this._clusterMarker) {\n this._clusterMarker.addTo(map);\n }\n }\n\n clear(map: L.Map) {\n this.removeClusterToBigMarker();\n this._rect.remove();\n this.positions = [];\n this.updateChanges(map);\n }\n\n removeClusterToBigMarker() {\n if (this._clusterMarker) {\n this._clusterMarker.remove();\n this._clusterMarker = null;\n }\n }\n\n addMarkerToMap(device: PositionManagedObject, map: L.Map) {\n const marker = this.addAssetCallback(device);\n this.markers.push(marker);\n marker.addTo(map);\n }\n\n setClusterToBigMarker(map: L.Map, count, leaflet: typeof L) {\n const bound = this.rect.getBounds();\n const text = this.translateService.instant(gettext('Zoom in'));\n const divMarker = leaflet.divIcon({\n html: `<div class=\"c8y-map-marker-count\" data-count=\"${count}\" title=\"${text}\"></div>`\n });\n const labelIcon = leaflet.marker(bound.getCenter(), {\n icon: divMarker\n });\n labelIcon.addTo(map);\n labelIcon.on('click', () => {\n map.fitBounds(bound);\n });\n this.clusterMarker = labelIcon;\n }\n\n private updateChanges(map: L.Map) {\n const changes = this.iterableDiffer.diff(this.positions);\n if (changes) {\n changes.forEachRemovedItem((record: IterableChangeRecord<PositionManagedObject>) => {\n this.removeMarkerFromMap(record.item);\n });\n\n changes.forEachAddedItem((record: IterableChangeRecord<PositionManagedObject>) => {\n this.addMarkerToMap(record.item, map);\n });\n }\n }\n\n private trackBy(index: number, item: PositionManagedObject) {\n const trackItems = [\n item.id,\n item.c8y_Position.lat,\n item.c8y_Position.lng,\n MapService.getStatus(item)\n ];\n return trackItems.join('');\n }\n\n private removeMarkerFromMap(device: PositionManagedObject) {\n const markers = this.markers.filter((marker: C8yMarker) => marker.asset?.id === device.id);\n markers.forEach(marker => marker.remove());\n }\n}\n","import { Directive, ElementRef, TemplateRef, ViewContainerRef } from '@angular/core';\n\n@Directive({ selector: '[c8yMapPopup]' })\nexport class MapPopupDirective {\n constructor(\n public template: TemplateRef<unknown>,\n public elementRef: ElementRef,\n public viewContainer: ViewContainerRef\n ) {}\n}\n","import {\n Component,\n ContentChild,\n ElementRef,\n EventEmitter,\n Inject,\n Input,\n Output,\n SimpleChange,\n SimpleChanges,\n ViewChild\n} from '@angular/core';\nimport { takeUntilDestroyed } from '@angular/core/rxjs-interop';\nimport { IEvent } from '@c8y/client';\nimport {\n DatePipe,\n GeoService,\n gettext,\n ManagedObjectRealtimeService,\n sortByPriority,\n WidgetGlobalAutoRefreshService\n} from '@c8y/ngx-components';\nimport type { MapDefaultConfig, MapTileLayer } from '@c8y/options';\nimport { TranslateService } from '@ngx-translate/core';\nimport type * as L from 'leaflet';\nimport { every, flatten, isEmpty, isNull, isUndefined, remove } from 'lodash-es';\nimport {\n BehaviorSubject,\n combineLatest,\n fromEvent,\n NEVER,\n Observable,\n Subject,\n Subscription\n} from 'rxjs';\nimport { filter, first, map, scan, switchMap, takeUntil } from 'rxjs/operators';\nimport { MapPopupDirective } from './map-popup.directive';\nimport {\n C8yMarker,\n C8yMarkerAttributes,\n getC8yMarker,\n MAP_DEFAULT_CONFIG,\n MAP_TILE_LAYER,\n MapConfig,\n PositionManagedObject\n} from './map.model';\nimport { MapService } from './map.service';\n\n@Component({\n selector: 'c8y-map',\n templateUrl: './map.component.html',\n providers: [ManagedObjectRealtimeService]\n})\nexport class MapComponent {\n /**\n * The leaflet map object instance.\n */\n map: L.Map;\n /**\n * The markers currently placed on the map.\n */\n markers: Array<C8yMarker | L.Marker> = [];\n /**\n * The leaflet library reference used for map operations.\n */\n leaflet: typeof L;\n /**\n * Indicates if the map was already initialized.\n */\n isInit = false;\n\n /**\n * Reference to the map DOM element.\n */\n @ViewChild('map')\n mapElement: ElementRef;\n\n /**\n * Reference to the custom popup directive for map markers.\n */\n @ContentChild(MapPopupDirective)\n popup: MapPopupDirective;\n\n /**\n * Map configuration object (center, zoom, icon, color, etc).\n */\n @Input()\n config: MapConfig = {};\n\n /**\n * Asset(s) to display as markers on the map.\n */\n @Input()\n assets: PositionManagedObject | PositionManagedObject[];\n\n /**\n * Observable for polyline coordinates to display on the map.\n */\n @Input()\n polyline$: Observable<L.LatLngExpression[] | L.LatLngExpression[][]> = NEVER;\n\n /**\n * Polyline display options for the map.\n */\n @Input()\n polylineOptions: L.PolylineOptions;\n\n /**\n * Emits when a tracked asset is updated in real-time.\n */\n @Output()\n onRealtimeUpdate = new EventEmitter<PositionManagedObject>();\n\n /**\n * Emits observable of map drag/move events.\n */\n @Output()\n onMove: Observable<L.LeafletEvent>;\n\n /**\n * Emits observable of map move end events.\n */\n @Output()\n onMoveEnd: Observable<L.LeafletEvent>;\n\n /**\n * Emits observable of map zoom start events.\n */\n @Output()\n onZoomStart: Observable<L.LeafletEvent>;\n\n /**\n * Emits observable of map zoom end events.\n */\n @Output()\n onZoomEnd: Observable<L.LeafletEvent>;\n\n /**\n * Emits the Leaflet map instance when available.\n */\n @Output()\n onMap = new BehaviorSubject<L.Map | null>(null);\n\n /**\n * Emits when the map and Leaflet library are initialized.\n */\n @Output()\n onInit = new EventEmitter<typeof L>();\n\n protected realtimeSubscription: Subscription;\n protected unsubscribeTrigger$ = new Subject<void>();\n protected destroy$ = new Subject<void>();\n\n private markerTitle = gettext('Marker at position {{lat}}, {{lng}}');\n\n constructor(\n protected moRealtimeService: ManagedObjectRealtimeService,\n protected mapService: MapService,\n @Inject(MAP_TILE_LAYER) protected layers$: Observable<MapTileLayer[]>,\n @Inject(MAP_DEFAULT_CONFIG)\n protected defaultConfig$: Observable<MapDefaultConfig>,\n protected translateService: TranslateService,\n protected geo: GeoService,\n protected datePipe: DatePipe,\n protected widgetGlobalAutoRefreshService: WidgetGlobalAutoRefreshService\n ) {\n this.initOutputs();\n }\n\n /**\n * Starts real-time updates for a single asset on the map.\n * Updates marker position and icon as new data arrives.\n */\n startRealtime() {\n if (!this.assets || (Array.isArray(this.assets) && this.assets.length > 1)) {\n this.config.realtime = false;\n this.stopRealtime();\n return;\n }\n const asset = Array.isArray(this.assets) ? this.assets[0] : this.assets;\n this.realtimeSubscription = this.moRealtimeService\n .onUpdate$(asset)\n .subscribe((asset: PositionManagedObject) => {\n const marker = this.findMarker(asset.id);\n const icon = this.getAssetIcon(asset);\n marker.setIcon(icon);\n marker.setLatLng(this.geo.getLatLong(asset));\n if (Array.isArray(this.assets)) {\n this.assets[0] = asset;\n } else {\n this.assets = asset;\n }\n this.moveToPositionOfMo(asset);\n this.onRealtimeUpdate.emit(asset);\n });\n }\n\n /**\n * Moves the map view to the position of the given asset(s) if follow is enabled.\n * @param positions The asset or array of assets to center the map on.\n */\n moveToPositionOfMo(positions: PositionManagedObject | PositionManagedObject[]) {\n const position = Array.isArray(positions) ? positions[0] : positions;\n if (this.config.follow) {\n this.map.setView([position.c8y_Position.lat, position.c8y_Position.lng]);\n }\n }\n\n /**\n * Stops real-time updates for the asset.\n */\n stopRealtime() {\n if (this.realtimeSubscription) {\n this.realtimeSubscription.unsubscribe();\n }\n }\n\n /**\n * Finds a marker on the map by asset, event, or ID.\n * @param moOrId Asset, event, or string ID to search for.\n * @returns The found marker or undefined.\n */\n findMarker(moOrId: string | IEvent | PositionManagedObject) {\n const getId = moOrId => (typeof moOrId === 'string' ? moOrId : moOrId?.id);\n return this.markers.find(\n (marker: C8yMarker) =>\n marker.asset?.id === getId(moOrId) || marker.event?.id === getId(moOrId)\n );\n }\n\n /**\n * Adds a marker to the map and internal marker list.\n * @param marker The marker to add.\n */\n addMarkerToMap(marker: C8yMarker | L.Marker) {\n this.markers.push(marker);\n marker.addTo(this.map);\n }\n\n /**\n * Creates and returns a marker for the given asset, including icon and popup.\n * @param asset The asset to create a marker for.\n * @returns The created marker.\n */\n getAssetMarker(asset: PositionManagedObject) {\n if (!asset) {\n return;\n }\n\n const icon = this.getAssetIcon(asset);\n const { lat, lng } = asset.c8y_Position;\n const leafletMarker = this.leaflet.marker(this.geo.getLatLong(asset), {\n icon,\n title: this.translateService.instant(this.markerTitle, { lat, lng })\n });\n const marker = getC8yMarker(leafletMarker, asset);\n this.bindPopup(marker, asset);\n\n return marker;\n }\n\n /**\n * Creates and returns a marker for a tracking event, including icon and popup.\n * @param event The event to create a marker for.\n * @returns The created marker.\n */\n getTrackingMarker(event: IEvent) {\n if (!event) {\n return;\n }\n\n const icon = this.getTrackingIcon();\n const { lat, lng } = event.c8y_Position;\n const leafletMarker = this.leaflet.marker(this.geo.getLatLong(event), {\n icon,\n title: this.translateService.instant(this.markerTitle, { lat, lng })\n });\n const marker = getC8yMarker(leafletMarker, null, event);\n this.bindPopup(marker, event);\n\n return marker;\n }\n\n /**\n * Returns a Leaflet icon for the given asset, using config or asset icon and color.\n * @param asset The asset to get the icon for.\n * @returns The Leaflet icon.\n */\n getAssetIcon(asset: PositionManagedObject) {\n const assetTypeIcon = this.config.icon || asset.icon?.name;\n const status = MapService.getStatus(asset);\n const color = this.config.color ? `style='color: ${this.config.color};'` : '';\n const icon = this.leaflet.divIcon({\n html: `<div class=\"dlt-c8y-icon-marker icon-3x ${status}\" ${color}><i class=\"dlt-c8y-icon-${\n assetTypeIcon || 'data-transfer'\n }\" /></div>`,\n className: 'c8y-map-marker-icon',\n // iconAnchor is used to set the marker accurately on click\n iconAnchor: [8, 8]\n });\n return icon;\n }\n\n /**\n * Returns a Leaflet icon for a tracking event.\n * @returns The Leaflet icon.\n */\n getTrackingIcon() {\n const icon = this.leaflet.divIcon({\n html: `<div class=\"dlt-c8y-icon-marker icon-3x text-muted\"></div>`,\n className: 'c8y-map-marker-icon',\n // iconAnchor is used to set the marker accurately on click\n iconAnchor: [8, 8]\n });\n return icon;\n }\n\n /**\n * Removes a marker from the map and internal marker list.\n * @param marker The marker to remove.\n */\n removeMarker(marker: C8yMarker | L.Marker) {\n if (marker) {\n marker.remove();\n remove(this.markers, m => m === marker);\n }\n }\n\n /**\n * Removes all markers from the map, optionally filtered by marker attribute.\n * @param fragment Optional marker attribute to filter by.\n */\n clearMarkers(fragment?: C8yMarkerAttributes) {\n const matchingMarkers = marker => !fragment || !!marker[fragment];\n this.markers.filter(matchingMarkers).forEach(marker => marker.remove());\n this.markers = this.markers.filter(marker => !matchingMarkers(marker));\n }\n\n /**\n * Refreshes all markers on the map based on the current assets.\n */\n refreshMarkers() {\n this.clearMarkers();\n let assets: PositionManagedObject[] = [];\n if (!isUndefined(this.assets)) {\n assets = Array.isArray(this.assets) ? this.assets : [this.assets];\n }\n assets.forEach(asset => {\n const marker = this.getAssetMarker(asset);\n this.addMarkerToMap(marker);\n });\n if (!this.config.center) {\n this.zoomToBound(assets);\n }\n this.toggleControls();\n }\n\n /**\n * Centers the map on the configured center coordinates.\n */\n center() {\n this.map?.setView(this.config.center);\n }\n\n /**\n * Refreshes the map and markers if the map is initialized.\n */\n refresh() {\n if (this.isInit) {\n this.refreshMarkers();\n }\n }\n\n protected async ngAfterViewInit() {\n this.leaflet = await this.mapService.getLeaflet();\n const initialized$ = combineLatest([this.layers$, this.defaultConfig$]).pipe(\n first(),\n takeUntil(this.unsubscribeTrigger$)\n );\n\n initialized$.subscribe(([layers, defaultConfig]) => {\n this.initMap(layers, defaultConfig);\n this.refreshMarkers();\n });\n\n combineLatest([this.polyline$, initialized$])\n .pipe(\n map(([expressions]) => this.leaflet.polyline(expressions, this.polylineOptions)),\n scan((oldPolyline, newPolyline) => {\n if (!!oldPolyline) {\n this.map.removeLayer(oldPolyline);\n }\n if (!!newPolyline) {\n newPolyline.addTo(this.map);\n this.fitBounds(newPolyline.getBounds());\n }\n\n return newPolyline;\n }, null),\n takeUntil(this.unsubscribeTrigger$)\n )\n .subscribe();\n }\n\n protected ngOnChanges(changes: SimpleChanges): void {\n if (!this.map) {\n return;\n }\n if (changes.assets?.currentValue && !changes.assets?.firstChange) {\n this.refreshMarkers();\n }\n\n if (changes.config?.currentValue && !changes.config?.firstChange) {\n this.changeConfig(changes.config);\n }\n }\n\n protected ngOnDestroy(): void {\n this.unsubscribeAllListeners();\n this.destroy$.next();\n this.destroy$.complete();\n }\n\n protected unsubscribeAllListeners() {\n this.unsubscribeTrigger$.next();\n this.stopRealtime();\n }\n\n protected initOutputs() {\n const getMapEventObservable = eventName => {\n return this.onMap.pipe(\n filter(map => !!map),\n switchMap(map => fromEvent<L.LeafletEvent>(map, eventName)),\n takeUntilDestroyed()\n );\n };\n const dragStart$ = getMapEventObservable('dragstart');\n const move$ = getMapEventObservable('move');\n const dragEnd$ = getMapEventObservable('dragend');\n\n this.onMove = dragStart$.pipe(\n switchMap(() => move$.pipe(takeUntil(dragEnd$))),\n takeUntil(this.unsubscribeTrigger$)\n );\n\n this.onMoveEnd = getMapEventObservable('moveend');\n\n this.onZoomEnd = getMapEventObservable('zoomend');\n\n this.onZoomStart = getMapEventObservable('zoomstart');\n }\n\n protected initMap(layers: MapTileLayer[], defaultConfig: MapConfig): void {\n const defaultOptions = {\n center: this.config.center || defaultConfig.center,\n zoomSnap: 0,\n zoom: this.config.zoomLevel || defaultConfig.zoomLevel,\n worldCopyJump: true\n };\n\n if (this.map) {\n this.map.remove();\n }\n this.map = this.leaflet.map(this.mapElement.nativeElement, defaultOptions);\n\n this.map.attributionControl.setPrefix('');\n\n this.fitBounds(this.config.bounds);\n\n this.addLayers(layers);\n\n this.handleMobile();\n\n this.onMap.next(this.map);\n\n if (this.config.realtime) {\n this.startRealtime();\n }\n\n this.isInit = true;\n this.onInit.emit(this.leaflet);\n }\n\n protected handleMobile() {\n // adding event listener to do mobile 2 finger scrolling\n if (this.leaflet.Browser.mobile) {\n const touchMsg = this.translateService.instant(gettext('Use two fingers to move the map.'));\n this.map.dragging.disable();\n const container = this.map.getContainer();\n container.setAttribute('data-touch-warning-content', touchMsg);\n container.addEventListener('touchstart', event => this.handleTouch(event));\n container.addEventListener('touchmove', event => this.handleTouch(event));\n container.addEventListener('touchend', event => this.handleTouch(event));\n container.addEventListener('touchcancel', event => this.handleTouch(event));\n container.addEventListener('click', event => this.handleTouch(event));\n }\n }\n\n protected addLayers(layers: MapTileLayer[]) {\n const flattenLayers: MapTileLayer[] = flatten(layers);\n const baseLayers: L.Control.LayersObject = {};\n const overlays: L.Control.LayersObject = {};\n let firstLayer = true;\n for (const layer of sortByPriority(flattenLayers)) {\n const objectToAddTo = layer.isOverlay ? overlays : baseLayers;\n if (objectToAddTo[layer.label]) {\n continue;\n }\n\n const tiles = this.leaflet.tileLayer(layer.layerUrl, layer.options);\n if (!layer.isOverlay && firstLayer) {\n firstLayer = false;\n tiles.addTo(this.map);\n }\n objectToAddTo[layer.label] = tiles;\n }\n if (flattenLayers.length > 1) {\n this.leaflet.control.layers(baseLayers, overlays, { position: 'bottomleft' }).addTo(this.map);\n }\n }\n\n protected changeConfig(change: SimpleChange) {\n if (this.hasChanged(change, 'zoomLevel')) {\n this.map.setZoom(this.config.zoomLevel);\n }\n\n if (this.hasChanged(change, 'center') && every(change.currentValue.center, p => !isNull(p))) {\n this.map.setView(change.currentValue.center);\n }\n\n if (this.hasChanged(change, 'icon') || this.hasChanged(change, 'color')) {\n this.refreshMarkers();\n }\n\n if (this.hasChanged(change, 'realtime') && change.currentValue.realtime) {\n this.startRealtime();\n }\n\n if (change.currentValue.realtime === false) {\n this.stopRealtime();\n }\n\n if (this.hasChanged(change, 'follow')) {\n this.moveToPositionOfMo(this.assets);\n }\n\n if (this.hasChanged(change, 'disablePan') || this.hasChanged(change, 'disableZoom')) {\n this.toggleControls();\n }\n }\n\n protected hasChanged(change: SimpleChange, prop: keyof MapConfig) {\n return change.currentValue[prop] !== change.previousValue[prop];\n }\n\n protected toggleControls() {\n if (this.config.disableZoom) {\n this.map.removeControl(this.map.zoomControl);\n this.map.scrollWheelZoom.disable();\n } else {\n this.map.addControl(this.map.zoomControl);\n this.map.scrollWheelZoom.enable();\n }\n if (this.config.disablePan) {\n this.map.dragging.disable();\n } else {\n this.map.dragging.enable();\n }\n }\n\n private handleTouch(e: Event) {\n // Disregard touch events on the minimap if present\n const ignoreList = [\n 'leaflet-control-minimap',\n 'leaflet-interactive',\n 'leaflet-popup-content',\n 'leaflet-popup-content-wrapper',\n 'leaflet-popup-close-button',\n 'leaflet-control-zoom-in',\n 'leaflet-control-zoom-out'\n ];\n\n let ignoreElement = false;\n for (let i = 0; i < ignoreList.length; i++) {\n if (this.leaflet.DomUtil.hasClass(e.target as HTMLElement, ignoreList[i])) {\n ignoreElement = true;\n }\n }\n\n const container = this.map.getContainer();\n if (ignoreElement) {\n if (\n this.leaflet.DomUtil.hasClass(e.target as HTMLElement, 'leaflet-interactive') &&\n e.type === 'touchmove' &&\n (e as TouchEvent).touches.length === 1\n ) {\n this.leaflet.DomUtil.addClass(container, 'touch-warning');\n this.map.dragging.disable();\n } else {\n this.leaflet.DomUtil.removeClass(container, 'touch-warning');\n }\n return;\n }\n\n if (e.type !== 'touchmove' && e.type !== 'touchstart') {\n this.leaflet.DomUtil.removeClass(container, 'touch-warning');\n return;\n }\n if ((e as TouchEvent).touches.length === 1) {\n this.leaflet.DomUtil.addClass(container, 'touch-warning');\n this.map.dragging.disable();\n } else {\n this.map.dragging.enable();\n this.leaflet.DomUtil.removeClass(container, 'touch-warning');\n }\n }\n\n private zoomToBound(assets: PositionManagedObject[]) {\n if (isEmpty(assets)) return;\n\n const bounds: [number, number][] = assets.map(asset => [\n asset.c8y_Position.lat,\n asset.c8y_Position.lng\n ]);\n if (assets.length > 1) {\n this.map.flyToBounds(bounds, { animate: false });\n return;\n }\n this.map.flyTo(\n [assets[0].c8y_Position.lat, assets[0].c8y_Position.lng],\n this.map.options.zoom,\n {\n animate: false\n }\n );\n }\n\n private fitBounds(bounds: L.LatLngBounds) {\n if (bounds?.isValid()) {\n this.map.fitBounds(bounds, this.config.fitBoundsOptions);\n }\n }\n\n private bindPopup(marker: C8yMarker, context: PositionManagedObject | IEvent) {\n if (this.popup) {\n marker.on('click', () => {\n this.popup.viewContainer.clear();\n const view = this.popup.viewContainer.createEmbeddedView(this.popup.template, {\n $implicit: context\n });\n view.detectChanges();\n marker\n .unbindPopup()\n .bindPopup(this.popup.elementRef.nativeElement.previousSibling, {\n offset: [-3, -40],\n maxWidth: 140,\n autoPan: true,\n closeButton: false\n })\n .openPopup();\n });\n }\n }\n}\n","<div class=\"c8y-map\">\n <div #map></div>\n</div>\n<ng-content></ng-content>\n","import {\n Component,\n ElementRef,\n EventEmitter,\n Inject,\n Input,\n IterableDiffers,\n Output,\n SimpleChange,\n SimpleChanges,\n ViewChild\n} from '@angular/core';\nimport { IManagedObject } from '@c8y/client';\nimport type { MapDefaultConfig, MapTileLayer } from '@c8y/options';\nimport {\n ColorService,\n CountdownIntervalComponent,\n DatePipe,\n GeoService,\n ManagedObjectRealtimeService,\n WidgetGlobalAutoRefreshService,\n globalAutoRefreshLoading,\n DynamicComponent,\n DynamicComponentAlertAggregator\n} from '@c8y/ngx-components';\nimport { TranslateService } from '@ngx-translate/core';\nimport type * as L from 'leaflet';\nimport {\n BehaviorSubject,\n EMPTY,\n Observable,\n combineLatest,\n from,\n fromEvent,\n merge,\n of\n} from 'rxjs';\nimport {\n catchError,\n debounceTime,\n mergeMap,\n skip,\n switchMap,\n takeUntil,\n tap\n} from 'rxjs/operators';\nimport { ClusterMap } from './cluster-map';\nimport { MapComponent } from './map.component';\nimport {\n ClusterMapConfig,\n ClusterSize,\n MAP_DEFAULT_CONFIG,\n MAP_TILE_LAYER,\n PositionManagedObject\n} from './map.model';\nimport { MapService } from './map.service';\n\n/**\n * Smart map component for that clusters devices together when there are too many to display individually.\n * Unlike the basic map, this component loads device data dynamically and manages cluster rendering and updates.\n * Extends the base MapComponent with clustering, dynamic data loading, and advanced refresh logic.\n */\n@Component({\n selector: 'c8y-cluster-map',\n templateUrl: './cluster-map.component.html',\n providers: [ManagedObjectRealtimeService]\n})\nexport class ClusterMapComponent extends MapComponent implements DynamicComponent {\n /** Emits true while the map is loading or refreshing clusters. */\n isLoading$ = new BehaviorSubject(false);\n /** @ignore */\n countdownIntervalComp: CountdownIntervalComponent;\n\n /**\n * Cluster map configuration, including clustering thresholds and refresh intervals.\n */\n @Input()\n config: ClusterMapConfig;\n\n /**\n * The root managed object (e.g., group or device) for which to load and cluster data.\n */\n @Input()\n rootNode: IManagedObject;\n\n /**\n * Single asset to display (if not clustering). Used for following a specific device.\n * If provided, clusters will not be used, and the asset will be displayed individually.\n * If not provided, the component will cluster devices based on the root node.\n */\n @Input('asset')\n assets: PositionManagedObject;\n\n /**\n * Whether to show a color overlay for each cluster rectangle. This can be useful for debugging or visualizing clusters.\n */\n @Input()\n showClusterColor = false;\n\n /**\n * Emits Leaflet map change events (move, moveend) for external listeners.\n */\n @Output()\n mapChange = new EventEmitter<L.LeafletEvent>();\n\n /** Reference to the map element in the template. */\n @ViewChild('map')\n mapElement: ElementRef;\n\n /** Aggregator for dynamic component alerts. */\n alerts: DynamicComponentAlertAggregator;\n\n /** @ignore */\n errorNotifier = new BehaviorSubject(null);\n private reloadTrigger$ = new BehaviorSubject(false);\n private clusters: ClusterMap[] = [];\n private readonly EVENT_THROTTLE_TIME = 750;\n\n /**\n * Constructs the ClusterMapComponent, injecting required services and initializing the base map.\n */\n constructor(\n protected moRealtimeService: ManagedObjectRealtimeService,\n protected mapService: MapService,\n @Inject(MAP_TILE_LAYER) protected layers$: Observable<MapTileLayer[]>,\n @Inject(MAP_DEFAULT_CONFIG)\n protected defaultConfig$: Observable<MapDefaultConfig>,\n protected translateService: TranslateService,\n protected widgetGlobalAutoRefreshService: WidgetGlobalAutoRefreshService,\n private iterable: IterableDiffers,\n private colorService: ColorService,\n geo: GeoService,\n datePipe: DatePipe\n ) {\n super(\n moRealtimeService,\n mapService,\n layers$,\n defaultConfig$,\n translateService,\n geo,\n datePipe,\n widgetGlobalAutoRefreshService\n );\n }\n\n /**\n * @ignore\n */\n async ngOnChanges(changes: SimpleChanges) {\n if (changes.config?.firstChange) {\n return;\n }\n\n if (changes.rootNode?.previousValue !== changes.rootNode?.currentValue) {\n this.changeRootNode(changes.rootNode.currentValue);\n }\n\n if (changes.config?.currentValue) {\n this.changeConfig(changes.config);\n }\n }\n\n /**\n * Handles changes to the map configuration, including follow and refresh interval.\n * Cancels reload on follow, triggers reload on refresh interval change, and delegates to base config change logic.\n * @param change The config change object.\n */\n changeConfig(change: SimpleChange) {\n // on following, cancel reload to avoid stale state\n if (change.currentValue.follow === true) {\n this.cancelReload();\n this.isLoading$.next(false);\n }\n\n if (change.currentValue.refreshInterval !== change.previousValue.refreshInterval) {\n this.reload();\n }\n super.changeConfig(change);\n }\n\n /**\n * @ignore\n */\n async ngAfterViewInit() {\n if (!this.leaflet) {\n this.leaflet = await this.mapService.getLeaflet();\n }\n\n if (this.config.widgetInstanceGlobalAutoRefreshContext) {\n this.handleGlobalRefreshLoading();\n }\n combineLatest([this.layers$, this.defaultConfig$])\n .pipe(takeUntil(this.unsubscribeTrigger$))\n .subscribe(([layers, defaultConfig]) => {\n this.initMap(layers, defaultConfig);\n this.changeRootNode(this.rootNode);\n this.ch