@c8y/tutorial 1024.15.7 → 1024.15.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/__mocks/index.ts +11 -11
- package/src/__mocks/mock.model.ts +1 -2
- package/src/__mocks/scoped-mocks/cluster-map.ts +207 -0
- package/src/__mocks/utils/generators/clusterDevices.ts +203 -0
- package/src/__mocks/utils/generators/managedObjects.ts +21 -4
- package/src/__mocks/utils/realtime.ts +27 -4
- package/src/maps/cluster-map-root-node/cluster-map-root-node-example.component.html +8 -5
- package/src/maps/cluster-map-root-node/cluster-map-root-node-example.component.ts +29 -1
- package/src/maps/simple-map/simple-map-example.component.ts +5 -1
- package/src/maps/simple-map-custom-config/map-layer.service.ts +6 -1
- package/src/maps/simple-map-custom-config/simple-map-custom-config.component.html +28 -5
- package/src/maps/simple-map-custom-config/simple-map-custom-config.component.ts +25 -4
- package/src/modal/ngx-modal-accessibility/ngx-modal-accessibility-example.component.ts +36 -3
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c8y/tutorial",
|
|
3
|
-
"version": "1024.15.
|
|
3
|
+
"version": "1024.15.16",
|
|
4
4
|
"description": "This package is used to scaffold a tutorial for Cumulocity IoT Web SDK which explains a lot of concepts.",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@angular/animations": "^21.2.0",
|
|
7
7
|
"@angular/cdk": "^21.2.0",
|
|
8
|
-
"@c8y/bootstrap": "1024.15.
|
|
9
|
-
"@c8y/client": "1024.15.
|
|
10
|
-
"@c8y/ngx-components": "1024.15.
|
|
11
|
-
"@c8y/style": "1024.15.
|
|
8
|
+
"@c8y/bootstrap": "1024.15.16",
|
|
9
|
+
"@c8y/client": "1024.15.16",
|
|
10
|
+
"@c8y/ngx-components": "1024.15.16",
|
|
11
|
+
"@c8y/style": "1024.15.16",
|
|
12
12
|
"gridstack": "^12.4.2",
|
|
13
13
|
"leaflet": "1.9.4",
|
|
14
14
|
"monaco-editor": "~0.53.0",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"zone.js": "~0.15.1"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"@c8y/devkit": "1024.15.
|
|
21
|
-
"@c8y/options": "1024.15.
|
|
20
|
+
"@c8y/devkit": "1024.15.16",
|
|
21
|
+
"@c8y/options": "1024.15.16"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
24
|
"@angular/common": ">=21 <22"
|
package/src/__mocks/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { InventoryInterceptor } from './global-mocks/inventory.interceptor';
|
|
|
12
12
|
import { MeasurementsInterceptor } from './global-mocks/measurements.interceptor';
|
|
13
13
|
import { API_MOCK_CONFIG, ApiMockConfig } from './mock.model';
|
|
14
14
|
import { RealtimeSubjectServiceWithMocking } from './mock.realtime';
|
|
15
|
+
import { ClusterMapInterceptor } from './scoped-mocks/cluster-map';
|
|
15
16
|
import { ContextDashboardInterceptor } from './scoped-mocks/context-dashboard';
|
|
16
17
|
import { DeviceDataGridInterceptor } from './scoped-mocks/device-data-grid';
|
|
17
18
|
import { ListsInterceptor } from './scoped-mocks/lists';
|
|
@@ -29,17 +30,16 @@ import { ProviderConfigurationsApiInterceptor } from './global-mocks/provider-co
|
|
|
29
30
|
|
|
30
31
|
export function provideAPIMock() {
|
|
31
32
|
return [
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// },
|
|
33
|
+
{
|
|
34
|
+
provide: API_MOCK_CONFIG,
|
|
35
|
+
useValue: {
|
|
36
|
+
// Scoped to both cluster map examples, `maps/cluster` and `maps/cluster-root-node`.
|
|
37
|
+
id: 'a-cluster-map-interceptor',
|
|
38
|
+
path: 'maps/cluster',
|
|
39
|
+
mockService: ClusterMapInterceptor
|
|
40
|
+
} as ApiMockConfig,
|
|
41
|
+
multi: true
|
|
42
|
+
},
|
|
43
43
|
{
|
|
44
44
|
provide: API_MOCK_CONFIG,
|
|
45
45
|
useValue: {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { IFetchResponse, IManagedObject } from '@c8y/client';
|
|
2
|
+
import { ApiCall, HttpHandler, HttpInterceptor } from '@c8y/ngx-components/api';
|
|
3
|
+
import { from, Observable } from 'rxjs';
|
|
4
|
+
import { generateResponse } from '../utils/common';
|
|
5
|
+
import {
|
|
6
|
+
getClusterMapSampleSites,
|
|
7
|
+
getClusterMapSiteGroups,
|
|
8
|
+
SampleSiteDevices
|
|
9
|
+
} from '../utils/generators/clusterDevices';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a cluster map query asks for. The cluster map loads its devices per bounding box, counts
|
|
13
|
+
* them before loading them and asks for the outermost device to fit the map to all devices.
|
|
14
|
+
*/
|
|
15
|
+
interface PositionQuery {
|
|
16
|
+
latMin?: number;
|
|
17
|
+
latMax?: number;
|
|
18
|
+
lngMin?: number;
|
|
19
|
+
lngMax?: number;
|
|
20
|
+
/**
|
|
21
|
+
* True if the bounding box crosses the antimeridian. The longitudes are combined with `or`
|
|
22
|
+
* instead of `and` in that case.
|
|
23
|
+
*/
|
|
24
|
+
lngWraps: boolean;
|
|
25
|
+
orderBy?: {
|
|
26
|
+
coordinate: 'lat' | 'lng';
|
|
27
|
+
ascending: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Id of the managed object the query is restricted to, if the map uses a root node.
|
|
31
|
+
*/
|
|
32
|
+
scopedToId?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Leaflet is lazy loaded. Answering too fast leads to markers that are not rendered.
|
|
37
|
+
*/
|
|
38
|
+
const LEAFLET_LAZY_LOAD_DELAY_MS = 300;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A number in a query, optionally suffixed by the `f` or `d` type marker the platform uses.
|
|
42
|
+
*/
|
|
43
|
+
const QUERY_NUMBER = '(-?\\d+(?:\\.\\d+)?)[fd]?';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Answers the inventory queries of the cluster map examples with a fixed set of sample devices,
|
|
47
|
+
* spread over places around the world. Queries the cluster map does not send, for example the ones
|
|
48
|
+
* of the asset selector, are passed on to the global inventory mock.
|
|
49
|
+
*/
|
|
50
|
+
export class ClusterMapInterceptor implements HttpInterceptor {
|
|
51
|
+
private readonly sites: SampleSiteDevices[] = getClusterMapSampleSites();
|
|
52
|
+
private readonly devices: IManagedObject[] = this.sites.flatMap(site => site.devices);
|
|
53
|
+
|
|
54
|
+
intercept(req: ApiCall, next: HttpHandler): Observable<IFetchResponse> {
|
|
55
|
+
const params = (req?.options?.params ?? {}) as Record<string, unknown>;
|
|
56
|
+
const query = [params.query, params.q].find(value => typeof value === 'string') as string;
|
|
57
|
+
const { method = 'GET' } = req?.options || {};
|
|
58
|
+
|
|
59
|
+
if (method !== 'GET' || !req?.url?.includes('inventory/managedObjects')) {
|
|
60
|
+
return next.handle(req);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (query?.includes('has(c8y_Position)')) {
|
|
64
|
+
return from(this.mockPositionQuery(query, params));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The asset selector of the root node example lists the places as selectable groups.
|
|
68
|
+
if (isRootGroupQuery(query, params)) {
|
|
69
|
+
return from(this.mockGroupQuery());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return next.handle(req);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async mockGroupQuery() {
|
|
76
|
+
await new Promise(resolve => setTimeout(resolve, LEAFLET_LAZY_LOAD_DELAY_MS));
|
|
77
|
+
const groups = getClusterMapSiteGroups();
|
|
78
|
+
|
|
79
|
+
return generateResponse(() => ({ managedObjects: groups }), {
|
|
80
|
+
totalPages: 1,
|
|
81
|
+
pageSize: groups.length,
|
|
82
|
+
currentPage: 1
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async mockPositionQuery(query: string, params: Record<string, unknown>) {
|
|
87
|
+
await new Promise(resolve => setTimeout(resolve, LEAFLET_LAZY_LOAD_DELAY_MS));
|
|
88
|
+
|
|
89
|
+
const matching = this.getMatchingDevices(parsePositionQuery(query));
|
|
90
|
+
const pageSize = Number(params?.pageSize) || matching.length || 1;
|
|
91
|
+
|
|
92
|
+
return generateResponse(() => ({ managedObjects: matching.slice(0, pageSize) }), {
|
|
93
|
+
// The cluster map counts the devices of a bounding box by requesting a single one and
|
|
94
|
+
// reading the total pages, so this has to reflect the number of matching devices.
|
|
95
|
+
totalPages: Math.ceil(matching.length / pageSize),
|
|
96
|
+
pageSize,
|
|
97
|
+
currentPage: 1
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private getMatchingDevices(query: PositionQuery): IManagedObject[] {
|
|
102
|
+
const searched = query.scopedToId ? this.getDevicesOfRootNode(query.scopedToId) : this.devices;
|
|
103
|
+
const matching = searched.filter(device => isInBounds(device, query));
|
|
104
|
+
|
|
105
|
+
if (!query.orderBy) {
|
|
106
|
+
return matching;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { coordinate, ascending } = query.orderBy;
|
|
110
|
+
return [...matching].sort(
|
|
111
|
+
(a, b) => (getCoordinate(a, coordinate) - getCoordinate(b, coordinate)) * (ascending ? 1 : -1)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns the devices a map with a root node shows. A root node that is one of the place groups
|
|
117
|
+
* resolves to the devices of that place, one that is a sample device to that single device, so
|
|
118
|
+
* that selecting a place in the asset selector shows exactly its devices.
|
|
119
|
+
* @param id Id of the root node.
|
|
120
|
+
* @returns The devices belonging to the root node.
|
|
121
|
+
*/
|
|
122
|
+
private getDevicesOfRootNode(id: string): IManagedObject[] {
|
|
123
|
+
const site = this.sites.find(({ id: siteId }) => siteId === id);
|
|
124
|
+
if (site) {
|
|
125
|
+
return site.devices;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const device = this.devices.find(({ id: deviceId }) => deviceId === id);
|
|
129
|
+
if (device) {
|
|
130
|
+
return [device];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const siteIndex = Math.abs(Number(id) || 0) % this.sites.length;
|
|
134
|
+
return this.sites[siteIndex].devices;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the request is the asset selector asking for its root groups. A user with inventory
|
|
140
|
+
* roles gets a `has(c8y_IsDeviceGroup)` query; the `noLogin` user of the examples lacks those
|
|
141
|
+
* roles, so the asset selector falls back to a `fragmentType=c8y_IsDeviceGroup` parameter instead.
|
|
142
|
+
*/
|
|
143
|
+
function isRootGroupQuery(query: string, params: Record<string, unknown>): boolean {
|
|
144
|
+
return query?.includes('c8y_IsDeviceGroup') || params.fragmentType === 'c8y_IsDeviceGroup';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parsePositionQuery(query: string): PositionQuery {
|
|
148
|
+
const lngConditions = new RegExp(
|
|
149
|
+
`\\(c8y_Position\\.lng gt ${QUERY_NUMBER}\\)\\s*(and|or)\\s*\\(c8y_Position\\.lng lt`
|
|
150
|
+
).exec(query);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
latMin: parseCondition(query, 'lat', 'gt'),
|
|
154
|
+
latMax: parseCondition(query, 'lat', 'lt'),
|
|
155
|
+
lngMin: parseCondition(query, 'lng', 'gt'),
|
|
156
|
+
lngMax: parseCondition(query, 'lng', 'lt'),
|
|
157
|
+
lngWraps: lngConditions?.[2] === 'or',
|
|
158
|
+
orderBy: parseOrderBy(query),
|
|
159
|
+
scopedToId: parseScopedToId(query)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseCondition(
|
|
164
|
+
query: string,
|
|
165
|
+
coordinate: 'lat' | 'lng',
|
|
166
|
+
operator: 'gt' | 'lt'
|
|
167
|
+
): number | undefined {
|
|
168
|
+
const match = new RegExp(`c8y_Position\\.${coordinate} ${operator} ${QUERY_NUMBER}`).exec(query);
|
|
169
|
+
return match ? Number(match[1]) : undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseOrderBy(query: string): PositionQuery['orderBy'] {
|
|
173
|
+
const match = /\$orderby=c8y_Position\.(lat|lng) (asc|desc)/.exec(query);
|
|
174
|
+
return match
|
|
175
|
+
? { coordinate: match[1] as 'lat' | 'lng', ascending: match[2] === 'asc' }
|
|
176
|
+
: undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseScopedToId(query: string): string | undefined {
|
|
180
|
+
// The platform writes these as a function call, `bygroupid(1)`, and the id of a managed object
|
|
181
|
+
// as a quoted value, `id eq '1'`.
|
|
182
|
+
const ofGroup = /(?:bygroupid|isinhierarchyof)(?:\(|\s+eq\s+)'?(\d+)'?/.exec(query);
|
|
183
|
+
const ofManagedObject = /\bid eq '?(\d+)'?/.exec(query);
|
|
184
|
+
|
|
185
|
+
return (ofGroup ?? ofManagedObject)?.[1];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isInBounds(device: IManagedObject, query: PositionQuery): boolean {
|
|
189
|
+
const lat = getCoordinate(device, 'lat');
|
|
190
|
+
const lng = getCoordinate(device, 'lng');
|
|
191
|
+
|
|
192
|
+
if (query.latMin !== undefined && lat <= query.latMin) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
if (query.latMax !== undefined && lat >= query.latMax) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const afterMin = query.lngMin === undefined || lng > query.lngMin;
|
|
200
|
+
const beforeMax = query.lngMax === undefined || lng < query.lngMax;
|
|
201
|
+
|
|
202
|
+
return query.lngWraps ? afterMin || beforeMax : afterMin && beforeMax;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getCoordinate(device: IManagedObject, coordinate: 'lat' | 'lng'): number {
|
|
206
|
+
return Number(device.c8y_Position?.[coordinate]);
|
|
207
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { IManagedObject } from '@c8y/client';
|
|
2
|
+
import { getMOCommonProps } from './managedObjects';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A place around which sample devices are positioned.
|
|
6
|
+
*/
|
|
7
|
+
interface SampleSite {
|
|
8
|
+
/**
|
|
9
|
+
* Name of the place, used to name its devices.
|
|
10
|
+
*/
|
|
11
|
+
name: string;
|
|
12
|
+
lat: number;
|
|
13
|
+
lng: number;
|
|
14
|
+
/**
|
|
15
|
+
* How many devices are positioned around the place.
|
|
16
|
+
*/
|
|
17
|
+
deviceCount: number;
|
|
18
|
+
/**
|
|
19
|
+
* Radius in degrees over which the devices are spread.
|
|
20
|
+
*/
|
|
21
|
+
spread: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The devices of one place, plus the group that holds them.
|
|
26
|
+
*/
|
|
27
|
+
export interface SampleSiteDevices {
|
|
28
|
+
/**
|
|
29
|
+
* Id of the group managed object that represents the place in the asset selector.
|
|
30
|
+
*/
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/**
|
|
34
|
+
* Center of the place, used to center the map on the group when it is selected.
|
|
35
|
+
*/
|
|
36
|
+
lat: number;
|
|
37
|
+
lng: number;
|
|
38
|
+
devices: IManagedObject[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The places the sample devices are positioned around. The amounts are chosen so that the cluster
|
|
43
|
+
* map has something to cluster: zoomed out, the places are merged into a few markers with a count,
|
|
44
|
+
* while zooming into a place reveals its individual devices.
|
|
45
|
+
*/
|
|
46
|
+
const SAMPLE_SITES: SampleSite[] = [
|
|
47
|
+
{ name: 'Düsseldorf', lat: 51.2254, lng: 6.7763, deviceCount: 120, spread: 0.08 },
|
|
48
|
+
{ name: 'Berlin', lat: 52.52, lng: 13.405, deviceCount: 90, spread: 0.08 },
|
|
49
|
+
{ name: 'Munich', lat: 48.1372, lng: 11.5755, deviceCount: 40, spread: 0.06 },
|
|
50
|
+
{ name: 'London', lat: 51.5074, lng: -0.1278, deviceCount: 70, spread: 0.08 },
|
|
51
|
+
{ name: 'Paris', lat: 48.8566, lng: 2.3522, deviceCount: 55, spread: 0.06 },
|
|
52
|
+
{ name: 'Madrid', lat: 40.4168, lng: -3.7038, deviceCount: 25, spread: 0.05 },
|
|
53
|
+
{ name: 'Rome', lat: 41.9028, lng: 12.4964, deviceCount: 20, spread: 0.05 },
|
|
54
|
+
{ name: 'Stockholm', lat: 59.3293, lng: 18.0686, deviceCount: 12, spread: 0.04 },
|
|
55
|
+
{ name: 'New York', lat: 40.7128, lng: -74.006, deviceCount: 65, spread: 0.08 },
|
|
56
|
+
{ name: 'San Francisco', lat: 37.7749, lng: -122.4194, deviceCount: 35, spread: 0.06 },
|
|
57
|
+
{ name: 'Chicago', lat: 41.8781, lng: -87.6298, deviceCount: 18, spread: 0.05 },
|
|
58
|
+
{ name: 'São Paulo', lat: -23.5505, lng: -46.6333, deviceCount: 14, spread: 0.05 },
|
|
59
|
+
{ name: 'Tokyo', lat: 35.6762, lng: 139.6503, deviceCount: 22, spread: 0.06 },
|
|
60
|
+
{ name: 'Singapore', lat: 1.3521, lng: 103.8198, deviceCount: 10, spread: 0.04 },
|
|
61
|
+
{ name: 'Sydney', lat: -33.8688, lng: 151.2093, deviceCount: 8, spread: 0.04 }
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Angle of Vogel's spiral, used to spread the devices of a place evenly. Compared to random
|
|
66
|
+
* positions this keeps the sample data the same on every request, so panning and zooming the map
|
|
67
|
+
* does not make devices jump around.
|
|
68
|
+
*/
|
|
69
|
+
const GOLDEN_ANGLE = 2.399963229728653;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The id the generated devices start at. High enough to not collide with the randomly generated
|
|
73
|
+
* ids of the other mocks.
|
|
74
|
+
*/
|
|
75
|
+
const FIRST_DEVICE_ID = 700000;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The id the site groups start at. Kept above the device ids so the two never collide.
|
|
79
|
+
*/
|
|
80
|
+
const FIRST_GROUP_ID = 800000;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Severities the devices with an active alarm cycle through, so that the map shows the status
|
|
84
|
+
* colors of single devices and of a cluster of devices.
|
|
85
|
+
*/
|
|
86
|
+
const ALARM_SEVERITIES = ['critical', 'major', 'minor', 'warning'] as const;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every n-th device gets an active alarm.
|
|
90
|
+
*/
|
|
91
|
+
const DEVICE_WITH_ALARM_INTERVAL = 7;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How far a device moves per realtime update.
|
|
95
|
+
*/
|
|
96
|
+
const MOVE_STEP_DEGREES = 0.0015;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The sample devices, created once so that every mock works with the same devices and a device
|
|
100
|
+
* that moved stays where it moved to.
|
|
101
|
+
*/
|
|
102
|
+
let sampleSites: SampleSiteDevices[];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* How often each device moved already, used to move it along a circle.
|
|
106
|
+
*/
|
|
107
|
+
const moveCount = new Map<string, number>();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the sample devices of every place, positioned around it.
|
|
111
|
+
* @returns The devices per place.
|
|
112
|
+
*/
|
|
113
|
+
export function getClusterMapSampleSites(): SampleSiteDevices[] {
|
|
114
|
+
if (!sampleSites) {
|
|
115
|
+
let id = FIRST_DEVICE_ID;
|
|
116
|
+
sampleSites = SAMPLE_SITES.map((site, siteIndex) => ({
|
|
117
|
+
id: `${FIRST_GROUP_ID + siteIndex}`,
|
|
118
|
+
name: site.name,
|
|
119
|
+
lat: site.lat,
|
|
120
|
+
lng: site.lng,
|
|
121
|
+
devices: [...Array(site.deviceCount)].map((_, index) => createDevice(site, index, id++))
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return sampleSites;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Returns one group managed object per place, so that the asset selector of the cluster map with
|
|
130
|
+
* a root node lists the places as selectable groups.
|
|
131
|
+
* @returns The group of every place.
|
|
132
|
+
*/
|
|
133
|
+
export function getClusterMapSiteGroups(): IManagedObject[] {
|
|
134
|
+
return getClusterMapSampleSites().map(
|
|
135
|
+
site =>
|
|
136
|
+
({
|
|
137
|
+
id: site.id,
|
|
138
|
+
name: site.name,
|
|
139
|
+
...getMOCommonProps(),
|
|
140
|
+
type: 'c8y_DeviceGroup',
|
|
141
|
+
c8y_IsDeviceGroup: {},
|
|
142
|
+
// Lets the example center the map on the place when its group is selected.
|
|
143
|
+
c8y_Position: { lat: site.lat, lng: site.lng, alt: 0, accuracy: 0 }
|
|
144
|
+
}) as IManagedObject
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Moves the sample device with the given id a bit further along a circle around its place, so that
|
|
150
|
+
* a map following the device shows it moving instead of jumping to an unrelated position.
|
|
151
|
+
* @param id Id of the device to move.
|
|
152
|
+
* @returns The moved device, or `undefined` if the id is not one of the sample devices.
|
|
153
|
+
*/
|
|
154
|
+
export function moveClusterMapSampleDevice(id: string): IManagedObject | undefined {
|
|
155
|
+
const device = getClusterMapSampleSites()
|
|
156
|
+
.flatMap(site => site.devices)
|
|
157
|
+
.find(sampleDevice => sampleDevice.id === id);
|
|
158
|
+
|
|
159
|
+
if (!device) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const step = (moveCount.get(id) ?? 0) + 1;
|
|
164
|
+
moveCount.set(id, step);
|
|
165
|
+
const angle = step * GOLDEN_ANGLE;
|
|
166
|
+
device.c8y_Position = {
|
|
167
|
+
...device.c8y_Position,
|
|
168
|
+
lat: round(device.c8y_Position.lat + MOVE_STEP_DEGREES * Math.cos(angle)),
|
|
169
|
+
lng: round(device.c8y_Position.lng + MOVE_STEP_DEGREES * Math.sin(angle))
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return device;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createDevice(site: SampleSite, index: number, id: number): IManagedObject {
|
|
176
|
+
const radius = site.spread * Math.sqrt((index + 1) / site.deviceCount);
|
|
177
|
+
const angle = index * GOLDEN_ANGLE;
|
|
178
|
+
// Compensating the latitude keeps the devices spread in a circle instead of an ellipse.
|
|
179
|
+
const lngScale = 1 / Math.cos((site.lat * Math.PI) / 180);
|
|
180
|
+
const severity =
|
|
181
|
+
id % DEVICE_WITH_ALARM_INTERVAL === 0
|
|
182
|
+
? ALARM_SEVERITIES[id % ALARM_SEVERITIES.length]
|
|
183
|
+
: undefined;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id: `${id}`,
|
|
187
|
+
name: `${site.name} sensor ${index + 1}`,
|
|
188
|
+
...getMOCommonProps(),
|
|
189
|
+
type: 'c8y_SensorPhone',
|
|
190
|
+
c8y_IsDevice: {},
|
|
191
|
+
c8y_Position: {
|
|
192
|
+
lat: round(site.lat + radius * Math.cos(angle)),
|
|
193
|
+
lng: round(site.lng + radius * Math.sin(angle) * lngScale),
|
|
194
|
+
alt: 0,
|
|
195
|
+
accuracy: 10
|
|
196
|
+
},
|
|
197
|
+
...(severity ? { c8y_ActiveAlarmsStatus: { [severity]: 1 } } : {})
|
|
198
|
+
} as IManagedObject;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function round(coordinate: number): number {
|
|
202
|
+
return Math.round(coordinate * 1e6) / 1e6;
|
|
203
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IManagedObject, IManagedObjectReferences } from '@c8y/client';
|
|
2
2
|
import { generateId } from '../common';
|
|
3
|
+
import { moveClusterMapSampleDevice } from './clusterDevices';
|
|
3
4
|
|
|
4
5
|
const cords = [
|
|
5
6
|
{
|
|
@@ -44,11 +45,27 @@ const cords = [
|
|
|
44
45
|
}
|
|
45
46
|
];
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Returns the managed object a realtime update is sent for.
|
|
50
|
+
* @param id Id of the device the update is for. Realtime updates of the sample devices of the
|
|
51
|
+
* cluster map examples move that device, any other id gets a generated device.
|
|
52
|
+
* @returns The managed object of the update.
|
|
53
|
+
*/
|
|
54
|
+
export function generateRealtimeDeviceMO(id?: string): IManagedObject {
|
|
55
|
+
const movedSampleDevice = id ? moveClusterMapSampleDevice(id) : undefined;
|
|
56
|
+
if (movedSampleDevice) {
|
|
57
|
+
return movedSampleDevice;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const device = generateDevice<{ id: string; customFragment: string }>({
|
|
61
|
+
id: id ?? '1',
|
|
50
62
|
customFragment: 'customFragment'
|
|
51
63
|
});
|
|
64
|
+
// Give the device an active alarm so the map does not render it as muted. A muted pin ignores
|
|
65
|
+
// the color set through the map config, which the simple map "Change color" example relies on;
|
|
66
|
+
// keeping it here means the device stays colorable across realtime updates too.
|
|
67
|
+
device.c8y_ActiveAlarmsStatus = { critical: 0, major: 0, minor: 0, warning: 1 };
|
|
68
|
+
return device;
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
export function generateDevice<T>(customAttributes?: T): IManagedObject {
|
|
@@ -135,7 +152,7 @@ export function generateRandomMo() {
|
|
|
135
152
|
return generators[randomPosition]();
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
function getMOCommonProps() {
|
|
155
|
+
export function getMOCommonProps() {
|
|
139
156
|
return {
|
|
140
157
|
creationTime: new Date().toISOString(),
|
|
141
158
|
lastUpdated: new Date().toISOString(),
|
|
@@ -9,9 +9,8 @@ import { getFakeOperation, getFakeOperationBulk } from './generators/operations'
|
|
|
9
9
|
/**
|
|
10
10
|
* Map of generators for various channels. Each generator function generates a mock data of specific type.
|
|
11
11
|
*/
|
|
12
|
-
const generators = {
|
|
12
|
+
const generators: Record<string, (id?: string) => IAnyData> = {
|
|
13
13
|
[Channels.ManagedObjects]: generateRealtimeDeviceMO,
|
|
14
|
-
[Channels.MangedObjectsMap]: generateRealtimeDeviceMO,
|
|
15
14
|
[Channels.Operations]: getFakeOperation,
|
|
16
15
|
[Channels.BulkOperations]: getFakeOperationBulk,
|
|
17
16
|
[Channels.Measurements]: getFakeMeasurement,
|
|
@@ -27,7 +26,7 @@ const generators = {
|
|
|
27
26
|
* @throws - Throws an error if there's no generator available for the given channel.
|
|
28
27
|
*/
|
|
29
28
|
export function getFakeData<T>(channel: string): RealtimeMessage<T> {
|
|
30
|
-
const generator =
|
|
29
|
+
const { generator, subscribedId } = resolveGenerator(channel);
|
|
31
30
|
if (!generator) {
|
|
32
31
|
throw new Error(
|
|
33
32
|
`No fake data generator for channel ${channel}, either stay with the supported mocked channels (${Object.keys(
|
|
@@ -35,7 +34,11 @@ export function getFakeData<T>(channel: string): RealtimeMessage<T> {
|
|
|
35
34
|
)}), provide a generator for your channel or remove the "noLogin" query parameter and login.`
|
|
36
35
|
);
|
|
37
36
|
}
|
|
38
|
-
const data: IAnyData = generator();
|
|
37
|
+
const data: IAnyData = generator(subscribedId);
|
|
38
|
+
if (subscribedId) {
|
|
39
|
+
// A channel of a single entity only delivers data of that entity.
|
|
40
|
+
data.id = subscribedId;
|
|
41
|
+
}
|
|
39
42
|
const message: RealtimeMessage<T> = {
|
|
40
43
|
id: (data?.id).toString(),
|
|
41
44
|
channel: data?.id ? channel.replace('*', (data?.id).toString()) : channel,
|
|
@@ -45,6 +48,26 @@ export function getFakeData<T>(channel: string): RealtimeMessage<T> {
|
|
|
45
48
|
return message;
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Resolves the generator of a channel. A channel of a single entity, for example
|
|
53
|
+
* `/managedobjects/1`, is served by the generator of its wildcard channel, `/managedobjects/*`.
|
|
54
|
+
*
|
|
55
|
+
* @param channel - The channel to resolve the generator for.
|
|
56
|
+
* @returns The generator and, for a channel of a single entity, the id of that entity.
|
|
57
|
+
*/
|
|
58
|
+
function resolveGenerator(channel: string): {
|
|
59
|
+
generator?: (id?: string) => IAnyData;
|
|
60
|
+
subscribedId?: string;
|
|
61
|
+
} {
|
|
62
|
+
if (generators[channel]) {
|
|
63
|
+
return { generator: generators[channel] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [, wildcardChannel, subscribedId] = /^(.*\/)([^/*]+)$/.exec(channel) ?? [];
|
|
67
|
+
|
|
68
|
+
return { generator: generators[`${wildcardChannel}*`], subscribedId };
|
|
69
|
+
}
|
|
70
|
+
|
|
48
71
|
/**
|
|
49
72
|
* Function to generate a random RealtimeAction.
|
|
50
73
|
* It returns 'UPDATE' in 80% of the cases, otherwise, it returns either 'CREATE' or 'DELETE' randomly.
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
<c8y-title>Cluster map with root node</c8y-title>
|
|
2
2
|
|
|
3
3
|
<div class="d-flex">
|
|
4
|
-
<div
|
|
4
|
+
<div
|
|
5
|
+
class="d-flex d-col"
|
|
6
|
+
style="width: 280px"
|
|
7
|
+
>
|
|
5
8
|
<c8y-asset-selector
|
|
9
|
+
class="bg-level-1 min-height-0"
|
|
10
|
+
style="height: 420px"
|
|
6
11
|
[(ngModel)]="rootNode"
|
|
7
12
|
[config]="{
|
|
8
13
|
groupsSelectable: true,
|
|
9
14
|
groupsOnly: false
|
|
10
15
|
}"
|
|
11
|
-
(
|
|
12
|
-
class="bg-level-1 min-height-0"
|
|
13
|
-
style="height: 420px;"
|
|
16
|
+
(onSelected)="onGroupSelected($event)"
|
|
14
17
|
></c8y-asset-selector>
|
|
15
18
|
</div>
|
|
16
19
|
<!-- important -->
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
<!-- /important -->
|
|
41
44
|
<div
|
|
42
45
|
class="a-s-stretch p-16 d-col bg-info-light"
|
|
43
|
-
style="width: 320px; height: 420px
|
|
46
|
+
style="width: 320px; height: 420px"
|
|
44
47
|
>
|
|
45
48
|
<label>RootNode</label>
|
|
46
49
|
<pre style="height: 120px">{{ rootNode | json }} </pre>
|
|
@@ -2,7 +2,16 @@ import { Component } from '@angular/core';
|
|
|
2
2
|
import { ClusterMapConfig, defaultMapConfig, MapModule } from '@c8y/ngx-components/map';
|
|
3
3
|
import { CommonModule } from '@angular/common';
|
|
4
4
|
import { CoreModule } from '@c8y/ngx-components';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
AssetSelectionChangeEvent,
|
|
7
|
+
AssetSelectorModule
|
|
8
|
+
} from '@c8y/ngx-components/assets-navigator';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Zoom level the map uses when it centers on a selected group, chosen so that the devices of one
|
|
12
|
+
* place fill the map.
|
|
13
|
+
*/
|
|
14
|
+
const GROUP_ZOOM_LEVEL = 11;
|
|
6
15
|
|
|
7
16
|
@Component({
|
|
8
17
|
selector: 'tut-cluster-map-root-node-example',
|
|
@@ -17,6 +26,25 @@ export class ClusterMapRootNodeExampleComponent {
|
|
|
17
26
|
rootNode: unknown;
|
|
18
27
|
oldRootNode: unknown;
|
|
19
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Centers the map on the selected group. The asset selector passes the full managed object as
|
|
31
|
+
* `change.item` (its model value is simplified to id and name), so the group position is read
|
|
32
|
+
* from there.
|
|
33
|
+
* @param event The selection change emitted by the asset selector.
|
|
34
|
+
*/
|
|
35
|
+
onGroupSelected(event: AssetSelectionChangeEvent) {
|
|
36
|
+
this.resetConfig();
|
|
37
|
+
|
|
38
|
+
const position = event?.change?.isSelected ? event.change.item?.c8y_Position : undefined;
|
|
39
|
+
if (position) {
|
|
40
|
+
this.followConfig = {
|
|
41
|
+
...this.followConfig,
|
|
42
|
+
center: [position.lat, position.lng],
|
|
43
|
+
zoomLevel: GROUP_ZOOM_LEVEL
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
toggleAutorefresh() {
|
|
21
49
|
this.config = {
|
|
22
50
|
...this.config,
|
|
@@ -44,7 +44,11 @@ export class SimpleMapExampleComponent implements OnInit {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
setCenter() {
|
|
47
|
-
this.config = {
|
|
47
|
+
this.config = {
|
|
48
|
+
...this.config,
|
|
49
|
+
center: [defaultMapConfig.center[0], defaultMapConfig.center[1]],
|
|
50
|
+
zoomLevel: 12
|
|
51
|
+
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
changeIcon() {
|
|
@@ -3,6 +3,11 @@ import { defaultLayer } from '@c8y/ngx-components/map';
|
|
|
3
3
|
import { MapTileLayer } from '@c8y/options';
|
|
4
4
|
import { BehaviorSubject, Observable } from 'rxjs';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The tile layers this example can switch to, next to the default layer.
|
|
8
|
+
*/
|
|
9
|
+
export type CustomMapTileLayerName = 'Carto' | 'OpenTopo';
|
|
10
|
+
|
|
6
11
|
@Injectable({
|
|
7
12
|
providedIn: 'root'
|
|
8
13
|
})
|
|
@@ -17,7 +22,7 @@ export class MapLayerService implements CumulocityServiceRegistry.MapTileLayerPr
|
|
|
17
22
|
return true;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
changeLayer(name:
|
|
25
|
+
changeLayer(name: CustomMapTileLayerName) {
|
|
21
26
|
if (name === 'Carto') {
|
|
22
27
|
this.maps$.next([
|
|
23
28
|
{
|
|
@@ -1,27 +1,50 @@
|
|
|
1
1
|
<c8y-title>Simple map with custom config</c8y-title>
|
|
2
2
|
|
|
3
3
|
<div class="d-flex">
|
|
4
|
-
<div
|
|
4
|
+
<div
|
|
5
|
+
class="d-flex d-col j-c-center p-r-16"
|
|
6
|
+
style="width: 280px; height: 420px"
|
|
7
|
+
>
|
|
5
8
|
<button
|
|
6
9
|
class="btn btn-default btn-block"
|
|
7
|
-
|
|
10
|
+
[class.active]="selectedLayer() === 'Carto'"
|
|
11
|
+
[attr.aria-pressed]="selectedLayer() === 'Carto'"
|
|
12
|
+
(click)="showLayer('Carto')"
|
|
8
13
|
>
|
|
9
14
|
Change to Carto layer
|
|
10
15
|
</button>
|
|
11
16
|
<button
|
|
12
17
|
class="btn btn-default btn-block"
|
|
13
|
-
|
|
18
|
+
[class.active]="selectedLayer() === 'OpenTopo'"
|
|
19
|
+
[attr.aria-pressed]="selectedLayer() === 'OpenTopo'"
|
|
20
|
+
(click)="showLayer('OpenTopo')"
|
|
14
21
|
>
|
|
15
22
|
Change to OpenTopo layer
|
|
16
23
|
</button>
|
|
17
24
|
<button
|
|
18
25
|
class="btn btn-default btn-block"
|
|
19
|
-
|
|
26
|
+
[class.active]="selectedLayer() === 'default'"
|
|
27
|
+
[attr.aria-pressed]="selectedLayer() === 'default'"
|
|
28
|
+
(click)="showLayer('default')"
|
|
20
29
|
>
|
|
21
30
|
Change to default
|
|
22
31
|
</button>
|
|
23
32
|
</div>
|
|
24
33
|
<div class="flex-grow p-relative a-s-stretch">
|
|
25
|
-
|
|
34
|
+
<!--
|
|
35
|
+
The map picks up the layers of `MapLayerService` while it initializes. Rendering one map per
|
|
36
|
+
layer re-creates it whenever another layer is selected, so the new layer is applied.
|
|
37
|
+
-->
|
|
38
|
+
@switch (selectedLayer()) {
|
|
39
|
+
@case ('Carto') {
|
|
40
|
+
<c8y-map></c8y-map>
|
|
41
|
+
}
|
|
42
|
+
@case ('OpenTopo') {
|
|
43
|
+
<c8y-map></c8y-map>
|
|
44
|
+
}
|
|
45
|
+
@default {
|
|
46
|
+
<c8y-map></c8y-map>
|
|
47
|
+
}
|
|
48
|
+
}
|
|
26
49
|
</div>
|
|
27
50
|
</div>
|
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import { CommonModule } from '@angular/common';
|
|
2
|
-
import { Component, inject } from '@angular/core';
|
|
2
|
+
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
|
3
3
|
import { CoreModule } from '@c8y/ngx-components';
|
|
4
4
|
import { AssetSelectorModule } from '@c8y/ngx-components/assets-navigator';
|
|
5
5
|
import { MapModule } from '@c8y/ngx-components/map';
|
|
6
|
-
import { MapLayerService } from './map-layer.service';
|
|
6
|
+
import { CustomMapTileLayerName, MapLayerService } from './map-layer.service';
|
|
7
7
|
|
|
8
8
|
@Component({
|
|
9
9
|
selector: 'c8y-simple-map-custom-config',
|
|
10
10
|
templateUrl: './simple-map-custom-config.component.html',
|
|
11
11
|
standalone: true,
|
|
12
|
-
imports: [CommonModule, MapModule, CoreModule, AssetSelectorModule]
|
|
12
|
+
imports: [CommonModule, MapModule, CoreModule, AssetSelectorModule],
|
|
13
|
+
changeDetection: ChangeDetectionStrategy.OnPush
|
|
13
14
|
})
|
|
14
15
|
export class SimpleMapCustomConfigComponent {
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The layer the map is currently showing. `c8y-map` reads the layers provided via
|
|
18
|
+
* `mapTileLayerHook` while it initializes, so the template uses this value to re-create the
|
|
19
|
+
* map whenever another layer is selected.
|
|
20
|
+
*/
|
|
21
|
+
protected readonly selectedLayer = signal<CustomMapTileLayerName | 'default'>('default');
|
|
22
|
+
|
|
23
|
+
private readonly mapLayerService = inject(MapLayerService);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Provides the given layer through `MapLayerService` and shows the map with it.
|
|
27
|
+
* @param layer The layer to show, or `default` for the layer the map ships with.
|
|
28
|
+
*/
|
|
29
|
+
protected showLayer(layer: CustomMapTileLayerName | 'default') {
|
|
30
|
+
if (layer === 'default') {
|
|
31
|
+
this.mapLayerService.revert();
|
|
32
|
+
} else {
|
|
33
|
+
this.mapLayerService.changeLayer(layer);
|
|
34
|
+
}
|
|
35
|
+
this.selectedLayer.set(layer);
|
|
36
|
+
}
|
|
16
37
|
}
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
CoreModule,
|
|
6
6
|
FormsModule,
|
|
7
7
|
HeaderModule,
|
|
8
|
-
ModalModule
|
|
8
|
+
ModalModule,
|
|
9
|
+
ModalService,
|
|
10
|
+
Status
|
|
9
11
|
} from '@c8y/ngx-components';
|
|
10
12
|
import { SimpleModalAccessibilityExampleComponent } from './simple-modal-accessibility-example.component';
|
|
11
13
|
|
|
@@ -21,6 +23,12 @@ import { SimpleModalAccessibilityExampleComponent } from './simple-modal-accessi
|
|
|
21
23
|
>
|
|
22
24
|
Create component modal with with ModalComponent and custom ids
|
|
23
25
|
</button>
|
|
26
|
+
<button class="btn btn-default m-8" type="button" (click)="closeDashboardDetails()">
|
|
27
|
+
Confirm modal with default ids and initial focus
|
|
28
|
+
</button>
|
|
29
|
+
@if (lastConfirmChoice) {
|
|
30
|
+
<p class="text-muted" role="status">You chose: {{ lastConfirmChoice }}</p>
|
|
31
|
+
}
|
|
24
32
|
</div>
|
|
25
33
|
</div>`,
|
|
26
34
|
standalone: true,
|
|
@@ -34,7 +42,13 @@ import { SimpleModalAccessibilityExampleComponent } from './simple-modal-accessi
|
|
|
34
42
|
]
|
|
35
43
|
})
|
|
36
44
|
export class NgxModalAccessibilityExampleComponent {
|
|
37
|
-
|
|
45
|
+
/** The label of the button the user picked in the confirm dialog, shown instead of logging it. */
|
|
46
|
+
lastConfirmChoice = '';
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private modalService: BsModalService,
|
|
50
|
+
private modal: ModalService
|
|
51
|
+
) {}
|
|
38
52
|
|
|
39
53
|
openComponentModalWithContentSelectors() {
|
|
40
54
|
this.modalService.show(SimpleModalAccessibilityExampleComponent, {
|
|
@@ -42,6 +56,25 @@ export class NgxModalAccessibilityExampleComponent {
|
|
|
42
56
|
ariaDescribedby: 'modal-body-custom',
|
|
43
57
|
ariaLabelledBy: 'modal-title-custom',
|
|
44
58
|
ignoreBackdropClick: true
|
|
45
|
-
})
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `ModalService.confirm` already points `aria-labelledby` and `aria-describedby` at the
|
|
64
|
+
* default `modal-title` and `modal-body` ids, so the title and the warning are announced
|
|
65
|
+
* when the dialog opens, and focus starts on "Cancel".
|
|
66
|
+
*/
|
|
67
|
+
async closeDashboardDetails() {
|
|
68
|
+
try {
|
|
69
|
+
await this.modal.confirm(
|
|
70
|
+
'Close dashboard details',
|
|
71
|
+
'Are you sure you want to close dashboard details? All unsaved changes will be lost.',
|
|
72
|
+
Status.WARNING,
|
|
73
|
+
{ ok: 'Close', cancel: 'Cancel' }
|
|
74
|
+
);
|
|
75
|
+
this.lastConfirmChoice = 'Close';
|
|
76
|
+
} catch {
|
|
77
|
+
this.lastConfirmChoice = 'Cancel';
|
|
78
|
+
}
|
|
46
79
|
}
|
|
47
80
|
}
|