@record-evolution/widget-mapbox 1.4.11

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.
@@ -0,0 +1,550 @@
1
+ import { html, css, LitElement } from 'lit';
2
+ import { property, state } from 'lit/decorators.js';
3
+ import { repeat } from 'lit/directives/repeat.js'
4
+ // @ts-ignore
5
+ // import mapboxgl from 'https://cdn.skypack.dev/-/mapbox-gl@v2.15.0-iKfohePv9lgutCMNih0d/dist=es2020,mode=imports,min/optimized/mapbox-gl.js'
6
+ import mapboxgl from 'https://esm.run/mapbox-gl@3.0.1';
7
+ import * as GeoJSON from 'geojson';
8
+ import tinycolor from "tinycolor2";
9
+ import { InputData, Dataseries, Point } from './types.js'
10
+
11
+ export class WidgetMapbox extends LitElement {
12
+
13
+ @property({type: Object})
14
+ inputData?: InputData = undefined
15
+
16
+ @state()
17
+ private map: any | undefined = undefined;
18
+
19
+ @state()
20
+ private dataSets: Dataseries[] = []
21
+
22
+ @state()
23
+ dataSources: any = new Map()
24
+
25
+ @state()
26
+ colors: any = new Map()
27
+
28
+ version: string = 'versionplaceholder'
29
+
30
+ resizeObserver: ResizeObserver
31
+ mapStyle?: string
32
+ constructor() {
33
+ super()
34
+ this.resizeObserver = new ResizeObserver(() => {
35
+ this.map?.resize()
36
+ this.fitBounds()
37
+ })
38
+ mapboxgl.accessToken = 'pk.eyJ1IjoibWFya29wZSIsImEiOiJjazc1OWlsNjkwN2pyM2VxajV1eGRnYzgwIn0.3lVksk1nej_0KnWjCkBDAA'
39
+ }
40
+
41
+ update(changedProperties: Map<string, any>) {
42
+ changedProperties.forEach((propName: string) => {
43
+ if (propName === 'inputData') {
44
+ this.transformInputData()
45
+ }
46
+ })
47
+ super.update(changedProperties)
48
+ }
49
+
50
+ firstUpdated() {
51
+
52
+ this.transformInputData()
53
+ this.createMap()
54
+ this.resizeObserver.observe(this.map._container)
55
+ this.fitBounds()
56
+
57
+ }
58
+
59
+ fitBounds() {
60
+ const bounds = new mapboxgl.LngLatBounds()
61
+ this.dataSources.forEach((col: GeoJSON.FeatureCollection) => {
62
+ col.features.forEach(f => {
63
+ // @ts-ignore
64
+ if (f.geometry.coordinates?.length) bounds.extend(f.geometry.coordinates)
65
+ })
66
+ })
67
+ if (!bounds.isEmpty()) {
68
+ this.map.fitBounds(bounds, { maxZoom: 14, padding: 16, duration: 100, })
69
+ }
70
+ }
71
+
72
+ transformInputData() {
73
+ if(!this?.inputData?.settings || !this?.inputData?.dataseries?.length) return
74
+
75
+ if (this.map && this.inputData.settings.style !== this.mapStyle) {
76
+ this.createMap()
77
+ }
78
+
79
+ // choose random color if dataseries has none and store it for furure updates
80
+ this.inputData.dataseries.forEach(ds => {
81
+ if (!this.colors.has(ds.label)) {
82
+ ds.color = ds.color ?? tinycolor.random().toString()
83
+ this.colors.set(ds.label, ds.color)
84
+ }
85
+ })
86
+ // console.log('The input data', this.inputData.dataseries[0], this.inputData.dataseries[1])
87
+ // Pivot inputData if required
88
+ this.dataSets = []
89
+ this.inputData.dataseries.forEach(ds => {
90
+ const color = this.colors.get(ds.label)
91
+ const distincts = [...new Set(ds.data.map((d: Point) => d.pivot))]
92
+ const derivedColors = tinycolor(color).monochromatic(distincts.length).map((c: any) => c.toHexString())
93
+ if (distincts.length > 1) {
94
+ distincts.forEach((piv, i) => {
95
+ const pds: any = {
96
+ label: `${ds.label} ${piv}`,
97
+ order: ds.order,
98
+ type: ds.type,
99
+ latestValues: ds.latestValues,
100
+ color: derivedColors[i],
101
+ config: ds.config,
102
+ data: ds.data.filter(d => d.pivot === piv)
103
+ }
104
+ this.dataSets.push(pds)
105
+ })
106
+ } else {
107
+ ds.color = ds.color ?? this.colors[ds.label]
108
+ this.dataSets.push(ds)
109
+ }
110
+ })
111
+
112
+ this.inputData.dataseries = []
113
+
114
+ // Filter for latest Values
115
+ this.dataSets.forEach(ds => {
116
+ if (ds.latestValues > 0) ds.data = ds.data.splice(-ds.latestValues)
117
+ })
118
+
119
+ // console.log('mapbox datasets', this.dataSets)
120
+
121
+ // create geojson sources
122
+ this.dataSets.sort((a, b) => b.order - a.order).forEach(ds => {
123
+
124
+ this.dataSources.set('input:' + ds.label, {
125
+ type: 'FeatureCollection',
126
+ features: this.createGEOJson(ds)
127
+ } as GeoJSON.FeatureCollection)
128
+
129
+ if (this.map) this.syncDataLayers()
130
+ // console.log('mapbox DataLayers', this.dataSources)
131
+ })
132
+ }
133
+
134
+ createGEOJson(ds: Dataseries): GeoJSON.Feature[] {
135
+ if (ds.type !== 'line') {
136
+ const features: GeoJSON.Feature[] = ds.data
137
+ .filter(p =>
138
+ p.lon !== undefined
139
+ && p.lat !== undefined
140
+ && p.value !== undefined
141
+ && typeof p.lon === 'number'
142
+ && typeof p.lat === 'number'
143
+ && typeof p.value === 'number')
144
+ .map(p => {
145
+ const point: GeoJSON.Feature = {
146
+ type: 'Feature',
147
+ geometry: {
148
+ type: 'Point',
149
+ coordinates: [p.lon, p.lat]
150
+ },
151
+ properties: {
152
+ value: Math.round(p.value)
153
+ }
154
+ }
155
+ return point
156
+ })
157
+ return features
158
+ }
159
+
160
+ const line: number[][] = ds.data.reverse()
161
+ .filter(p => p.lon !== undefined && p.lat !== undefined)
162
+ .map(p => [p.lon, p.lat, p.alt])
163
+
164
+ const feature: GeoJSON.Feature = {
165
+ type: 'Feature',
166
+ geometry: {
167
+ type: 'LineString',
168
+ coordinates: line
169
+ },
170
+ properties: {}
171
+ }
172
+ return [feature]
173
+ }
174
+
175
+ addCircleLayer(dataSet: Dataseries) {
176
+ if (!dataSet) return
177
+ const layerConfig = {
178
+ 'id': dataSet.label + ':circle',
179
+ 'type': 'circle',
180
+ 'source': 'input:' + dataSet.label,
181
+ 'paint': {
182
+ ...dataSet.config['circle'],
183
+ "circle-radius": ['get', 'value'],
184
+ "circle-radius-transition": {
185
+ duration: 1000,
186
+ delay: 0
187
+ },
188
+ "circle-color": dataSet.color
189
+ },
190
+ // Place polygons under labels, roads and buildings.
191
+ // 'aeroway-polygon'
192
+ }
193
+
194
+ this.map?.addLayer(layerConfig)
195
+
196
+ if (!dataSet.config['symbol']) return
197
+ const layerConfig2 = {
198
+ 'id': dataSet.label + ':symbol',
199
+ 'type': 'symbol',
200
+ 'source': 'input:' + dataSet.label,
201
+ layout: {
202
+ 'text-field': ['get', 'value'],
203
+ 'text-size': dataSet.config['symbol']['text-size'],
204
+ 'text-anchor': 'center',
205
+ },
206
+ paint: {
207
+ 'text-color': dataSet.config['symbol']['text-color'],
208
+ }
209
+ // Place polygons under labels, roads and buildings.
210
+ // 'aeroway-polygon'
211
+ }
212
+ this.map?.addLayer(layerConfig2)
213
+ }
214
+
215
+ addSymbolLayer(dataSet: Dataseries) {
216
+ if (!dataSet) return
217
+ const layerConfig = {
218
+ 'id': dataSet.label + ':symbol',
219
+ 'type': 'symbol',
220
+ 'source': 'input:' + dataSet.label,
221
+ layout: {
222
+ 'text-field': ['get', 'value'],
223
+ 'text-size': dataSet.config['symbol']['text-size'],
224
+ 'text-anchor': 'center',
225
+ 'icon-image': dataSet.config.symbol['icon-image'],
226
+ 'icon-size': dataSet.config.symbol['icon-size']
227
+ },
228
+ paint: {
229
+ 'text-color': dataSet.config['symbol']['text-color'],
230
+ }
231
+ // Place polygons under labels, roads and buildings.
232
+ // 'aeroway-polygon'
233
+ }
234
+ this.map?.addLayer(layerConfig)
235
+ }
236
+
237
+ addHeatmapLayer(dataSet: Dataseries) {
238
+ if (!dataSet) return
239
+
240
+ const min = Math.min(...dataSet.data.map(p => p.value))
241
+ const max = Math.max(...dataSet.data.map(p => p.value))
242
+ const layerConfig = {
243
+ 'id': dataSet.label + ':heatmap',
244
+ 'type': 'heatmap',
245
+ 'source': 'input:' + dataSet.label,
246
+ paint: {
247
+ ...dataSet.config.heatmap,
248
+ 'heatmap-color': [
249
+ "interpolate",
250
+ ["linear"],
251
+ ["heatmap-density"],
252
+ 0,"rgba(0, 0, 255, 0)",
253
+ 0.1, "royalblue",
254
+ 0.3, "cyan",
255
+ 0.5, "lime",
256
+ 0.7, "yellow",
257
+ 1, "tomato"
258
+ ],
259
+ // Increase the heatmap weight based on frequency and property magnitude
260
+ 'heatmap-weight': [
261
+ 'interpolate',
262
+ ['linear'],
263
+ ['get', 'value'],
264
+ min, 0,
265
+ max, 3
266
+ ],
267
+ // // Increase the heatmap color weight weight by zoom level
268
+ // // heatmap-intensity is a multiplier on top of heatmap-weight
269
+ // 'heatmap-intensity': [
270
+ // 'interpolate',
271
+ // ['linear'],
272
+ // ['zoom'],
273
+ // 0, 1,
274
+ // 9, 3
275
+ // ],
276
+ // Adjust the heatmap radius by zoom level
277
+ 'heatmap-radius': [
278
+ 'interpolate',
279
+ ['linear'],
280
+ ['get', 'value'],
281
+ min, 30,
282
+ max, 30 + dataSet.config.heatmap['heatmap-radius']
283
+ ],
284
+ 'heatmap-radius-transition': {
285
+ duration: 1000,
286
+ delay: 0
287
+ }
288
+ // // Transition from heatmap to circle layer by zoom level
289
+ // 'heatmap-opacity': [
290
+ // 'interpolate',
291
+ // ['linear'],
292
+ // ['zoom'],
293
+ // 7, 1,
294
+ // 9, 0
295
+ // ]
296
+ }
297
+ // Place polygons under labels, roads and buildings.
298
+ // 'aeroway-polygon'
299
+ }
300
+ this.map?.addLayer(layerConfig)
301
+ }
302
+
303
+ addLineLayer(dataSet: Dataseries) {
304
+ if (!dataSet) return
305
+ const layerConfig = {
306
+ 'id': dataSet.label + ':line',
307
+ 'type': 'line',
308
+ 'source': 'input:' + dataSet.label,
309
+ layout: {
310
+ 'line-cap': 'round',
311
+ },
312
+ paint: {
313
+ ...dataSet.config.line,
314
+ "line-color": dataSet.color,
315
+ }
316
+ // Place polygons under labels, roads and buildings.
317
+ // 'aeroway-polygon'
318
+ }
319
+ this.map?.addLayer(layerConfig)
320
+
321
+ if (!dataSet.config.symbol['icon-image']) return
322
+
323
+ const layerConfig2 = {
324
+ 'id': dataSet.label + ':symbol',
325
+ 'type': 'symbol',
326
+ 'source': 'input:' + dataSet.label,
327
+ layout: {
328
+ 'icon-image': dataSet.config.symbol['icon-image'],
329
+ 'icon-size': dataSet.config.symbol['icon-size'],
330
+ },
331
+ paint: {
332
+ 'icon-color': dataSet.color
333
+ }
334
+ // Place polygons under labels, roads and buildings.
335
+ // 'aeroway-polygon'
336
+ }
337
+ this.map?.addLayer(layerConfig2)
338
+ }
339
+
340
+ syncDataLayers() {
341
+
342
+ // remove sources and all Layers that are not part of the inputData anymore
343
+ const sources: any[] = this.map.getStyle().sources ?? []
344
+ const mySources: [string, any][] = Object.entries(sources).filter(([l]) => l.startsWith('input:'))
345
+
346
+ mySources.forEach(([label]) => {
347
+ if (!this.dataSources.has(label)){
348
+ // remove all layers using this source
349
+ this.map.getStyle().layers.filter((la: any) => la.id === label + ':' + la.type).forEach((la: any) => {
350
+ this.map.removeLayer(la.id)
351
+ })
352
+ this.map.removeSource(label)
353
+ }
354
+ })
355
+
356
+ // add new layers or update the data of existing layers
357
+ this.dataSets.forEach(ds => {
358
+ const fc = this.dataSources.get('input:' + ds.label)
359
+ const src = this.map.getSource('input:' + ds.label)
360
+ if (src) {
361
+ src.setData(fc || [])
362
+ return
363
+ }
364
+ // console.log('adding source', ds.label, ds.type)
365
+ this.map?.addSource('input:' + ds.label, {
366
+ type: 'geojson',
367
+ data: fc || []
368
+ })
369
+
370
+ switch(ds.type) {
371
+ case 'circle':
372
+ this.addCircleLayer(ds)
373
+ return
374
+ case 'symbol':
375
+ this.addSymbolLayer(ds)
376
+ return
377
+ case 'heatmap':
378
+ this.addHeatmapLayer(ds)
379
+ return
380
+ case 'line':
381
+ this.addLineLayer(ds)
382
+ }
383
+ })
384
+ if (this.inputData?.settings?.follow) this.fitBounds()
385
+ }
386
+
387
+ addBuildingLayer() {
388
+ // Insert the layer beneath any symbol layer.
389
+ const {layers} = this.map.getStyle()
390
+
391
+ let labelLayerId: number = 0
392
+ for (let i = 0; i < layers.length; i++) {
393
+ if (layers[i].type === 'symbol' && layers[i].layout['text-field']) {
394
+ labelLayerId = layers[i].id
395
+ break
396
+ }
397
+ }
398
+
399
+ this.map.addLayer({
400
+ 'id': '3d-buildings',
401
+ 'source': 'composite',
402
+ 'source-layer': 'building',
403
+ 'filter': ['==', 'extrude', 'true'],
404
+ 'type': 'fill-extrusion',
405
+ 'minzoom': 14,
406
+ 'paint': {
407
+ 'fill-extrusion-color': '#aaa',
408
+ // use an 'interpolate' expression to add a smooth transition effect to the
409
+ // buildings as the user zooms in
410
+ 'fill-extrusion-height': [
411
+ "interpolate", ["linear"],
412
+ ["zoom"],
413
+ 14, 0,
414
+ 14.05, ["get", "height"]
415
+ ],
416
+ 'fill-extrusion-base': [
417
+ "interpolate", ["linear"],
418
+ ["zoom"],
419
+ 14, 0,
420
+ 14.05, ["get", "min_height"]
421
+ ],
422
+ 'fill-extrusion-opacity': .6
423
+ }
424
+ }, labelLayerId)
425
+ }
426
+
427
+ createMap() {
428
+ if (this.map) return
429
+ this.mapStyle = this.inputData?.settings?.style
430
+ this.map = new mapboxgl.Map({
431
+ container: this.shadowRoot?.getElementById('map') as HTMLCanvasElement,
432
+ style: `mapbox://styles/mapbox/${this.mapStyle ?? 'light-v11'}` ,
433
+ center: [8.6841700, 50.1155200],
434
+ zoom: 1.8,
435
+ attributionControl: false
436
+ })
437
+
438
+ this.map.scrollZoom.disable();
439
+
440
+ this.map.addControl(new mapboxgl.NavigationControl(), 'top-right')
441
+
442
+ const scale = new mapboxgl.ScaleControl({
443
+ maxWidth: 80,
444
+ unit: 'metric'
445
+ })
446
+
447
+ this.map.addControl(scale, 'bottom-left')
448
+
449
+ console.log('MAPBOX VERSION', mapboxgl.version)
450
+
451
+ this.map.on('load', () => {
452
+ this.addBuildingLayer()
453
+ this.syncDataLayers()
454
+ });
455
+ }
456
+
457
+ static styles = css`
458
+ :host {
459
+ display: block;
460
+ color: var(--re-bar-text-color, #000);
461
+ font-family: sans-serif;
462
+ padding: 16px;
463
+ box-sizing: border-box;
464
+ margin: auto;
465
+ }
466
+
467
+ .paging:not([active]) { display: none !important; }
468
+
469
+ header {
470
+ display: flex;
471
+ margin: 0 0 16px 0;
472
+ gap: 24px;
473
+ justify-content: space-between;
474
+ }
475
+ h3 {
476
+ margin: 0;
477
+ max-width: 300px;
478
+ overflow: hidden;
479
+ text-overflow: ellipsis;
480
+ white-space: nowrap;
481
+ }
482
+ p {
483
+ margin: 10px 0 0 0;
484
+ max-width: 300px;
485
+ font-size: 14px;
486
+ overflow: hidden;
487
+ text-overflow: ellipsis;
488
+ white-space: nowrap;
489
+ line-height: 17px;
490
+ }
491
+
492
+ .wrapper {
493
+ display: flex;
494
+ flex-direction: column;
495
+ height: 100%;
496
+ width: 100%;
497
+ }
498
+ #map {
499
+ flex: 1;
500
+ }
501
+
502
+ .title {
503
+ white-space: nowrap;
504
+ }
505
+
506
+ .legend {
507
+ display: flex;
508
+ flex-wrap: wrap;
509
+ gap: 12px;
510
+ }
511
+
512
+ .label {
513
+ display: flex;
514
+ align-items: center;
515
+ font-size: 14px;
516
+ gap: 8px
517
+ }
518
+
519
+ a.mapboxgl-ctrl-logo {
520
+ display: none;
521
+ }
522
+
523
+ `;
524
+
525
+ render() {
526
+ return html`
527
+ <link href="https://api.mapbox.com/mapbox-gl-js/v${mapboxgl.version}/mapbox-gl.css" rel="stylesheet">
528
+ <div class="wrapper">
529
+ <header>
530
+ <div class="title">
531
+ <h3 class="paging" ?active=${this.inputData?.settings?.title}>${this.inputData?.settings?.title}</h3>
532
+ <p class="paging" ?active=${this.inputData?.settings?.subTitle}>${this.inputData?.settings?.subTitle}</p>
533
+ </div>
534
+ <div class="legend paging" ?active=${this?.inputData?.settings?.showLegend}>
535
+ ${repeat(this.dataSets, ds => ds.label, ds => {
536
+ return html`
537
+ <div class="label">
538
+ <div style="background: ${ds.color}; width: 24px; height: 12px;"></div>
539
+ <div>${ds.label}</div>
540
+ </div>
541
+ `})}
542
+ </div>
543
+ </header>
544
+ <div id="map"></div>
545
+ </div>
546
+ `;
547
+ }
548
+ }
549
+
550
+ window.customElements.define('widget-mapbox-versionplaceholder', WidgetMapbox);