UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1,246 lines (1,239 loc) 72.5 kB
import * as i0 from '@angular/core'; import { InjectionToken, Injectable, Directive, EventEmitter, Output, Input, ContentChild, ViewChild, Inject, Component, SimpleChange, NgModule } from '@angular/core'; import * as i1$1 from '@c8y/ngx-components'; import { gettext, sortByPriority, ManagedObjectRealtimeService, globalAutoRefreshLoading, CountdownIntervalComponent, IconDirective, C8yTranslatePipe, CommonModule as CommonModule$1, FormsModule as FormsModule$1, RealtimeModule, CoreModule } from '@c8y/ngx-components'; import * as i3 from '@ngx-translate/core'; import * as i4 from 'rxjs'; import { of, combineLatest, defer, NEVER, BehaviorSubject, Subject, fromEvent, EMPTY, merge, from } from 'rxjs'; import { map, first, takeUntil, scan, filter, switchMap, tap, mergeMap, catchError, debounceTime, skip } from 'rxjs/operators'; import * as i1 from '@c8y/client'; import { latLng, latLngBounds } from 'leaflet'; import { get, remove, isUndefined, flatten, every, isNull, isEmpty, cloneDeep } from 'lodash-es'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgIf, NgClass, AsyncPipe, CommonModule } from '@angular/common'; import { TooltipDirective, TooltipModule } from 'ngx-bootstrap/tooltip'; import { FormsModule } from '@angular/forms'; /** * Utility function to assign asset and event information to a Leaflet marker. * @param marker The Leaflet marker instance. * @param asset The managed object representing the asset (optional). * @param event The event associated with the marker (optional). * @returns The marker with asset and/or event information attached. */ function getC8yMarker(marker, asset, event) { marker.asset = asset; marker.event = event; return marker; } /** * Injection token for providing map tile layers as an observable. */ const MAP_TILE_LAYER = new InjectionToken('MAP_TILE_LAYER'); /** * Enum for supported cluster sizes on the map. */ var ClusterSize; (function (ClusterSize) { /** No clustering. */ ClusterSize[ClusterSize["NONE"] = 0] = "NONE"; /** Cluster of 4. */ ClusterSize[ClusterSize["FOUR"] = 1] = "FOUR"; /** Cluster of 16. */ ClusterSize[ClusterSize["SIXTEEN"] = 2] = "SIXTEEN"; })(ClusterSize || (ClusterSize = {})); /** * Enum for map tenant option keys used in configuration. */ var MapTenantOptionKeys; (function (MapTenantOptionKeys) { /** Map configuration key. */ MapTenantOptionKeys["CONFIG"] = "map-config"; /** Map layers key. */ MapTenantOptionKeys["LAYERS"] = "map-layers"; })(MapTenantOptionKeys || (MapTenantOptionKeys = {})); /** * Injection token for providing the default map configuration as an observable. */ const MAP_DEFAULT_CONFIG = new InjectionToken('MAP_DEFAULT_CONFIG'); /** * Default map tile layer configuration (OpenStreetMap). */ const defaultLayer = { layerUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', label: 'OpenStreetMap', priority: 1000, options: { maxZoom: 18, minZoom: 0, attribution: '&copy;<a href="http://www.openstreetmap.org/copyright" rel="noreferrer nofollow">OpenStreetMap</a>', noWrap: false } }; /** * Default map configuration (centered on Düsseldorf, zoom level 2). */ const defaultMapConfig = { center: [51.23544, 6.79599], // Düsseldorf zoomLevel: 2 }; /** * Default options for fitting map bounds (padding). */ const defaultFitBoundsOptions = { padding: [50, 50] }; class MapService { /** * Returns asset icon status for highest alarm severity found in device object. * @param device Device that contains alarms information. * @returns Status string according to alarm severity */ static getStatus(device) { if (!device.c8y_ActiveAlarmsStatus) { return 'text-muted'; } if (device.c8y_ActiveAlarmsStatus.critical) { return 'status critical'; } if (device.c8y_ActiveAlarmsStatus.major) { return 'status major'; } if (device.c8y_ActiveAlarmsStatus.minor) { return 'status minor'; } if (device.c8y_ActiveAlarmsStatus.warning) { return 'status warning'; } return 'text-muted'; } /** * @ignore: Only DI. */ constructor(inventory, options, serviceRegistry) { this.inventory = inventory; this.options = options; this.serviceRegistry = serviceRegistry; /** * The devices that are maximal displayed in one cluster. */ this.MAX_DEVICE_PER_CLUSTER = 200; /** * The count until the cluster is sized. There are a maximum of * three clusters: 1, 4 or 16. */ this.CLUSTER_LEVEL_THRESHOLD = 500; } /** * Returns the leaflet instance used by the cumulocity core. */ async getLeaflet() { const originalLeflet = window.L; const c8yLeafletInstance = (await import('leaflet')).default; c8yLeafletInstance.noConflict(); window.L = originalLeflet; return c8yLeafletInstance; } /** * Verifies if a given managed object is a device with a position fragment. * @param mo The given managed object. */ isPositionedDevice(mo) { return !!(mo?.c8y_IsDevice && this.hasPosition(mo)); } /** * Verifies if a given managed object has a position fragment. * @param mo The given managed object. */ hasPosition(mo) { return mo?.c8y_Position; } getMapTileLayerProviders() { const layerProviders = this.serviceRegistry.get('mapTileLayerHook'); return layerProviders; } getMapTileLayersFromHookedProviders$(layerProviders) { if (!layerProviders.length) { return of([]); } const layers = combineLatest(layerProviders.map(provider => provider.getMapTileLayers$())); return layers.pipe(map(layers => layers.flat())); } /** * Returns the layers available in this application. * Layers are taken from plugins installed to this application. * In case none of the plugins override the default layers, the default layers are also considered. * @returns The layers. */ getMapTileLayers$() { return defer(() => { const layerProviders = this.getMapTileLayerProviders(); const overridesDefaultLayer = layerProviders.some(provider => provider.overridesDefaultLayer?.()); const layersFromProviders = defer(() => this.getMapTileLayersFromHookedProviders$(layerProviders)); if (overridesDefaultLayer) { return layersFromProviders; } return combineLatest([this.getDefaultLayers(), layersFromProviders]).pipe(map(layers => { return layers.flat(); })); }); } /** * Returns the layers configured in the current platform via tenant options. * @returns The layers. If not set in tenant options the default layers. */ getDefaultLayers() { return this.getMapOption(MapTenantOptionKeys.LAYERS, this.options.mapLayers || [defaultLayer]); } /** * Returns the map configuration configured on the tenant. * @returns The configuration. If not set in tenant options the default configuration. */ getDefaultConfig() { return this.getMapOption(MapTenantOptionKeys.CONFIG, this.options.mapConfig || defaultMapConfig); } /** * Counts all managed objects in a given bound with a c8y_Position fragment. * @param bound The lat lng bound to request the managed objects for. * @param byGroupIdMO The group managed object of which direct children should be searched for. * @returns The number of all position managed objects in the given bound (and group). */ async getPositionMOsFromBoundCount(bound, byGroupIdMO) { return this.getPositionMOsFromBound(bound, byGroupIdMO, true); } async getPositionMOsFromBound(bound, byGroupIdMO, count = false) { const { lat: latMin, lng: lngMinRaw } = bound.getSouthWest(); const { lat: latMax, lng: lngMaxRaw } = bound.getNorthEast(); const lngMin = lngMaxRaw - lngMinRaw > 360 ? -180 : this.normalizeLongitude(lngMinRaw); const lngMax = lngMaxRaw - lngMinRaw > 360 ? 180 : this.normalizeLongitude(lngMaxRaw); const byGroupIdFilter = byGroupIdMO ? `(bygroupid(${byGroupIdMO.id}) or id eq '${byGroupIdMO.id}') and ` : ''; let boundFilter = `$filter=${byGroupIdFilter}has(c8y_Position) and c8y_Position.lat gt ${latMin}d and c8y_Position.lat lt ${latMax}d`; if (lngMin < lngMax) { boundFilter = `${boundFilter} and c8y_Position.lng gt ${lngMin}d and c8y_Position.lng lt ${lngMax}d`; } else { boundFilter = `${boundFilter} and (c8y_Position.lng gt ${lngMin}d or c8y_Position.lng lt ${lngMax}d)`; } const { paging, data } = await this.inventory.list({ pageSize: count ? 1 : this.MAX_DEVICE_PER_CLUSTER, withTotalPages: count, query: boundFilter }); if (count) { return paging.totalPages; } return data.map((pmo) => bound.contains(latLng(pmo.c8y_Position.lat, pmo.c8y_Position.lng)) ? pmo : this.denormalizePMO(pmo, bound)); } /** * Returns all devices with c8y_Position. * @param pageSize The page size to return. * @param count Switches to counting only. * @returns All devices or the device count with a c8y_Position fragment. */ async getPositionDevices(pageSize = this.MAX_DEVICE_PER_CLUSTER, count) { const { paging, data } = await this.inventory.list({ pageSize: count ? 1 : pageSize, withTotalPages: !!count, query: '$filter=has(c8y_Position) and has(c8y_IsDevice)' }); if (count) { return paging.totalPages; } return data; } /** * Returns all managed object with a c8y_Position fragment. * @param byGroupIdMO The group managed object of which direct children should be searched for. * @param pageSize Defines how many results should be returned. * @returns The managed objects with position. */ async getAllPositionMOs(byGroupIdMO, pageSize = 500) { const filter = { pageSize, withTotalPages: true, query: 'has(c8y_Position)' }; if (byGroupIdMO) { filter.query = `$filter=(bygroupid(${byGroupIdMO.id}) or id eq '${byGroupIdMO.id}') and has(c8y_Position)`; } const { paging, data, res } = await this.inventory.list(filter); return { res, paging: paging, data: data }; } /** * Determines a rectangular geographical area based on the positions of all devices. * * @returns A [[LatLngBounds]] object fitting all devices' geo positions. */ async getAllDevicesBounds() { const filter = (coord, order) => ({ pageSize: 1, q: `$filter=has(c8y_Position) $orderby=c8y_Position.${coord} ${order}` }); const filterReverse = (op, order) => ({ pageSize: 1, q: `$filter=has(c8y_Position) and c8y_Position.lng ${op} 0d $orderby=c8y_Position.lng ${order}` }); const [latMin, latMax, lngMin, lngMax, lngRevMin, lngRevMax] = await Promise.all([ this.inventory.list(filter('lat', 'asc')), this.inventory.list(filter('lat', 'desc')), this.inventory.list(filter('lng', 'asc')), this.inventory.list(filter('lng', 'desc')), this.inventory.list(filterReverse('gt', 'asc')), this.inventory.list(filterReverse('lt', 'desc')) ]).then(result => result.map(r => get(r.data, '[0].c8y_Position'))); const shiftWorld = (lngRevMin?.lng ?? 0) - (lngRevMax?.lng ?? 0) > 180; return latLngBounds(latLng(latMin?.lat, shiftWorld ? lngRevMin?.lng : lngMin?.lng), latLng(latMax?.lat, shiftWorld ? lngRevMax?.lng + 360 : lngMax?.lng)); } async getAssetsBounds(assets) { const leaflet = await this.getLeaflet(); const bounds = leaflet.latLngBounds([]); let hasValidPositions = false; assets.forEach(asset => { const position = asset.c8y_Position; if (position && typeof position.lat === 'number' && typeof position.lng === 'number') { bounds.extend([position.lat, position.lng]); hasValidPositions = true; } }); if (!hasValidPositions || !bounds.isValid()) { return; } return bounds; } /** * Returns the cluster size for clustered maps. Counting the position MOs in a bounding * and if it reach a threshold, returning a [[ClusterSize]]. * @param bound The bounding to check for cluster size. * @returns The cluster size, can be NONE, FOUR or SIXTEEN. */ async getClusterSize(bound) { const count = await this.getPositionMOsFromBoundCount(bound); let clusterSize = ClusterSize.NONE; if (count > this.CLUSTER_LEVEL_THRESHOLD) { clusterSize = ClusterSize.SIXTEEN; } else if (count > this.MAX_DEVICE_PER_CLUSTER) { clusterSize = ClusterSize.FOUR; } return clusterSize; } getMapOption(key, defaultValue) { return defer(() => this.options.getTenantOption('configuration', key, defaultValue)).pipe(map(config => { if (typeof config === 'string') { console.error(`The tenant option for maps 'configuration.${key}' is not a valid JSON structure.`); return defaultValue; } return config; })); } /** * Shifts longitudes received from Leaflet.js in the [-180 - k*360; 180 + k*360] rangewhen * `noWrap` is enabled to the [-180; 180] range expected for values of the c8y_Position fragment. * * @param lng Longitude to shift. * @returns Longitude value in the [-180; 180] range */ normalizeLongitude(lng) { return ((((lng + 180) % 360) + 360) % 360) - 180; } /** * Shifts longitudes in the [-180; 180] range expected for values of the c8y_Position fragment * the the [-180 - k*360; 180 + k*360] range expected from Leaflet.js when `noWrap` is enabled. * * The method naively adds/subtracts 360 degrees to the original value until the position fits in the expected bounds. * * @param pmo A managed object with a `c8y_Position` fragment * @param bounds The bounds where the position should fit * @returns A managed object whose `c8y_Position`'s `lng` values has been shifted to fit in bounds */ denormalizePMO(pmo, bounds) { let { lng } = pmo.c8y_Position; const shiftFactor = lng > bounds.getEast() ? -1 : 1; while (!bounds.contains(latLng(pmo.c8y_Position.lat, lng))) { lng += shiftFactor * 360; } pmo.c8y_Position.lng = lng; return pmo; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapService, deps: [{ token: i1.InventoryService }, { token: i1$1.OptionsService }, { token: i1$1.ServiceRegistry }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.InventoryService }, { type: i1$1.OptionsService }, { type: i1$1.ServiceRegistry }] }); class ClusterMap { set clusterMarker(item) { this.removeClusterToBigMarker(); this._clusterMarker = item; } get clusterMarker() { return this._clusterMarker; } set rect(item) { if (this._rect) { this._rect.remove(); } this._rect = item; } get rect() { return this._rect; } constructor(iterable, addAssetCallback, translateService) { this.iterable = iterable; this.addAssetCallback = addAssetCallback; this.translateService = translateService; this.markers = []; this.positions = []; this.iterableDiffer = this.iterable.find(this.positions).create(this.trackBy); } render(map) { if (this._rect) { this._rect.addTo(map); } this.updateChanges(map); if (this._clusterMarker) { this._clusterMarker.addTo(map); } } clear(map) { this.removeClusterToBigMarker(); this._rect.remove(); this.positions = []; this.updateChanges(map); } removeClusterToBigMarker() { if (this._clusterMarker) { this._clusterMarker.remove(); this._clusterMarker = null; } } addMarkerToMap(device, map) { const marker = this.addAssetCallback(device); this.markers.push(marker); marker.addTo(map); } setClusterToBigMarker(map, count, leaflet) { const bound = this.rect.getBounds(); const text = this.translateService.instant(gettext('Zoom in')); const divMarker = leaflet.divIcon({ html: `<div class="c8y-map-marker-count" data-count="${count}" title="${text}"></div>` }); const labelIcon = leaflet.marker(bound.getCenter(), { icon: divMarker }); labelIcon.addTo(map); labelIcon.on('click', () => { map.fitBounds(bound); }); this.clusterMarker = labelIcon; } updateChanges(map) { const changes = this.iterableDiffer.diff(this.positions); if (changes) { changes.forEachRemovedItem((record) => { this.removeMarkerFromMap(record.item); }); changes.forEachAddedItem((record) => { this.addMarkerToMap(record.item, map); }); } } trackBy(index, item) { const trackItems = [ item.id, item.c8y_Position.lat, item.c8y_Position.lng, MapService.getStatus(item) ]; return trackItems.join(''); } removeMarkerFromMap(device) { const markers = this.markers.filter((marker) => marker.asset?.id === device.id); markers.forEach(marker => marker.remove()); } } class MapPopupDirective { constructor(template, elementRef, viewContainer) { this.template = template; this.elementRef = elementRef; this.viewContainer = viewContainer; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapPopupDirective, deps: [{ token: i0.TemplateRef }, { token: i0.ElementRef }, { token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Directive }); } static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.14", type: MapPopupDirective, isStandalone: true, selector: "[c8yMapPopup]", ngImport: i0 }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapPopupDirective, decorators: [{ type: Directive, args: [{ selector: '[c8yMapPopup]' }] }], ctorParameters: () => [{ type: i0.TemplateRef }, { type: i0.ElementRef }, { type: i0.ViewContainerRef }] }); class MapComponent { constructor(moRealtimeService, mapService, layers$, defaultConfig$, translateService, geo, datePipe, widgetGlobalAutoRefreshService) { this.moRealtimeService = moRealtimeService; this.mapService = mapService; this.layers$ = layers$; this.defaultConfig$ = defaultConfig$; this.translateService = translateService; this.geo = geo; this.datePipe = datePipe; this.widgetGlobalAutoRefreshService = widgetGlobalAutoRefreshService; /** * The markers currently placed on the map. */ this.markers = []; /** * Indicates if the map was already initialized. */ this.isInit = false; /** * Map configuration object (center, zoom, icon, color, etc). */ this.config = {}; /** * Observable for polyline coordinates to display on the map. */ this.polyline$ = NEVER; /** * Emits when a tracked asset is updated in real-time. */ this.onRealtimeUpdate = new EventEmitter(); /** * Emits the Leaflet map instance when available. */ this.onMap = new BehaviorSubject(null); /** * Emits when the map and Leaflet library are initialized. */ this.onInit = new EventEmitter(); this.unsubscribeTrigger$ = new Subject(); this.destroy$ = new Subject(); this.markerTitle = gettext('Marker at position {{lat}}, {{lng}}'); this.initOutputs(); } /** * Starts real-time updates for a single asset on the map. * Updates marker position and icon as new data arrives. */ startRealtime() { if (!this.assets || (Array.isArray(this.assets) && this.assets.length > 1)) { this.config.realtime = false; this.stopRealtime(); return; } const asset = Array.isArray(this.assets) ? this.assets[0] : this.assets; this.realtimeSubscription = this.moRealtimeService .onUpdate$(asset) .subscribe((asset) => { const marker = this.findMarker(asset.id); const icon = this.getAssetIcon(asset); marker.setIcon(icon); marker.setLatLng(this.geo.getLatLong(asset)); if (Array.isArray(this.assets)) { this.assets[0] = asset; } else { this.assets = asset; } this.moveToPositionOfMo(asset); this.onRealtimeUpdate.emit(asset); }); } /** * Moves the map view to the position of the given asset(s) if follow is enabled. * @param positions The asset or array of assets to center the map on. */ moveToPositionOfMo(positions) { const position = Array.isArray(positions) ? positions[0] : positions; if (this.config.follow) { this.map.setView([position.c8y_Position.lat, position.c8y_Position.lng]); } } /** * Stops real-time updates for the asset. */ stopRealtime() { if (this.realtimeSubscription) { this.realtimeSubscription.unsubscribe(); } } /** * Finds a marker on the map by asset, event, or ID. * @param moOrId Asset, event, or string ID to search for. * @returns The found marker or undefined. */ findMarker(moOrId) { const getId = moOrId => (typeof moOrId === 'string' ? moOrId : moOrId?.id); return this.markers.find((marker) => marker.asset?.id === getId(moOrId) || marker.event?.id === getId(moOrId)); } /** * Adds a marker to the map and internal marker list. * @param marker The marker to add. */ addMarkerToMap(marker) { this.markers.push(marker); marker.addTo(this.map); } /** * Creates and returns a marker for the given asset, including icon and popup. * @param asset The asset to create a marker for. * @returns The created marker. */ getAssetMarker(asset) { if (!asset) { return; } const icon = this.getAssetIcon(asset); const { lat, lng } = asset.c8y_Position; const leafletMarker = this.leaflet.marker(this.geo.getLatLong(asset), { icon, title: this.translateService.instant(this.markerTitle, { lat, lng }) }); const marker = getC8yMarker(leafletMarker, asset); this.bindPopup(marker, asset); return marker; } /** * Creates and returns a marker for a tracking event, including icon and popup. * @param event The event to create a marker for. * @returns The created marker. */ getTrackingMarker(event) { if (!event) { return; } const icon = this.getTrackingIcon(); const { lat, lng } = event.c8y_Position; const leafletMarker = this.leaflet.marker(this.geo.getLatLong(event), { icon, title: this.translateService.instant(this.markerTitle, { lat, lng }) }); const marker = getC8yMarker(leafletMarker, null, event); this.bindPopup(marker, event); return marker; } /** * Returns a Leaflet icon for the given asset, using config or asset icon and color. * @param asset The asset to get the icon for. * @returns The Leaflet icon. */ getAssetIcon(asset) { const assetTypeIcon = this.config.icon || asset.icon?.name; const status = MapService.getStatus(asset); const color = this.config.color ? `style='color: ${this.config.color};'` : ''; const icon = this.leaflet.divIcon({ html: `<div class="dlt-c8y-icon-marker icon-3x ${status}" ${color}><i class="dlt-c8y-icon-${assetTypeIcon || 'data-transfer'}" /></div>`, className: 'c8y-map-marker-icon', // iconAnchor is used to set the marker accurately on click iconAnchor: [8, 8] }); return icon; } /** * Returns a Leaflet icon for a tracking event. * @returns The Leaflet icon. */ getTrackingIcon() { const icon = this.leaflet.divIcon({ html: `<div class="dlt-c8y-icon-marker icon-3x text-muted"></div>`, className: 'c8y-map-marker-icon', // iconAnchor is used to set the marker accurately on click iconAnchor: [8, 8] }); return icon; } /** * Removes a marker from the map and internal marker list. * @param marker The marker to remove. */ removeMarker(marker) { if (marker) { marker.remove(); remove(this.markers, m => m === marker); } } /** * Removes all markers from the map, optionally filtered by marker attribute. * @param fragment Optional marker attribute to filter by. */ clearMarkers(fragment) { const matchingMarkers = marker => !fragment || !!marker[fragment]; this.markers.filter(matchingMarkers).forEach(marker => marker.remove()); this.markers = this.markers.filter(marker => !matchingMarkers(marker)); } /** * Refreshes all markers on the map based on the current assets. */ refreshMarkers() { this.clearMarkers(); let assets = []; if (!isUndefined(this.assets)) { assets = Array.isArray(this.assets) ? this.assets : [this.assets]; } assets.forEach(asset => { const marker = this.getAssetMarker(asset); this.addMarkerToMap(marker); }); if (!this.config.center) { this.zoomToBound(assets); } this.toggleControls(); } /** * Centers the map on the configured center coordinates. */ center() { this.map?.setView(this.config.center); } /** * Refreshes the map and markers if the map is initialized. */ refresh() { if (this.isInit) { this.refreshMarkers(); } } async ngAfterViewInit() { this.leaflet = await this.mapService.getLeaflet(); const initialized$ = combineLatest([this.layers$, this.defaultConfig$]).pipe(first(), takeUntil(this.unsubscribeTrigger$)); initialized$.subscribe(([layers, defaultConfig]) => { this.initMap(layers, defaultConfig); this.refreshMarkers(); }); combineLatest([this.polyline$, initialized$]) .pipe(map(([expressions]) => this.leaflet.polyline(expressions, this.polylineOptions)), scan((oldPolyline, newPolyline) => { if (!!oldPolyline) { this.map.removeLayer(oldPolyline); } if (!!newPolyline) { newPolyline.addTo(this.map); this.fitBounds(newPolyline.getBounds()); } return newPolyline; }, null), takeUntil(this.unsubscribeTrigger$)) .subscribe(); } ngOnChanges(changes) { if (!this.map) { return; } if (changes.assets?.currentValue && !changes.assets?.firstChange) { this.refreshMarkers(); } if (changes.config?.currentValue && !changes.config?.firstChange) { this.changeConfig(changes.config); } } ngOnDestroy() { this.unsubscribeAllListeners(); this.destroy$.next(); this.destroy$.complete(); } unsubscribeAllListeners() { this.unsubscribeTrigger$.next(); this.stopRealtime(); } initOutputs() { const getMapEventObservable = eventName => { return this.onMap.pipe(filter(map => !!map), switchMap(map => fromEvent(map, eventName)), takeUntilDestroyed()); }; const dragStart$ = getMapEventObservable('dragstart'); const move$ = getMapEventObservable('move'); const dragEnd$ = getMapEventObservable('dragend'); this.onMove = dragStart$.pipe(switchMap(() => move$.pipe(takeUntil(dragEnd$))), takeUntil(this.unsubscribeTrigger$)); this.onMoveEnd = getMapEventObservable('moveend'); this.onZoomEnd = getMapEventObservable('zoomend'); this.onZoomStart = getMapEventObservable('zoomstart'); } initMap(layers, defaultConfig) { const defaultOptions = { center: this.config.center || defaultConfig.center, zoomSnap: 0, zoom: this.config.zoomLevel || defaultConfig.zoomLevel, worldCopyJump: true }; if (this.map) { this.map.remove(); } this.map = this.leaflet.map(this.mapElement.nativeElement, defaultOptions); this.map.attributionControl.setPrefix(''); this.fitBounds(this.config.bounds); this.addLayers(layers); this.handleMobile(); this.onMap.next(this.map); if (this.config.realtime) { this.startRealtime(); } this.isInit = true; this.onInit.emit(this.leaflet); } handleMobile() { // adding event listener to do mobile 2 finger scrolling if (this.leaflet.Browser.mobile) { const touchMsg = this.translateService.instant(gettext('Use two fingers to move the map.')); this.map.dragging.disable(); const container = this.map.getContainer(); container.setAttribute('data-touch-warning-content', touchMsg); container.addEventListener('touchstart', event => this.handleTouch(event)); container.addEventListener('touchmove', event => this.handleTouch(event)); container.addEventListener('touchend', event => this.handleTouch(event)); container.addEventListener('touchcancel', event => this.handleTouch(event)); container.addEventListener('click', event => this.handleTouch(event)); } } addLayers(layers) { const flattenLayers = flatten(layers); const baseLayers = {}; const overlays = {}; let firstLayer = true; for (const layer of sortByPriority(flattenLayers)) { const objectToAddTo = layer.isOverlay ? overlays : baseLayers; if (objectToAddTo[layer.label]) { continue; } const tiles = this.leaflet.tileLayer(layer.layerUrl, layer.options); if (!layer.isOverlay && firstLayer) { firstLayer = false; tiles.addTo(this.map); } objectToAddTo[layer.label] = tiles; } if (flattenLayers.length > 1) { this.leaflet.control.layers(baseLayers, overlays, { position: 'bottomleft' }).addTo(this.map); } } changeConfig(change) { if (this.hasChanged(change, 'zoomLevel')) { this.map.setZoom(this.config.zoomLevel); } if (this.hasChanged(change, 'center') && every(change.currentValue.center, p => !isNull(p))) { this.map.setView(change.currentValue.center); } if (this.hasChanged(change, 'icon') || this.hasChanged(change, 'color')) { this.refreshMarkers(); } if (this.hasChanged(change, 'realtime') && change.currentValue.realtime) { this.startRealtime(); } if (change.currentValue.realtime === false) { this.stopRealtime(); } if (this.hasChanged(change, 'follow')) { this.moveToPositionOfMo(this.assets); } if (this.hasChanged(change, 'disablePan') || this.hasChanged(change, 'disableZoom')) { this.toggleControls(); } } hasChanged(change, prop) { return change.currentValue[prop] !== change.previousValue[prop]; } toggleControls() { if (this.config.disableZoom) { this.map.removeControl(this.map.zoomControl); this.map.scrollWheelZoom.disable(); } else { this.map.addControl(this.map.zoomControl); this.map.scrollWheelZoom.enable(); } if (this.config.disablePan) { this.map.dragging.disable(); } else { this.map.dragging.enable(); } } handleTouch(e) { // Disregard touch events on the minimap if present const ignoreList = [ 'leaflet-control-minimap', 'leaflet-interactive', 'leaflet-popup-content', 'leaflet-popup-content-wrapper', 'leaflet-popup-close-button', 'leaflet-control-zoom-in', 'leaflet-control-zoom-out' ]; let ignoreElement = false; for (let i = 0; i < ignoreList.length; i++) { if (this.leaflet.DomUtil.hasClass(e.target, ignoreList[i])) { ignoreElement = true; } } const container = this.map.getContainer(); if (ignoreElement) { if (this.leaflet.DomUtil.hasClass(e.target, 'leaflet-interactive') && e.type === 'touchmove' && e.touches.length === 1) { this.leaflet.DomUtil.addClass(container, 'touch-warning'); this.map.dragging.disable(); } else { this.leaflet.DomUtil.removeClass(container, 'touch-warning'); } return; } if (e.type !== 'touchmove' && e.type !== 'touchstart') { this.leaflet.DomUtil.removeClass(container, 'touch-warning'); return; } if (e.touches.length === 1) { this.leaflet.DomUtil.addClass(container, 'touch-warning'); this.map.dragging.disable(); } else { this.map.dragging.enable(); this.leaflet.DomUtil.removeClass(container, 'touch-warning'); } } zoomToBound(assets) { if (isEmpty(assets)) return; const bounds = assets.map(asset => [ asset.c8y_Position.lat, asset.c8y_Position.lng ]); if (assets.length > 1) { this.map.flyToBounds(bounds, { animate: false }); return; } this.map.flyTo([assets[0].c8y_Position.lat, assets[0].c8y_Position.lng], this.map.options.zoom, { animate: false }); } fitBounds(bounds) { if (bounds?.isValid()) { this.map.fitBounds(bounds, this.config.fitBoundsOptions); } } bindPopup(marker, context) { if (this.popup) { marker.on('click', () => { this.popup.viewContainer.clear(); const view = this.popup.viewContainer.createEmbeddedView(this.popup.template, { $implicit: context }); view.detectChanges(); marker .unbindPopup() .bindPopup(this.popup.elementRef.nativeElement.previousSibling, { offset: [-3, -40], maxWidth: 140, autoPan: true, closeButton: false }) .openPopup(); }); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapComponent, deps: [{ token: i1$1.ManagedObjectRealtimeService }, { token: MapService }, { token: MAP_TILE_LAYER }, { token: MAP_DEFAULT_CONFIG }, { token: i3.TranslateService }, { token: i1$1.GeoService }, { token: i1$1.DatePipe }, { token: i1$1.WidgetGlobalAutoRefreshService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.14", type: MapComponent, isStandalone: true, selector: "c8y-map", inputs: { config: "config", assets: "assets", polyline$: "polyline$", polylineOptions: "polylineOptions" }, outputs: { onRealtimeUpdate: "onRealtimeUpdate", onMove: "onMove", onMoveEnd: "onMoveEnd", onZoomStart: "onZoomStart", onZoomEnd: "onZoomEnd", onMap: "onMap", onInit: "onInit" }, providers: [ManagedObjectRealtimeService], queries: [{ propertyName: "popup", first: true, predicate: MapPopupDirective, descendants: true }], viewQueries: [{ propertyName: "mapElement", first: true, predicate: ["map"], descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"c8y-map\">\n <div #map></div>\n</div>\n<ng-content></ng-content>\n" }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.14", ngImport: i0, type: MapComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-map', providers: [ManagedObjectRealtimeService], template: "<div class=\"c8y-map\">\n <div #map></div>\n</div>\n<ng-content></ng-content>\n" }] }], ctorParameters: () => [{ type: i1$1.ManagedObjectRealtimeService }, { type: MapService }, { type: i4.Observable, decorators: [{ type: Inject, args: [MAP_TILE_LAYER] }] }, { type: i4.Observable, decorators: [{ type: Inject, args: [MAP_DEFAULT_CONFIG] }] }, { type: i3.TranslateService }, { type: i1$1.GeoService }, { type: i1$1.DatePipe }, { type: i1$1.WidgetGlobalAutoRefreshService }], propDecorators: { mapElement: [{ type: ViewChild, args: ['map'] }], popup: [{ type: ContentChild, args: [MapPopupDirective] }], config: [{ type: Input }], assets: [{ type: Input }], polyline$: [{ type: Input }], polylineOptions: [{ type: Input }], onRealtimeUpdate: [{ type: Output }], onMove: [{ type: Output }], onMoveEnd: [{ type: Output }], onZoomStart: [{ type: Output }], onZoomEnd: [{ type: Output }], onMap: [{ type: Output }], onInit: [{ type: Output }] } }); /** * Smart map component for that clusters devices together when there are too many to display individually. * Unlike the basic map, this component loads device data dynamically and manages cluster rendering and updates. * Extends the base MapComponent with clustering, dynamic data loading, and advanced refresh logic. */ class ClusterMapComponent extends MapComponent { /** * Constructs the ClusterMapComponent, injecting required services and initializing the base map. */ constructor(moRealtimeService, mapService, layers$, defaultConfig$, translateService, widgetGlobalAutoRefreshService, iterable, colorService, geo, datePipe) { super(moRealtimeService, mapService, layers$, defaultConfig$, translateService, geo, datePipe, widgetGlobalAutoRefreshService); this.moRealtimeService = moRealtimeService; this.mapService = mapService; this.layers$ = layers$; this.defaultConfig$ = defaultConfig$; this.translateService = translateService; this.widgetGlobalAutoRefreshService = widgetGlobalAutoRefreshService; this.iterable = iterable; this.colorService = colorService; /** Emits true while the map is loading or refreshing clusters. */ this.isLoading$ = new BehaviorSubject(false); /** * Whether to show a color overlay for each cluster rectangle. This can be useful for debugging or visualizing clusters. */ this.showClusterColor = false; /** * Emits Leaflet map change events (move, moveend) for external listeners. */ this.mapChange = new EventEmitter(); /** @ignore */ this.errorNotifier = new BehaviorSubject(null); this.reloadTrigger$ = new BehaviorSubject(false); this.clusters = []; this.EVENT_THROTTLE_TIME = 750; } /** * @ignore */ async ngOnChanges(changes) { if (changes.config?.firstChange) { return; } if (changes.rootNode?.previousValue !== changes.rootNode?.currentValue) { this.changeRootNode(changes.rootNode.currentValue); } if (changes.config?.currentValue) { this.changeConfig(changes.config); } } /** * Handles changes to the map configuration, including follow and refresh interval. * Cancels reload on follow, triggers reload on refresh interval change, and delegates to base config change logic. * @param change The config change object. */ changeConfig(change) { // on following, cancel reload to avoid stale state if (change.currentValue.follow === true) { this.cancelReload(); this.isLoading$.next(false); } if (change.currentValue.refreshInterval !== change.previousValue.refreshInterval) { this.reload(); } super.changeConfig(change); } /** * @ignore */ async ngAfterViewInit() { if (!this.leaflet) { this.leaflet = await this.mapService.getLeaflet(); } if (this.config.widgetInstanceGlobalAutoRefreshContext) { this.handleGlobalRefreshLoading(); } combineLatest([this.layers$, this.defaultConfig$]) .pipe(takeUntil(this.unsubscribeTrigger$)) .subscribe(([layers, defaultConfig]) => { this.initMap(layers, defaultConfig); this.changeRootNode(this.rootNode); this.changeConfig(new SimpleChange({}, this.config, false)); }); } /** * Resets the map and clusters, re-initializing the component. */ async reset() { this.ngOnDestroy(); await this.ngAfterViewInit(); } /** * Triggers a reload of cluster data. */ reload() { this.reloadTrigger$.next(true); } /** * Cancels an ongoing reload operation. */ cancelReload() { this.reloadTrigger$.next(false); } /** * Subscribes to cluster and interval changes, updating clusters as needed. * Handles auto-refresh and global refresh loading. */ listenToClusterAndIntervalChanges() { const countdownEnded$ = this.config.widgetInstanceGlobalAutoRefreshContext ? this.widgetGlobalAutoRefreshService.countdownActions.countdownEnded$.pipe(takeUntil(this.destroy$)) : this.countdownIntervalComp ? this.countdownIntervalComp.countdownEnded.pipe(takeUntil(this.unsubscribeTrigger$)) : EMPTY; const mapChange$ = this.getMapChangeObservable(); merge(this.reloadTrigger$, mapChange$, countdownEnded$) .pipe(tap(value => { if (this.config.widgetInstanceGlobalAutoRefreshContext) { !this.isLeafletEventInterface(value) && this.isLoading$.next(true); return; } this.isLoading$.next(true); this.countdownIntervalComp?.stop(true); }), switchMap(value => value === false ? of([]) : from(this.mapService.getClusterSize(this.map.getBounds())).pipe(mergeMap((clusterSize) => this.getClusterRects(clusterSize, this.map.getBounds())), mergeMap(rects => this.createOrUpdateCluster(rects)), catchError(error => { this.errorNotifier.next(error); return of([]); }))), takeUntil(this.unsubscribeTrigger$)) .subscribe((clusters) => { clusters.forEach(cluster => cluster.render(this.map)); this.isLoading$.next(false); if (this.config.widgetInstanceGlobalAutoRefreshContext) { return; } this.countdownIntervalComp?.start(); }); } /** * Subscribes to map change events for cluster updates. */ listenToClusterMapChanges() { this.getMapChangeObservable().subscribe(); } /** * Refreshes markers or clusters, depending on whether a single asset or clusters are shown. */ refreshMarkers() { if (this.assets) { super.refreshMarkers(); return; } this.clusters.forEach(cluster => { cluster.clear(this.map); }); this.reload(); } changeRootNode(mo) { this.unsubscribeAllListeners(); this.clearMarkers(); this.clearClusters(); const isPositionDevice = mo?.c8y_Position && mo?.c8y_IsDevice; if (isPositionDevice) { this.assets = mo; this.refreshMarkers(); this.listenToClusterMapChanges(); } else { this.assets = null; this.listenToClusterAndIntervalChanges(); this.reload(); } } async getClusterRects(levelThreshold = ClusterSize.FOUR, viewBounds, level = 0) { let rects = []; if (levelThreshold === ClusterSize.NONE) { const rect = await this.getRect(viewBounds); rects.push(rect); return rects; } if (level >= levelThreshold) { return rects; } level++; const { lat: x1, lng: y1 } = viewBounds.getSouthWest(); const { lat: x2, lng: y2 } = viewBounds.getNorthEast(); const newX2 = (x1 + x2) / 2; const newY2 = (y1 + y2) / 2; const bounds = [ [ [x1, y1], [newX2, newY2] ], [ [newX2, newY2], [x2, y2] ], [ [x1, newY2], [newX2, y2] ], [ [newX2, y1], [x2, newY2] ] ]; for (const bound of bounds) { const latLngBound = this.leaflet.latLngBounds(bound); const rect = await this.getRect(latLngBound); rects = [...rects, ...(await this.getClusterRects(levelThreshold, latLngBound, level))]; if (level === levelThreshold) { rects.push(rect); } } return rects; } async getRect(latLngBound) { let color = 'none'; if (this.showClusterColor) { color = await this.colorService.generateColor(latLngBound.toBBoxString()); } const rect = this.leaflet.rectangle(latLngBound, { color, weight: color === 'none' ? 0 : 1, interactive: false }); return rect; } clearClusters() { this.clusters.forEach(cluster => { cluster.clear(this.map); }); this.clusters = []; } async updateCluster(cluster) { const clusterCount = await this.mapService.getPositionMOsFromBoundCount(cluster.rect.getBounds(), this.rootNode); if (clusterCount > this.mapService.MAX_DEVICE_PER_CLUSTER) { cluster.setClusterToBigMarker(this.map, clusterCount, this.leaflet); cluster.positions = []; return cluster; } cluster.removeClusterToBigMarker(); cluster.positions = await this.mapService.getPositionMOsFromBound(cluster.rect.getBounds(), this.rootNode); return cluster; } createOrUpdateCluster(rects) { const isNew = rects.length !== this.clusters.length; if (isNew) { this.clearClusters(); } const updatePromise = rects.map((rect, index) => { if (isNew) { const cluster = new ClusterMap(this.iterable, asset => this.getAssetMarker(asset), this.translateService); this.clusters.push(cluster); } this.clusters[index].rect = rect; return this.updateCluster(this.clusters[index]); }); return Promise.all(updatePromise); } getMapChangeObservable() { return merge(fromEvent(this.map, 'move'), fromEvent(this.map, 'moveend')).pipe(debounceTime(this.EVENT_THROTTLE_TIME), tap(event => this.mapChange.emit(event)), takeUntil(this.unsubscribeTrigger$)); } isLeafletEventInterface(LeafletEventObject) {