UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1 lines • 156 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, QueriesUtil } 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';\nimport { GlobalContextDisplayMode } from '@c8y/ngx-components/global-context';\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\nexport interface GroupedPositionManagedObject {\n items: PositionManagedObject[];\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 isRealtimeEnabled?: boolean;\n isAutoRefreshEnabled?: boolean;\n /**\n * Optional refresh interval in milliseconds (null for no auto-refresh).\n */\n refreshInterval?: number | null;\n /** If set to true, the map will automatically fit to bounds on initialization. */\n autoFitToBounds?: boolean;\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 refreshOption?: 'live' | 'history';\n displayMode?: GlobalContextDisplayMode;\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 // Required for OSM tiles usage, possible values are documented here: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img#referrerpolicy\n referrerPolicy: 'strict-origin-when-cross-origin'\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 autoRefresh: { show: boolean; disabled?: boolean };\n refresh: { show: boolean; disabled?: boolean };\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\n/**\n * Extension of QueriesUtil to use decimal literals instead of float literals.\n * Used for queries involving longitude and latitude to avoid precision issues.\n */\nexport class QueriesUtilDecimalExtension extends QueriesUtil {\n override useAsFloat(operand: string | number) {\n operand = this.quoteString(operand);\n if (typeof operand === 'number') {\n return `${operand}d`;\n }\n return operand;\n }\n}\n","import { Injectable } from '@angular/core';\nimport {\n IIdentified,\n IManagedObject,\n InventoryService,\n IResultList,\n Paging,\n QueriesUtil\n} from '@c8y/client';\nimport { FeatureCacheService, 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, firstValueFrom, Observable, of } from 'rxjs';\nimport { map } from 'rxjs/operators';\nimport {\n ClusterSize,\n defaultLayer,\n defaultMapConfig,\n GroupedPositionManagedObject,\n MapTenantOptionKeys,\n PositionManagedObject,\n QueriesUtilDecimalExtension\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 /**\n * Returns the status of a group (highest severity among its devices).\n * @param group The group of devices.\n * @returns The status string representing the highest severity in the group.\n */\n static getGroupStatus(group: GroupedPositionManagedObject) {\n let mostCriticalStatus = 'text-muted';\n let highestSeverity = 0;\n\n for (const device of group.items) {\n const status = this.getStatus(device);\n const severity = this.getStatusSeverity(status);\n\n if (severity > highestSeverity) {\n highestSeverity = severity;\n mostCriticalStatus = status;\n\n if (severity === 4) {\n return status; // Critical is highest, return immediately\n }\n }\n }\n\n return mostCriticalStatus;\n }\n\n /**\n * Maps status strings to severity levels for comparison.\n * @param status The status string.\n * @returns Numeric severity level (higher = more severe).\n */\n private static getStatusSeverity(status: string): number {\n const severityMap: Record<string, number> = {\n 'status critical': 4,\n 'status major': 3,\n 'status minor': 2,\n 'status warning': 1,\n 'text-muted': 0\n };\n return severityMap[status] ?? 0;\n }\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 private queriesUtil = new QueriesUtil();\n\n /**\n * @ignore: Only DI.\n */\n constructor(\n private inventory: InventoryService,\n private options: OptionsService,\n private serviceRegistry: ServiceRegistry,\n private featureCacheService: FeatureCacheService\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 isInHierarchyOfMO 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 isInHierarchyOfMO?: IManagedObject\n ): Promise<number> {\n return this.getPositionMOsFromBound(bound, isInHierarchyOfMO, 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 isInHierarchyOfMO 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 isInHierarchyOfMO: 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 isInHierarchyOfMO 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 isInHierarchyOfMO: IManagedObject,\n count: true\n ): Promise<number>;\n async getPositionMOsFromBound(\n bound: L.LatLngBounds,\n isInHierarchyOfMO?: 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 filterObjWithCoordinates = {\n __and: [\n ...(isInHierarchyOfMO\n ? [\n {\n __or: [\n { id: isInHierarchyOfMO.id },\n await this.childrenOfGroupFilter(isInHierarchyOfMO)\n ]\n }\n ]\n : []),\n {\n __has: 'c8y_Position'\n },\n {\n ['c8y_Position.lat']: {\n __gt: latMin\n }\n },\n {\n ['c8y_Position.lat']: {\n __lt: latMax\n }\n },\n {\n [lngMin < lngMax ? '__and' : '__or']: [\n {\n ['c8y_Position.lng']: {\n __gt: lngMin\n }\n },\n {\n ['c8y_Position.lng']: {\n __lt: lngMax\n }\n }\n ]\n }\n ]\n };\n\n const query = this.queriesUtil.buildQuery(filterObjWithCoordinates);\n\n const { paging, data } = await this.inventory.list({\n pageSize: count ? 1 : this.MAX_DEVICE_PER_CLUSTER,\n withTotalPages: count,\n query\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 query = this.queriesUtil.buildQuery({\n __and: [{ __has: 'c8y_Position' }, { __has: 'c8y_IsDevice' }]\n });\n const { paging, data } = await this.inventory.list({\n pageSize: count ? 1 : pageSize,\n withTotalPages: !!count,\n query\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 isInHierarchyOfMO 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 isInHierarchyOfMO?: IIdentified,\n pageSize = 500\n ): Promise<IResultList<PositionManagedObject>> {\n const childrenFilter = isInHierarchyOfMO\n ? await this.childrenOfGroupFilter(isInHierarchyOfMO)\n : undefined;\n\n const orValue = childrenFilter ? [{ id: isInHierarchyOfMO.id }, childrenFilter] : undefined;\n\n const queryObj = {\n __filter: {\n __and: [{ __has: 'c8y_Position' }],\n ...(orValue ? { __or: orValue } : {})\n }\n };\n\n const query = this.queriesUtil.buildQuery(queryObj);\n const filter: { pageSize: number; withTotalPages: boolean; query: string } = {\n pageSize,\n withTotalPages: true,\n query\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 * Gets the bounds of all assets, optionally within a group.\n * @param isInHierarchyOfMO Is inhierachy of managed object to limit the assets to a group.\n * @returns The bounds of all assets.\n */\n async getAllBounds(isInHierarchyOfMO?: IIdentified): Promise<L.LatLngBounds> {\n return this.getBounds('query', isInHierarchyOfMO);\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 return this.getBounds('q');\n }\n\n /**\n * Calculates the bounding box that contains all given assets.\n * @param assets The assets to calculate the bounds for.\n * @returns The calculated bounds or undefined if no valid positions are found.\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 private async childrenOfGroupFilter(mo: IIdentified) {\n try {\n const useInHierarchyOf = await firstValueFrom(\n this.featureCacheService.getFeatureState('ui.map.isinhierarchyof')\n );\n\n if (useInHierarchyOf) {\n return { __isinhierarchyof: mo.id };\n }\n } catch (e) {\n console.warn('Error while determining isinhierarchyof feature toggle state:', e);\n }\n\n return { __bygroupid: mo.id };\n }\n\n /**\n * Internal method to calculate bounds based on position coordinates.\n * @param queryName The query parameter name to use ('q' or 'query'). Use q to limit to devices only.\n * @param isInHierarchyOfMO Optional managed object to limit the assets to a group.\n * @returns The calculated bounds\n */\n private async getBounds(\n queryName: 'q' | 'query',\n isInHierarchyOfMO?: IIdentified\n ): Promise<L.LatLngBounds> {\n const childrenFilter = isInHierarchyOfMO\n ? await this.childrenOfGroupFilter(isInHierarchyOfMO)\n : undefined;\n const filter = (coord: 'lat' | 'lng', order: 'asc' | 'desc') => ({\n pageSize: 1,\n [queryName]: new QueriesUtil().buildQuery({\n __filter: {\n __and: [\n childrenFilter,\n {\n __has: 'c8y_Position'\n }\n ].filter(Boolean)\n },\n __orderby: [\n {\n [`c8y_Position.${coord}`]: order === 'asc' ? 1 : -1\n }\n ]\n })\n });\n\n const filterReverse = (op: 'lt' | 'gt', order: 'asc' | 'desc') => ({\n pageSize: 1,\n [queryName]: new QueriesUtilDecimalExtension().buildQuery({\n __filter: {\n __and: [\n childrenFilter,\n {\n __has: 'c8y_Position',\n [`c8y_Position.lng`]: {\n [`__${op}`]: 0\n }\n }\n ].filter(Boolean)\n },\n __orderby: [\n {\n [`c8y_Position.lng`]: order === 'asc' ? 1 : -1\n }\n ]\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","import { IterableChangeRecord, IterableDiffer, IterableDiffers } from '@angular/core';\nimport { TranslateService } from '@ngx-translate/core';\nimport type * as L from 'leaflet';\nimport { C8yMarker, GroupedPositionManagedObject } from './map.model';\nimport { MapService } from './map.service';\n\nexport class ClusterMap {\n markers: C8yMarker[] = [];\n positions: GroupedPositionManagedObject[] = [];\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<GroupedPositionManagedObject> | null;\n\n constructor(\n private iterable: IterableDiffers,\n private addAssetCallback: (asset: GroupedPositionManagedObject) => 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: GroupedPositionManagedObject, 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 divMarker = leaflet.divIcon({\n html: `<div><div class=\"popover top show\"><span class=\"popover-content p-l-0 p-t-0 p-b-0 p-r-4 d-flex a-i-center\"><i class=\"dlt-c8y-icon-marker\"></i><span class=\"text-12 text-nowrap\">${count}</span></span><span class=\"arrow\" style=\"translate: -50% 0\"></span></div></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<GroupedPositionManagedObject>) => {\n this.removeMarkerFromMap(record.item);\n });\n\n changes.forEachAddedItem((record: IterableChangeRecord<GroupedPositionManagedObject>) => {\n this.addMarkerToMap(record.item, map);\n });\n }\n }\n\n private trackBy(index: number, item: GroupedPositionManagedObject) {\n const trackItems = item.items.map(i => [\n i.id,\n i.c8y_Position.lat,\n i.c8y_Position.lng,\n MapService.getStatus(i)\n ]);\n return trackItems.join('');\n }\n\n private removeMarkerFromMap(group: GroupedPositionManagedObject) {\n group.items.forEach(device => {\n const markers = this.markers.filter((marker: C8yMarker) => marker.asset?.id === device.id);\n markers.forEach(marker => marker.remove());\n });\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 { gettext } from '@c8y/ngx-components/gettext';\nimport {\n DatePipe,\n GeoService,\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 GroupedPositionManagedObject,\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 private markerGroupTitle = gettext('Group of {{count}} assets');\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.realtimeSubscription) {\n return;\n }\n\n if (this.assets === null || (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\n if (!marker) {\n return;\n }\n\n const icon = this.getAssetIcon(asset);\n const newLatLng = this.geo.getLatLong(asset);\n\n marker.setIcon(icon);\n marker.setLatLng(newLatLng);\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 if (!positions || !this.map) {\n return;\n }\n const position = Array.isArray(positions) ? positions[0] : positions;\n if (!position?.c8y_Position) {\n return;\n }\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 this.realtimeSubscription = null;\n }\n }\n\n /**\n * Finds a marker on the map by asset, event, or ID. (only if it is a C8yMarker)\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): C8yMarker | undefined {\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 ) as C8yMarker | undefined;\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: GroupedPositionManagedObject) {\n if (!asset || asset.items.length === 0) {\n return;\n }\n\n if (asset.items.length === 1) {\n return this.getAssetMarkerSingle(asset.items[0]);\n } else {\n return this.getGroupedMarker(asset);\n }\n }\n\n /**\n * Creates and returns a marker for a single asset, including icon and popup.\n * @param asset The asset to create a marker for.\n * @returns The created marker.\n */\n getAssetMarkerSingle(asset: PositionManagedObject) {\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 * Returns a marker with popup for a group of assets (clustered).\n * @param group The asset group.\n * @returns A marker for a group of assets (clustered).\n */\n getGroupedMarker(group: GroupedPositionManagedObject) {\n const latLngs = group.items.map(item =>\n this.leaflet.latLng(item.c8y_Position.lat, item.c8y_Position.lng)\n );\n const bounds = this.leaflet.latLngBounds(latLngs);\n const center = bounds.getCenter();\n\n const icon = this.getGroupAssetIcon(group);\n const leafletMarker = this.leaflet.marker(center, {\n icon,\n title: this.translateService.instant(this.markerGroupTitle, { count: group.items.length })\n });\n const asset = group.items[0];\n const marker = getC8yMarker(leafletMarker, asset);\n\n marker.on('click', () => {\n this.map.fitBounds(bounds, { padding: [50, 50] });\n // Check if all markers are at the same position (within a small threshold)\n const areAllSamePosition = latLngs.every(\n ll => Math.abs(ll.lat - center.lat) < 0.0001 && Math.abs(ll.lng - center.lng) < 0.0001\n );\n\n // If all markers are at the same position and zoom is low, create spider layout\n if (areAllSamePosition && this.map && this.map.getZoom() >= 10) {\n this.createSpiderMarkers(group, center, marker);\n }\n });\n\n return marker;\n }\n\n /**\n * Creates spider markers for a group of assets at the same position.\n * @param group The group of assets to create spider markers for.\n * @param center The center position of the group.\n * @param originalMarker The marker which was replaced by spider markers.\n */\n createSpiderMarkers(\n group: GroupedPositionManagedObject,\n center: L.LatLng,\n originalMarker: C8yMarker\n ): void {\n const markerGroup = this.leaflet.featureGroup();\n const items = group.items;\n const count = items.length;\n const radius = 0.0002; // Adjust based on your needs\n const angleStep = (2 * Math.PI) / count;\n\n originalMarker.setOpacity(0); // Hide the original marker\n\n items.forEach((item, index) => {\n const angle = angleStep * index;\n const offsetLat = center.lat + radius * Math.cos(angle);\n const offsetLng = center.lng + radius * Math.sin(angle);\n const markerPos = this.leaflet.latLng(offsetLat, offsetLng);\n\n // Create spider leg (line from center to marker)\n const polyline = this.leaflet.polyline([center, markerPos], {\n color: '#666',\n weight: 2,\n opacity: 1\n });\n polyline.addTo(markerGroup);\n\n // Create individual marker\n const icon = this.getAssetIcon(item, [6, -12]);\n const leafletMarker = this.leaflet.marker(markerPos, {\n icon,\n title: this.translateService.instant(this.markerTitle, {\n lat: item.c8y_Position.lat,\n lng: item.c8y_Position.lng\n })\n });\n const marker = getC8yMarker(leafletMarker, item);\n this.bindPopup(marker, item, new this.leaflet.Point(-2, -20));\n marker.addTo(markerGroup);\n });\n\n // Create red center dot\n const centerIcon = this.leaflet.divIcon({\n html: '<div style=\"background-color: red; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;\"></div>',\n className: 'spider-center-marker',\n iconSize: [12, 12],\n iconAnchor: [6, 6]\n });\n const centerMarker = this.leaflet.marker(center, {\n icon: centerIcon,\n title: this.translateService.instant(gettext('Click to collapse'))\n });\n\n const restoreOriginalMarker = () => {\n this.map.off('zoomstart', restoreOriginalMarker);\n this.map.off('movestart', restoreOriginalMarker);\n markerGroup.remove();\n originalMarker.setOpacity(1);\n };\n // Add click handler to collapse spider and remove it\n // if user navigates away\n centerMarker.on('click', restoreOriginalMarker);\n this.map.on('zoomstart', restoreOriginalMarker);\n this.map.on('movestart', restoreOriginalMarker);\n\n centerMarker.addTo(markerGroup);\n markerGroup.addTo(this.map);\n }\n\n /**\n * The icon for a group of assets (clustered).\n * It shows the count and is colored based on the highest severity status in the group.\n * @param group The group of assets.\n * @returns The marker icon for the group.\n */\n getGroupAssetIcon(group: GroupedPositionManagedObject) {\n const status = MapService.getGroupStatus(group);\n const color = this.config.color ? `style='color: ${this.config.color};'` : '';\n const moreThan99 = group.items.length > 99 ? 'more-than-99' : '';\n const icon = this.leaflet.divIcon({\n html: `<div><div class=\"popover top show\"><span class=\"popover-content p-l-0 p-t-0 p-b-0 p-r-4 d-flex a-i-center\"><i class=\"dlt-c8y-icon-marker ${status}\" ${color}></i><span class=\"text-12 text-nowrap ${moreThan99}\">${group.items.length > 99 ? '99+' : group.items.length}</span></span><span class=\"arrow\" style=\"translate: -50% 0\"></span></div></div>`,\n className: 'c8y-map-marker-icon',\n iconAnchor: [21, 40]\n });\n return icon;\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 * @param anchor The icon anchor point.\n * @returns The Leaflet icon.\n */\n getAssetIcon(asset: PositionManagedObject, anchor: [number, number] = [8, 8]) {\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: anchor\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\n assets.forEach(asset => {\n const marker = this.getAssetMarkerSingle(asset);\n this.addMarkerToMap(marker);\n });\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: