@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
1,246 lines (1,239 loc) • 72.5 kB
JavaScript
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: '©<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) {