gridviz 3.0.40 → 3.1.0

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/src/core/Map.js CHANGED
@@ -1,669 +1,670 @@
1
- //@ts-check
2
- 'use strict'
3
-
4
- // internal imports
5
- import { GeoCanvas } from './GeoCanvas.js'
6
- import { Tooltip } from './Tooltip.js'
7
- import { ZoomButtons } from '../button/ZoomButtons.js'
8
- import { FullscreenButton } from '../button/FullscreenButton.js'
9
-
10
- // external imports
11
- import { select } from 'd3-selection'
12
-
13
- /**
14
- * A gridviz application.
15
- *
16
- * @module core
17
- * @author Joseph Davies, Julien Gaffuri
18
- */
19
- export class Map {
20
- /**
21
- * @param {HTMLDivElement} container
22
- * @param {object} opts
23
- */
24
- constructor(container, opts) {
25
- opts = opts || {}
26
-
27
- /**
28
- * The layers.
29
- * @type {Array.<import("./Layer.js").Layer>}
30
- * */
31
- this.layers = opts.layers || []
32
-
33
- /**
34
- * When true, redraw() schedules one redraw for the next animation frame instead of
35
- * drawing at once, so the many redraw requests of a busy moment (every tile that
36
- * arrives, every info.json, every setting change) are drawn once. redrawNow() still
37
- * draws at once. Off by default: redraw() stays synchronous, as it always was.
38
- * @type {boolean} */
39
- this.asyncRedraw = !!opts.asyncRedraw
40
- /** @private @type {number|null} */
41
- this._redrawFrame = null
42
-
43
- //get container element
44
- this.container = container || document.getElementById('gridviz')
45
- if (!this.container) {
46
- console.error('Cannot find gridviz container element.')
47
- return
48
- }
49
- this.container.classList.add('gridviz-map')
50
-
51
- //https://css-tricks.com/absolute-positioning-inside-relative-positioning/
52
- this.container.style.position = 'relative' // container element must have relative positioning
53
-
54
- //set dimensions
55
- /** @type {number} */
56
- this.w = opts.w || this.container.offsetWidth
57
- /** @type {number} */
58
- this.h = opts.h || this.container.offsetHeight
59
-
60
- // Create the main canvas (for rendering to screen)
61
- /** @type {HTMLCanvasElement} */
62
- this._canvas = opts.canvas || this.initialiseCanvas()
63
-
64
- /** Initialize GeoCanvas
65
- * @type {GeoCanvas}
66
- * @private */
67
- this.geoCanvas = new GeoCanvas(this._canvas, opts.x, opts.y, opts.z, opts)
68
-
69
- this.geoCanvas.redraw = () => {
70
- this.redraw()
71
- }
72
- this.geoCanvas.cancelCurrentRequests = () => {
73
- // when the zoom level changes, avoid drawing outdated tiles, and ensure that requests are properly aborted when necessary
74
- for (const layer of this.layers) {
75
- //multires
76
- if (layer.dataset?.datasets) {
77
- for (const dataset of layer.dataset?.datasets) {
78
- if (dataset?.cancelCurrentRequests) dataset.cancelCurrentRequests()
79
- }
80
- }
81
- //single res
82
- if (layer.dataset?.cancelCurrentRequests) layer.dataset?.cancelCurrentRequests()
83
- }
84
- }
85
-
86
- // legend div
87
- this.legend = opts.legendContainer
88
- ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
89
- : null
90
- this._legendProvidedByUser = !!opts.legendContainer
91
-
92
- /**
93
- * Called with the legend container (an HTML element) each time the map has drawn and its legends
94
- * are in the container, for a host that needs to adjust them (fit their width, restyle...).
95
- * It can also be set after the map is created.
96
- * @type {function(HTMLElement):void|undefined} */
97
- this.onLegendUpdate = opts.onLegendUpdate
98
- if (!this.legend) this.initialiseLegend()
99
-
100
- //tooltip
101
-
102
- // set App container as default parent element for tooltip
103
- if (!opts.tooltip) opts.tooltip = {}
104
- if (!opts.tooltip.parentElement) opts.tooltip.parentElement = this.container
105
-
106
- /**
107
- * @private
108
- * @type {Tooltip} */
109
- this.tooltip = new Tooltip(opts.tooltip)
110
-
111
- /**
112
- * Set to true so that the tooltip appears for the first layer starting from top where a cell is found.
113
- * Otherwise, it shows a tooltip only for the first layer from the top with a cellInfoHTML property, even if there is no cell at the mouse position for this layer.
114
- * @type {boolean} */
115
- this.digForTooltip = opts.digForTooltip || false
116
-
117
- // add event listeners to the canvas. Mouse moves are handled at most once per
118
- // animation frame (the latest one): finding the cell and redrawing the highlight
119
- // for every intermediate event only delays the one that's shown.
120
- /** @private */
121
- this._focusFrame = null
122
- /** @private */
123
- this._focusEvent = null
124
- const scheduleFocus = (e) => {
125
- this._focusEvent = e
126
- if (this._focusFrame) return
127
- this._focusFrame = requestAnimationFrame(() => {
128
- this._focusFrame = null
129
- const ev = this._focusEvent
130
- this._focusEvent = null
131
- if (ev) this.focusCell(ev)
132
- })
133
- }
134
- this.mouseOverHandler = scheduleFocus
135
- this.mouseMoveHandler = scheduleFocus
136
- this.mouseOutHandler = (e) => {
137
- if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
138
- this._focusFrame = null
139
- this._focusEvent = null
140
- this.tooltip.hide()
141
- }
142
- this.geoCanvas.canvas.addEventListener('mouseover', this.mouseOverHandler)
143
- this.geoCanvas.canvas.addEventListener('mousemove', this.mouseMoveHandler)
144
- this.geoCanvas.canvas.addEventListener('mouseout', this.mouseOutHandler)
145
-
146
- // listen for resize events on the App's container and handle them
147
- this.defineResizeObserver()
148
-
149
- // add extra logic to onZoomStartFun
150
- this.geoCanvas.onZoomStartFun = (e) => {
151
- if (opts.onZoomStartFun) opts.onZoomStartFun(e)
152
- this.tooltip.hide()
153
- }
154
-
155
- //for mouse over
156
- /**
157
- * @private
158
- * @type {HTMLCanvasElement|null} */
159
- this.canvasSave = null
160
-
161
- this.selectionRectangleColor = opts.selectionRectangleColor || '#FF6347'
162
- this.selectionRectangleWidthPix = opts.selectionRectangleWidthPix || (() => 4) //(r,z) => {}
163
-
164
- // transparent background (e.g. leaflet) 'red painting' fix
165
- this.transparentBackground = opts.transparentBackground
166
-
167
- //set default globalCompositeOperation
168
- this.defaultGlobalCompositeOperation =
169
- opts.defaultGlobalCompositeOperation || this.geoCanvas.ctx.globalCompositeOperation
170
- }
171
-
172
- /**
173
- * @protected
174
- * @returns {HTMLCanvasElement}
175
- */
176
- initialiseCanvas() {
177
- const canvas = document.createElement('canvas')
178
- canvas.classList.add('gridviz-canvas')
179
- canvas.setAttribute('width', '' + this.w)
180
- canvas.setAttribute('height', '' + this.h)
181
- this.container.appendChild(canvas)
182
- return canvas
183
- }
184
-
185
- initialiseLegend() {
186
- this.legend = select(this.container)
187
- .append('div') // Create a new container
188
- .attr('id', 'gridviz-legend')
189
- .classed('gridviz-legends', true)
190
- .style('position', 'absolute')
191
- .style('width', 'auto')
192
- .style('height', 'auto')
193
- .style('background', '#FFFFFF')
194
- //.style("padding", this.padding)
195
- .style('border', '0px')
196
- //.style('border-radius', '5px')
197
- .style('box-shadow', '3px 3px 3px grey, -3px -3px 3px #ddd')
198
- .style('font-family', 'Helvetica, Arial, sans-serif')
199
- .style('bottom', '15px')
200
- .style('right', '15px')
201
- //hide
202
- //.style("visibility", "hidden")
203
- }
204
-
205
- /**
206
- * Set/get layer stack.
207
- *
208
- * @param {undefined|import("./Layer.js").Layer|import("./Layer.js").Layer[]} layers
209
- * @returns { this | import("./Layer.js").Layer[] }
210
- */
211
- layers_(layers) {
212
- if (arguments.length === 0) return this.layers
213
- if (arguments.length === 1)
214
- if (Array.isArray(layers)) this.layers = layers
215
- else this.layers = [layers]
216
- else this.layers = arguments
217
- return this
218
- }
219
-
220
- /**
221
- * Redraws the map: at once, or, with the asyncRedraw option, once at the next
222
- * animation frame however many times it's called before then.
223
- * @returns {this} */
224
- redraw() {
225
- if (this.asyncRedraw) {
226
- if (!this._redrawFrame && !this._destroyed)
227
- this._redrawFrame = requestAnimationFrame(() => {
228
- this._redrawFrame = null
229
- this.redrawNow()
230
- })
231
- return this
232
- }
233
- return this.redrawNow()
234
- }
235
-
236
- /**
237
- * Redraws the map at once, whatever the asyncRedraw option (and cancels a scheduled redraw).
238
- * @returns {this} */
239
- redrawNow() {
240
- if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
241
- this._redrawFrame = null
242
- // a destroyed map can still be asked to redraw by a dataset it shared (see destroy)
243
- if (this._destroyed) return this
244
-
245
- //remove legend elements: the legends themselves only, not what is inside them
246
- //(a legend keeps its content while one of its swatches is hovered, see Style.updateLegends)
247
- //except the one being hovered, which would lose its mouseleave (see Legend.highlight)
248
- if (this.legend) {
249
- this.legend.selectAll(':scope > *:not([data-gridviz-hovered])').remove()
250
- // the legends of this draw are placed one after the other (see GridLayer.addLegends)
251
- this.legend.node()._gridvizLegendCursor = null
252
- }
253
-
254
- //clear
255
- this.geoCanvas.initCanvasTransform()
256
- this.geoCanvas.clear(this.geoCanvas.backgroundColor)
257
-
258
- const z = this.geoCanvas.view.z
259
- this.updateExtentGeo()
260
-
261
- const ctx = this.geoCanvas.offscreenCtx
262
-
263
- //go through the layers
264
- for (const layer of this.layers) {
265
- //check if layer is visible
266
- if (layer.visible && !layer.visible(z)) continue
267
-
268
- //set layer alpha and blend mode
269
- if (layer.alpha || layer.blendOperation) {
270
- ctx.save()
271
- if (layer.alpha) ctx.globalAlpha = layer.alpha(z)
272
- if (layer.blendOperation) ctx.globalCompositeOperation = layer.blendOperation(z)
273
- }
274
-
275
- //set affin transform to draw with geographical coordinates
276
- this.geoCanvas.setCanvasTransform()
277
-
278
- //draw layer
279
- layer.draw(this.geoCanvas, this.legend)
280
-
281
- //draw layer filter
282
- if (layer.filterColor) layer.drawFilter(this.geoCanvas)
283
-
284
- //restore default alpha and blend operation
285
- if (layer.alpha || layer.blendOperation) ctx.restore()
286
- }
287
-
288
- // one drawImage call: draw the offscreen canvas to the main canvas
289
- this.geoCanvas.initCanvasTransform()
290
- this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
291
-
292
- this.canvasSave = null
293
- // the canvas holds no focus rectangle any more
294
- this._focusedCell = undefined
295
-
296
- // the legends are in the container now
297
- if (this.onLegendUpdate && this.legend) this.onLegendUpdate(this.legend.node())
298
-
299
- return this
300
- }
301
-
302
- /**
303
- * @param {number} marginPx
304
- * @returns {import('./GeoCanvas.js').Envelope}
305
- * @public
306
- */
307
- updateExtentGeo(marginPx = 20) {
308
- return this.geoCanvas.updateExtentGeo(marginPx)
309
- }
310
-
311
- /** @param {MouseEvent} e */
312
- focusCell(e) {
313
- // Don’t process hover events during active zoom
314
- if (this.geoCanvas._isZooming) return;
315
-
316
- //compute mouse geo position
317
- const mousePositionGeo = {
318
- x: this.geoCanvas.pixToGeoX(e.offsetX + this.tooltip.xMouseOffset),
319
- y: this.geoCanvas.pixToGeoY(e.offsetY + this.tooltip.yMouseOffset),
320
- }
321
- /** @type {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined} */
322
- const focus = this.getCellFocusInfo(mousePositionGeo)
323
-
324
- // Same cell as the one already highlighted (or still no cell): the canvas is right
325
- // as it is, only the tooltip follows the mouse. Saves a full-canvas copy per move.
326
- const focusedCell = focus ? focus.cell : null
327
- if (focusedCell === this._focusedCell) {
328
- if (focus) {
329
- this.tooltip.setPosition(e)
330
- this.tooltip.show()
331
- } else this.tooltip.hide()
332
- return
333
- }
334
- this._focusedCell = focusedCell
335
-
336
- // Transparent background (Leaflet): redraw base, then draw focus rect directly on the visible canvas
337
- if (this.transparentBackground) {
338
- // Always restore the base (offscreen) into the visible canvas
339
- this.geoCanvas.initCanvasTransform();
340
- this.geoCanvas.ctx.clearRect(0, 0, this.w, this.h);
341
- this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0);
342
-
343
- if (focus) {
344
- this.tooltip.html(focus.html);
345
- this.tooltip.setPosition(e);
346
- this.tooltip.show();
347
-
348
- const rectWPix = this.selectionRectangleWidthPix
349
- ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
350
- : 4;
351
-
352
- const ctx = this.geoCanvas.ctx; // draw directly on visible canvas
353
- ctx.save();
354
- ctx.strokeStyle = this.selectionRectangleColor;
355
- ctx.lineWidth = rectWPix;
356
- ctx.beginPath();
357
- // compute in pixel space (no geo transform on ctx)
358
- const xPix = this.geoCanvas.geoToPixX(focus.cell.x);
359
- const yPix = this.geoCanvas.geoToPixY(focus.cell.y);
360
- const wPix = focus.resolution / this.geoCanvas.view.z;
361
- const hPix = -wPix; // y axis inverted in geoToPix
362
- ctx.rect(
363
- xPix - rectWPix / 2,
364
- yPix + rectWPix / 2,
365
- wPix + rectWPix,
366
- hPix - rectWPix
367
- );
368
- ctx.stroke();
369
- ctx.restore();
370
- } else {
371
- this.tooltip.hide();
372
- }
373
- return; // handled
374
- }
375
-
376
- if (focus) {
377
- this.geoCanvas.canvas.style.cursor = 'pointer';
378
- this.tooltip.html(focus.html)
379
- this.tooltip.setPosition(e)
380
- this.tooltip.show()
381
-
382
- //show cell position as a rectangle
383
- if (!this.canvasSave) {
384
- this.canvasSave = document.createElement('canvas')
385
- this.canvasSave.setAttribute('width', '' + this.w)
386
- this.canvasSave.setAttribute('height', '' + this.h)
387
- this.canvasSave.getContext('2d')?.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
388
- } else {
389
- this.geoCanvas.offscreenCtx.drawImage(this.canvasSave, 0, 0)
390
- }
391
-
392
- //draw image saved + draw rectangle
393
- const rectWPix = this.selectionRectangleWidthPix
394
- ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
395
- : 4
396
- this.geoCanvas.initCanvasTransform()
397
- const ctx = this.geoCanvas.offscreenCtx
398
- ctx.strokeStyle = this.selectionRectangleColor
399
- ctx.lineWidth = rectWPix
400
- ctx.beginPath()
401
-
402
- ctx.rect(
403
- this.geoCanvas.geoToPixX(focus.cell.x) - rectWPix / 2,
404
- this.geoCanvas.geoToPixY(focus.cell.y) + rectWPix / 2,
405
- focus.resolution / this.geoCanvas.view.z + rectWPix,
406
- -focus.resolution / this.geoCanvas.view.z - rectWPix
407
- )
408
- ctx.stroke()
409
- this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
410
- } else {
411
- this.geoCanvas.canvas.style.cursor = 'default';
412
- this.tooltip.hide()
413
- if (this.canvasSave) this.geoCanvas.ctx.drawImage(this.canvasSave, 0, 0)
414
- }
415
- }
416
-
417
- /**
418
- * Return the cell HTML info at a given geo position.
419
- * This is usefull for user interactions, to show this info where the user clicks for example.
420
- *
421
- * @param {{x:number,y:number}} posGeo
422
- * @returns {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined}
423
- * @protected
424
- */
425
- getCellFocusInfo(posGeo) {
426
-
427
- // go through the layers, starting from top one
428
- const z = this.geoCanvas.view.z
429
- for (let i = this.layers.length - 1; i >= 0; i--) {
430
-
431
- /** @type {import("./Layer.js").Layer} */
432
- const layer = this.layers[i]
433
-
434
- if (layer.visible && !layer.visible(z)) continue
435
- if (layer.cellInfoHTML === 'none') continue
436
- if (!layer.cellInfoHTML) continue
437
- if (!layer.getDataset) continue
438
-
439
- // get dataset
440
- const dsc = layer.getDataset(z)
441
- if (!dsc) continue
442
-
443
- //get cell at mouse position
444
- /** @type {import('./Dataset.js').Cell|undefined} */
445
- const cell = dsc.getCellFromPosition(posGeo, dsc.getViewCache())
446
-
447
- // no cell found at theis position for this layer
448
- if (!cell)
449
- // dig to the next layer below
450
- if (this.digForTooltip) continue
451
- // no tooltip to show
452
- else return undefined
453
-
454
- //rare case for a dataset with mixed resolutions
455
- if (dsc.mixedResolution) {
456
- const r = +dsc.mixedResolution(cell)
457
- const html = layer.cellInfoHTML(cell, r)
458
- if (!html) return undefined
459
- return { cell: cell, html: html, resolution: r }
460
- }
461
-
462
- const html = layer.cellInfoHTML(cell, dsc.getResolution())
463
- if (!html) return undefined
464
- return { cell: cell, html: html, resolution: dsc.getResolution() }
465
- }
466
- }
467
-
468
- /**
469
- * @param {number} x
470
- * @param {number} y
471
- * @param {number|undefined} z
472
- */
473
- setView(x, y, z = undefined) {
474
- this.geoCanvas.view.x = x
475
- this.geoCanvas.view.y = y
476
- if (z != undefined) this.geoCanvas.view.z = z
477
- return this
478
- }
479
-
480
- /** @returns {import('./GeoCanvas.js').View} */
481
- getView() {
482
- return this.geoCanvas.view
483
- }
484
-
485
- /** @returns {number} */
486
- getZoom() {
487
- return this.geoCanvas.view.z
488
- }
489
- /** @param {number} z @returns {this} */
490
- setZoom(z) {
491
- this.geoCanvas.view.z = z
492
- return this
493
- }
494
-
495
- /** @returns {Array.<number|undefined>} */
496
- getCenterExtent() {
497
- return this.geoCanvas.getCenterExtent()
498
- }
499
- /** @param {Array.<number>} val @returns {this} */
500
- setCenterExtent(val) {
501
- this.geoCanvas.setCenterExtent(val)
502
- return this
503
- }
504
-
505
- /** @returns {Array.<number>} */
506
- getZoomExtent() {
507
- return this.geoCanvas.getZoomExtent()
508
- }
509
- /** @param {Array.<number>} val @returns {this} */
510
- setZoomExtent(val) {
511
- this.geoCanvas.setZoomExtent(val)
512
- return this
513
- }
514
-
515
- /** @returns {string} */
516
- getBackgroundColor() {
517
- return this.geoCanvas.backgroundColor
518
- }
519
- /** @param {string} val @returns {this} */
520
- setBackgroundColor(val) {
521
- this.geoCanvas.backgroundColor = val
522
- return this
523
- }
524
-
525
- /**
526
- * Adds a set of zoom buttons to the map
527
- *
528
- * @param {object} opts
529
- * @returns {this}
530
- */
531
- addZoomButtons(opts) {
532
- // * opts.id
533
- // * opts.onZoom - custom event handler function
534
- // * opts.x
535
- // * opts.y
536
- // * opts.delta - zoom delta applied on each click
537
-
538
- this.zoomButtons = new ZoomButtons({
539
- map: this,
540
- id: opts?.id || 'gridviz-zoom-buttons-' + this.container.id,
541
- class: opts?.class,
542
- x: opts?.x,
543
- y: opts?.y,
544
- onZoom: opts?.onZoom,
545
- delta: opts?.delta || 0.2,
546
- })
547
-
548
- return this
549
- }
550
-
551
- /**
552
- * Adds a fullscreen toggle button to the app
553
- *
554
- * @param {object} opts
555
- * @returns {this}
556
- */
557
- addFullscreenButton(opts) {
558
- // * opts.map - the gridviz map
559
- // * opts.id
560
- // * opts.x
561
- // * opts.y
562
-
563
- this.fullscreenButton = new FullscreenButton({
564
- map: this,
565
- id: opts?.id || 'gridviz-fullscreen-button',
566
- class: opts?.class,
567
- x: opts?.x,
568
- y: opts?.y,
569
- })
570
-
571
- return this
572
- }
573
-
574
- /** @returns {this} */
575
- setViewFromURL() {
576
- this.geoCanvas.setViewFromURL()
577
- return this
578
- }
579
-
580
- /**
581
- * @description Add a resize event observer to the Apps container and update the canvas accordingly
582
- * @memberof App
583
- */
584
- defineResizeObserver() {
585
- // Track whether the observer is currently processing a resize event
586
- let resizePending = false
587
-
588
- const resizeObserver = new ResizeObserver((entries) => {
589
- if (!Array.isArray(entries) || !entries.length) return
590
-
591
- let container = this.container
592
-
593
- // Ensure the container has valid dimensions
594
- if (container.clientWidth > 0 && container.clientHeight > 0) {
595
- if (!resizePending) {
596
- resizePending = true // Prevent overlapping resize triggers
597
-
598
- window.requestAnimationFrame(() => {
599
- resizePending = false // Reset the flag after processing
600
-
601
- // Check for size changes
602
- if (this.h !== container.clientHeight || this.w !== container.clientWidth) {
603
- this.h = container.clientHeight
604
- this.w = container.clientWidth
605
-
606
- // Update geoCanvas sizes
607
- this.geoCanvas.h = this.h
608
- this.geoCanvas.w = this.w
609
- this.geoCanvas.canvas.setAttribute('width', String(this.w))
610
- this.geoCanvas.canvas.setAttribute('height', String(this.h))
611
- this.geoCanvas.offscreenCanvas.setAttribute('width', String(this.w))
612
- this.geoCanvas.offscreenCanvas.setAttribute('height', String(this.h))
613
-
614
- this.redraw()
615
-
616
- // Optionally reposition UI elements
617
- // if (this.zoomButtons) this.zoomButtons.node.style.left = this.w - 50 + 'px';
618
- // if (this.fullscreenButton) this.fullscreenButton.node.style.left = this.w - 50 + 'px';
619
- }
620
- })
621
- }
622
- }
623
- })
624
-
625
- resizeObserver.observe(this.container)
626
- /** @private */
627
- this._resizeObserver = resizeObserver
628
- }
629
-
630
- /**
631
- * @description Destroy the map and it's event listeners
632
- * This should significantly reduce the memory used when creating and destroying gridviz map instances (for example in leaflet-gridviz)
633
- * @memberof App
634
- */
635
- destroy() {
636
- this._destroyed = true
637
-
638
- // clear layers
639
- this.layers = []
640
- this.bgLayers = []
641
-
642
- // cancel scheduled work
643
- if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
644
- this._redrawFrame = null
645
- if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
646
- this._focusFrame = null
647
-
648
- // stop observing the container: the observer would otherwise keep this map alive
649
- if (this._resizeObserver) this._resizeObserver.disconnect()
650
- this._resizeObserver = null
651
-
652
- // remove event listeners, from the canvas they were added to
653
- this.geoCanvas.canvas.removeEventListener('mouseover', this.mouseOverHandler)
654
- this.geoCanvas.canvas.removeEventListener('mousemove', this.mouseMoveHandler)
655
- this.geoCanvas.canvas.removeEventListener('mouseout', this.mouseOutHandler)
656
-
657
- // remove canvas
658
- this.geoCanvas.canvas.remove()
659
-
660
- // remove legend
661
- // only remove legend if gridviz created it
662
- if (!this._legendProvidedByUser) {
663
- this.legend?.remove()
664
- }
665
-
666
- // remove tooltip
667
- this.tooltip.tooltip?.remove()
668
- }
669
- }
1
+ //@ts-check
2
+ 'use strict'
3
+
4
+ // internal imports
5
+ import { isVisibleAt } from './Drawable.js'
6
+ import { GeoCanvas } from './GeoCanvas.js'
7
+ import { Tooltip } from './Tooltip.js'
8
+ import { ZoomButtons } from '../button/ZoomButtons.js'
9
+ import { FullscreenButton } from '../button/FullscreenButton.js'
10
+
11
+ // external imports
12
+ import { select } from 'd3-selection'
13
+
14
+ /**
15
+ * A gridviz application.
16
+ *
17
+ * @module core
18
+ * @author Joseph Davies, Julien Gaffuri
19
+ */
20
+ export class Map {
21
+ /**
22
+ * @param {HTMLDivElement} container
23
+ * @param {object} opts
24
+ */
25
+ constructor(container, opts) {
26
+ opts = opts || {}
27
+
28
+ /**
29
+ * The layers.
30
+ * @type {Array.<import("./Layer.js").Layer>}
31
+ * */
32
+ this.layers = opts.layers || []
33
+
34
+ /**
35
+ * When true (the default), redraw() schedules one redraw for the next animation frame
36
+ * instead of drawing at once, so the many redraw requests of a busy moment (every tile
37
+ * that arrives, every info.json, every setting change) are drawn once. redrawNow() still
38
+ * draws at once. Set it to false for redraw() to be synchronous, as it was before 3.1.0.
39
+ * @type {boolean} */
40
+ this.asyncRedraw = opts.asyncRedraw !== false
41
+ /** @private @type {number|null} */
42
+ this._redrawFrame = null
43
+
44
+ //get container element
45
+ this.container = container || document.getElementById('gridviz')
46
+ if (!this.container) {
47
+ console.error('Cannot find gridviz container element.')
48
+ return
49
+ }
50
+ this.container.classList.add('gridviz-map')
51
+
52
+ //https://css-tricks.com/absolute-positioning-inside-relative-positioning/
53
+ this.container.style.position = 'relative' // container element must have relative positioning
54
+
55
+ //set dimensions
56
+ /** @type {number} */
57
+ this.w = opts.w || this.container.offsetWidth
58
+ /** @type {number} */
59
+ this.h = opts.h || this.container.offsetHeight
60
+
61
+ // Create the main canvas (for rendering to screen)
62
+ /** @type {HTMLCanvasElement} */
63
+ this._canvas = opts.canvas || this.initialiseCanvas()
64
+
65
+ /** Initialize GeoCanvas
66
+ * @type {GeoCanvas}
67
+ * @private */
68
+ this.geoCanvas = new GeoCanvas(this._canvas, opts.x, opts.y, opts.z, opts)
69
+
70
+ this.geoCanvas.redraw = () => {
71
+ this.redraw()
72
+ }
73
+ this.geoCanvas.cancelCurrentRequests = () => {
74
+ // when the zoom level changes, avoid drawing outdated tiles, and ensure that requests are properly aborted when necessary
75
+ for (const layer of this.layers) {
76
+ //multires
77
+ if (layer.dataset?.datasets) {
78
+ for (const dataset of layer.dataset?.datasets) {
79
+ if (dataset?.cancelCurrentRequests) dataset.cancelCurrentRequests()
80
+ }
81
+ }
82
+ //single res
83
+ if (layer.dataset?.cancelCurrentRequests) layer.dataset?.cancelCurrentRequests()
84
+ }
85
+ }
86
+
87
+ // legend div
88
+ this.legend = opts.legendContainer
89
+ ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
90
+ : null
91
+ this._legendProvidedByUser = !!opts.legendContainer
92
+
93
+ /**
94
+ * Called with the legend container (an HTML element) each time the map has drawn and its legends
95
+ * are in the container, for a host that needs to adjust them (fit their width, restyle...).
96
+ * It can also be set after the map is created.
97
+ * @type {function(HTMLElement):void|undefined} */
98
+ this.onLegendUpdate = opts.onLegendUpdate
99
+ if (!this.legend) this.initialiseLegend()
100
+
101
+ //tooltip
102
+
103
+ // set App container as default parent element for tooltip
104
+ if (!opts.tooltip) opts.tooltip = {}
105
+ if (!opts.tooltip.parentElement) opts.tooltip.parentElement = this.container
106
+
107
+ /**
108
+ * @private
109
+ * @type {Tooltip} */
110
+ this.tooltip = new Tooltip(opts.tooltip)
111
+
112
+ /**
113
+ * Set to true so that the tooltip appears for the first layer starting from top where a cell is found.
114
+ * Otherwise, it shows a tooltip only for the first layer from the top with a cellInfoHTML property, even if there is no cell at the mouse position for this layer.
115
+ * @type {boolean} */
116
+ this.digForTooltip = opts.digForTooltip || false
117
+
118
+ // add event listeners to the canvas. Mouse moves are handled at most once per
119
+ // animation frame (the latest one): finding the cell and redrawing the highlight
120
+ // for every intermediate event only delays the one that's shown.
121
+ /** @private */
122
+ this._focusFrame = null
123
+ /** @private */
124
+ this._focusEvent = null
125
+ const scheduleFocus = (e) => {
126
+ this._focusEvent = e
127
+ if (this._focusFrame) return
128
+ this._focusFrame = requestAnimationFrame(() => {
129
+ this._focusFrame = null
130
+ const ev = this._focusEvent
131
+ this._focusEvent = null
132
+ if (ev) this.focusCell(ev)
133
+ })
134
+ }
135
+ this.mouseOverHandler = scheduleFocus
136
+ this.mouseMoveHandler = scheduleFocus
137
+ this.mouseOutHandler = (e) => {
138
+ if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
139
+ this._focusFrame = null
140
+ this._focusEvent = null
141
+ this.tooltip.hide()
142
+ }
143
+ this.geoCanvas.canvas.addEventListener('mouseover', this.mouseOverHandler)
144
+ this.geoCanvas.canvas.addEventListener('mousemove', this.mouseMoveHandler)
145
+ this.geoCanvas.canvas.addEventListener('mouseout', this.mouseOutHandler)
146
+
147
+ // listen for resize events on the App's container and handle them
148
+ this.defineResizeObserver()
149
+
150
+ // add extra logic to onZoomStartFun
151
+ this.geoCanvas.onZoomStartFun = (e) => {
152
+ if (opts.onZoomStartFun) opts.onZoomStartFun(e)
153
+ this.tooltip.hide()
154
+ }
155
+
156
+ //for mouse over
157
+ /**
158
+ * @private
159
+ * @type {HTMLCanvasElement|null} */
160
+ this.canvasSave = null
161
+
162
+ this.selectionRectangleColor = opts.selectionRectangleColor || '#FF6347'
163
+ this.selectionRectangleWidthPix = opts.selectionRectangleWidthPix || (() => 4) //(r,z) => {}
164
+
165
+ // transparent background (e.g. leaflet) 'red painting' fix
166
+ this.transparentBackground = opts.transparentBackground
167
+
168
+ //set default globalCompositeOperation
169
+ this.defaultGlobalCompositeOperation =
170
+ opts.defaultGlobalCompositeOperation || this.geoCanvas.ctx.globalCompositeOperation
171
+ }
172
+
173
+ /**
174
+ * @protected
175
+ * @returns {HTMLCanvasElement}
176
+ */
177
+ initialiseCanvas() {
178
+ const canvas = document.createElement('canvas')
179
+ canvas.classList.add('gridviz-canvas')
180
+ canvas.setAttribute('width', '' + this.w)
181
+ canvas.setAttribute('height', '' + this.h)
182
+ this.container.appendChild(canvas)
183
+ return canvas
184
+ }
185
+
186
+ initialiseLegend() {
187
+ this.legend = select(this.container)
188
+ .append('div') // Create a new container
189
+ .attr('id', 'gridviz-legend')
190
+ .classed('gridviz-legends', true)
191
+ .style('position', 'absolute')
192
+ .style('width', 'auto')
193
+ .style('height', 'auto')
194
+ .style('background', '#FFFFFF')
195
+ //.style("padding", this.padding)
196
+ .style('border', '0px')
197
+ //.style('border-radius', '5px')
198
+ .style('box-shadow', '3px 3px 3px grey, -3px -3px 3px #ddd')
199
+ .style('font-family', 'Helvetica, Arial, sans-serif')
200
+ .style('bottom', '15px')
201
+ .style('right', '15px')
202
+ //hide
203
+ //.style("visibility", "hidden")
204
+ }
205
+
206
+ /**
207
+ * Set/get layer stack.
208
+ *
209
+ * @param {undefined|import("./Layer.js").Layer|import("./Layer.js").Layer[]} layers
210
+ * @returns { this | import("./Layer.js").Layer[] }
211
+ */
212
+ layers_(layers) {
213
+ if (arguments.length === 0) return this.layers
214
+ if (arguments.length === 1)
215
+ if (Array.isArray(layers)) this.layers = layers
216
+ else this.layers = [layers]
217
+ else this.layers = arguments
218
+ return this
219
+ }
220
+
221
+ /**
222
+ * Redraws the map: at once, or, with the asyncRedraw option, once at the next
223
+ * animation frame however many times it's called before then.
224
+ * @returns {this} */
225
+ redraw() {
226
+ if (this.asyncRedraw) {
227
+ if (!this._redrawFrame && !this._destroyed)
228
+ this._redrawFrame = requestAnimationFrame(() => {
229
+ this._redrawFrame = null
230
+ this.redrawNow()
231
+ })
232
+ return this
233
+ }
234
+ return this.redrawNow()
235
+ }
236
+
237
+ /**
238
+ * Redraws the map at once, whatever the asyncRedraw option (and cancels a scheduled redraw).
239
+ * @returns {this} */
240
+ redrawNow() {
241
+ if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
242
+ this._redrawFrame = null
243
+ // a destroyed map can still be asked to redraw by a dataset it shared (see destroy)
244
+ if (this._destroyed) return this
245
+
246
+ //remove legend elements: the legends themselves only, not what is inside them
247
+ //(a legend keeps its content while one of its swatches is hovered, see Style.updateLegends)
248
+ //except the one being hovered, which would lose its mouseleave (see Legend.highlight)
249
+ if (this.legend) {
250
+ this.legend.selectAll(':scope > *:not([data-gridviz-hovered])').remove()
251
+ // the legends of this draw are placed one after the other (see GridLayer.addLegends)
252
+ this.legend.node()._gridvizLegendCursor = null
253
+ }
254
+
255
+ //clear
256
+ this.geoCanvas.initCanvasTransform()
257
+ this.geoCanvas.clear(this.geoCanvas.backgroundColor)
258
+
259
+ const z = this.geoCanvas.view.z
260
+ this.updateExtentGeo()
261
+
262
+ const ctx = this.geoCanvas.offscreenCtx
263
+
264
+ //go through the layers
265
+ for (const layer of this.layers) {
266
+ //check if layer is visible
267
+ if (!isVisibleAt(layer, z)) continue
268
+
269
+ //set layer alpha and blend mode
270
+ if (layer.alpha || layer.blendOperation) {
271
+ ctx.save()
272
+ if (layer.alpha) ctx.globalAlpha = layer.alpha(z)
273
+ if (layer.blendOperation) ctx.globalCompositeOperation = layer.blendOperation(z)
274
+ }
275
+
276
+ //set affin transform to draw with geographical coordinates
277
+ this.geoCanvas.setCanvasTransform()
278
+
279
+ //draw layer
280
+ layer.draw(this.geoCanvas, this.legend)
281
+
282
+ //draw layer filter
283
+ if (layer.filterColor) layer.drawFilter(this.geoCanvas)
284
+
285
+ //restore default alpha and blend operation
286
+ if (layer.alpha || layer.blendOperation) ctx.restore()
287
+ }
288
+
289
+ // one drawImage call: draw the offscreen canvas to the main canvas
290
+ this.geoCanvas.initCanvasTransform()
291
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
292
+
293
+ this.canvasSave = null
294
+ // the canvas holds no focus rectangle any more
295
+ this._focusedCell = undefined
296
+
297
+ // the legends are in the container now
298
+ if (this.onLegendUpdate && this.legend) this.onLegendUpdate(this.legend.node())
299
+
300
+ return this
301
+ }
302
+
303
+ /**
304
+ * @param {number} marginPx
305
+ * @returns {import('./GeoCanvas.js').Envelope}
306
+ * @public
307
+ */
308
+ updateExtentGeo(marginPx = 20) {
309
+ return this.geoCanvas.updateExtentGeo(marginPx)
310
+ }
311
+
312
+ /** @param {MouseEvent} e */
313
+ focusCell(e) {
314
+ // Don’t process hover events during active zoom
315
+ if (this.geoCanvas._isZooming) return;
316
+
317
+ //compute mouse geo position
318
+ const mousePositionGeo = {
319
+ x: this.geoCanvas.pixToGeoX(e.offsetX + this.tooltip.xMouseOffset),
320
+ y: this.geoCanvas.pixToGeoY(e.offsetY + this.tooltip.yMouseOffset),
321
+ }
322
+ /** @type {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined} */
323
+ const focus = this.getCellFocusInfo(mousePositionGeo)
324
+
325
+ // Same cell as the one already highlighted (or still no cell): the canvas is right
326
+ // as it is, only the tooltip follows the mouse. Saves a full-canvas copy per move.
327
+ const focusedCell = focus ? focus.cell : null
328
+ if (focusedCell === this._focusedCell) {
329
+ if (focus) {
330
+ this.tooltip.setPosition(e)
331
+ this.tooltip.show()
332
+ } else this.tooltip.hide()
333
+ return
334
+ }
335
+ this._focusedCell = focusedCell
336
+
337
+ // Transparent background (Leaflet): redraw base, then draw focus rect directly on the visible canvas
338
+ if (this.transparentBackground) {
339
+ // Always restore the base (offscreen) into the visible canvas
340
+ this.geoCanvas.initCanvasTransform();
341
+ this.geoCanvas.ctx.clearRect(0, 0, this.w, this.h);
342
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0);
343
+
344
+ if (focus) {
345
+ this.tooltip.html(focus.html);
346
+ this.tooltip.setPosition(e);
347
+ this.tooltip.show();
348
+
349
+ const rectWPix = this.selectionRectangleWidthPix
350
+ ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
351
+ : 4;
352
+
353
+ const ctx = this.geoCanvas.ctx; // draw directly on visible canvas
354
+ ctx.save();
355
+ ctx.strokeStyle = this.selectionRectangleColor;
356
+ ctx.lineWidth = rectWPix;
357
+ ctx.beginPath();
358
+ // compute in pixel space (no geo transform on ctx)
359
+ const xPix = this.geoCanvas.geoToPixX(focus.cell.x);
360
+ const yPix = this.geoCanvas.geoToPixY(focus.cell.y);
361
+ const wPix = focus.resolution / this.geoCanvas.view.z;
362
+ const hPix = -wPix; // y axis inverted in geoToPix
363
+ ctx.rect(
364
+ xPix - rectWPix / 2,
365
+ yPix + rectWPix / 2,
366
+ wPix + rectWPix,
367
+ hPix - rectWPix
368
+ );
369
+ ctx.stroke();
370
+ ctx.restore();
371
+ } else {
372
+ this.tooltip.hide();
373
+ }
374
+ return; // handled
375
+ }
376
+
377
+ if (focus) {
378
+ this.geoCanvas.canvas.style.cursor = 'pointer';
379
+ this.tooltip.html(focus.html)
380
+ this.tooltip.setPosition(e)
381
+ this.tooltip.show()
382
+
383
+ //show cell position as a rectangle
384
+ if (!this.canvasSave) {
385
+ this.canvasSave = document.createElement('canvas')
386
+ this.canvasSave.setAttribute('width', '' + this.w)
387
+ this.canvasSave.setAttribute('height', '' + this.h)
388
+ this.canvasSave.getContext('2d')?.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
389
+ } else {
390
+ this.geoCanvas.offscreenCtx.drawImage(this.canvasSave, 0, 0)
391
+ }
392
+
393
+ //draw image saved + draw rectangle
394
+ const rectWPix = this.selectionRectangleWidthPix
395
+ ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
396
+ : 4
397
+ this.geoCanvas.initCanvasTransform()
398
+ const ctx = this.geoCanvas.offscreenCtx
399
+ ctx.strokeStyle = this.selectionRectangleColor
400
+ ctx.lineWidth = rectWPix
401
+ ctx.beginPath()
402
+
403
+ ctx.rect(
404
+ this.geoCanvas.geoToPixX(focus.cell.x) - rectWPix / 2,
405
+ this.geoCanvas.geoToPixY(focus.cell.y) + rectWPix / 2,
406
+ focus.resolution / this.geoCanvas.view.z + rectWPix,
407
+ -focus.resolution / this.geoCanvas.view.z - rectWPix
408
+ )
409
+ ctx.stroke()
410
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
411
+ } else {
412
+ this.geoCanvas.canvas.style.cursor = 'default';
413
+ this.tooltip.hide()
414
+ if (this.canvasSave) this.geoCanvas.ctx.drawImage(this.canvasSave, 0, 0)
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Return the cell HTML info at a given geo position.
420
+ * This is usefull for user interactions, to show this info where the user clicks for example.
421
+ *
422
+ * @param {{x:number,y:number}} posGeo
423
+ * @returns {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined}
424
+ * @protected
425
+ */
426
+ getCellFocusInfo(posGeo) {
427
+
428
+ // go through the layers, starting from top one
429
+ const z = this.geoCanvas.view.z
430
+ for (let i = this.layers.length - 1; i >= 0; i--) {
431
+
432
+ /** @type {import("./Layer.js").Layer} */
433
+ const layer = this.layers[i]
434
+
435
+ if (!isVisibleAt(layer, z)) continue
436
+ if (layer.cellInfoHTML === 'none') continue
437
+ if (!layer.cellInfoHTML) continue
438
+ if (!layer.getDataset) continue
439
+
440
+ // get dataset
441
+ const dsc = layer.getDataset(z)
442
+ if (!dsc) continue
443
+
444
+ //get cell at mouse position
445
+ /** @type {import('./Dataset.js').Cell|undefined} */
446
+ const cell = dsc.getCellFromPosition(posGeo, dsc.getViewCache())
447
+
448
+ // no cell found at theis position for this layer
449
+ if (!cell)
450
+ // dig to the next layer below
451
+ if (this.digForTooltip) continue
452
+ // no tooltip to show
453
+ else return undefined
454
+
455
+ //rare case for a dataset with mixed resolutions
456
+ if (dsc.mixedResolution) {
457
+ const r = +dsc.mixedResolution(cell)
458
+ const html = layer.cellInfoHTML(cell, r)
459
+ if (!html) return undefined
460
+ return { cell: cell, html: html, resolution: r }
461
+ }
462
+
463
+ const html = layer.cellInfoHTML(cell, dsc.getResolution())
464
+ if (!html) return undefined
465
+ return { cell: cell, html: html, resolution: dsc.getResolution() }
466
+ }
467
+ }
468
+
469
+ /**
470
+ * @param {number} x
471
+ * @param {number} y
472
+ * @param {number|undefined} z
473
+ */
474
+ setView(x, y, z = undefined) {
475
+ this.geoCanvas.view.x = x
476
+ this.geoCanvas.view.y = y
477
+ if (z != undefined) this.geoCanvas.view.z = z
478
+ return this
479
+ }
480
+
481
+ /** @returns {import('./GeoCanvas.js').View} */
482
+ getView() {
483
+ return this.geoCanvas.view
484
+ }
485
+
486
+ /** @returns {number} */
487
+ getZoom() {
488
+ return this.geoCanvas.view.z
489
+ }
490
+ /** @param {number} z @returns {this} */
491
+ setZoom(z) {
492
+ this.geoCanvas.view.z = z
493
+ return this
494
+ }
495
+
496
+ /** @returns {Array.<number|undefined>} */
497
+ getCenterExtent() {
498
+ return this.geoCanvas.getCenterExtent()
499
+ }
500
+ /** @param {Array.<number>} val @returns {this} */
501
+ setCenterExtent(val) {
502
+ this.geoCanvas.setCenterExtent(val)
503
+ return this
504
+ }
505
+
506
+ /** @returns {Array.<number>} */
507
+ getZoomExtent() {
508
+ return this.geoCanvas.getZoomExtent()
509
+ }
510
+ /** @param {Array.<number>} val @returns {this} */
511
+ setZoomExtent(val) {
512
+ this.geoCanvas.setZoomExtent(val)
513
+ return this
514
+ }
515
+
516
+ /** @returns {string} */
517
+ getBackgroundColor() {
518
+ return this.geoCanvas.backgroundColor
519
+ }
520
+ /** @param {string} val @returns {this} */
521
+ setBackgroundColor(val) {
522
+ this.geoCanvas.backgroundColor = val
523
+ return this
524
+ }
525
+
526
+ /**
527
+ * Adds a set of zoom buttons to the map
528
+ *
529
+ * @param {object} opts
530
+ * @returns {this}
531
+ */
532
+ addZoomButtons(opts) {
533
+ // * opts.id
534
+ // * opts.onZoom - custom event handler function
535
+ // * opts.x
536
+ // * opts.y
537
+ // * opts.delta - zoom delta applied on each click
538
+
539
+ this.zoomButtons = new ZoomButtons({
540
+ map: this,
541
+ id: opts?.id || 'gridviz-zoom-buttons-' + this.container.id,
542
+ class: opts?.class,
543
+ x: opts?.x,
544
+ y: opts?.y,
545
+ onZoom: opts?.onZoom,
546
+ delta: opts?.delta || 0.2,
547
+ })
548
+
549
+ return this
550
+ }
551
+
552
+ /**
553
+ * Adds a fullscreen toggle button to the app
554
+ *
555
+ * @param {object} opts
556
+ * @returns {this}
557
+ */
558
+ addFullscreenButton(opts) {
559
+ // * opts.map - the gridviz map
560
+ // * opts.id
561
+ // * opts.x
562
+ // * opts.y
563
+
564
+ this.fullscreenButton = new FullscreenButton({
565
+ map: this,
566
+ id: opts?.id || 'gridviz-fullscreen-button',
567
+ class: opts?.class,
568
+ x: opts?.x,
569
+ y: opts?.y,
570
+ })
571
+
572
+ return this
573
+ }
574
+
575
+ /** @returns {this} */
576
+ setViewFromURL() {
577
+ this.geoCanvas.setViewFromURL()
578
+ return this
579
+ }
580
+
581
+ /**
582
+ * @description Add a resize event observer to the Apps container and update the canvas accordingly
583
+ * @memberof App
584
+ */
585
+ defineResizeObserver() {
586
+ // Track whether the observer is currently processing a resize event
587
+ let resizePending = false
588
+
589
+ const resizeObserver = new ResizeObserver((entries) => {
590
+ if (!Array.isArray(entries) || !entries.length) return
591
+
592
+ let container = this.container
593
+
594
+ // Ensure the container has valid dimensions
595
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
596
+ if (!resizePending) {
597
+ resizePending = true // Prevent overlapping resize triggers
598
+
599
+ window.requestAnimationFrame(() => {
600
+ resizePending = false // Reset the flag after processing
601
+
602
+ // Check for size changes
603
+ if (this.h !== container.clientHeight || this.w !== container.clientWidth) {
604
+ this.h = container.clientHeight
605
+ this.w = container.clientWidth
606
+
607
+ // Update geoCanvas sizes
608
+ this.geoCanvas.h = this.h
609
+ this.geoCanvas.w = this.w
610
+ this.geoCanvas.canvas.setAttribute('width', String(this.w))
611
+ this.geoCanvas.canvas.setAttribute('height', String(this.h))
612
+ this.geoCanvas.offscreenCanvas.setAttribute('width', String(this.w))
613
+ this.geoCanvas.offscreenCanvas.setAttribute('height', String(this.h))
614
+
615
+ this.redraw()
616
+
617
+ // Optionally reposition UI elements
618
+ // if (this.zoomButtons) this.zoomButtons.node.style.left = this.w - 50 + 'px';
619
+ // if (this.fullscreenButton) this.fullscreenButton.node.style.left = this.w - 50 + 'px';
620
+ }
621
+ })
622
+ }
623
+ }
624
+ })
625
+
626
+ resizeObserver.observe(this.container)
627
+ /** @private */
628
+ this._resizeObserver = resizeObserver
629
+ }
630
+
631
+ /**
632
+ * @description Destroy the map and it's event listeners
633
+ * This should significantly reduce the memory used when creating and destroying gridviz map instances (for example in leaflet-gridviz)
634
+ * @memberof App
635
+ */
636
+ destroy() {
637
+ this._destroyed = true
638
+
639
+ // clear layers
640
+ this.layers = []
641
+ this.bgLayers = []
642
+
643
+ // cancel scheduled work
644
+ if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
645
+ this._redrawFrame = null
646
+ if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
647
+ this._focusFrame = null
648
+
649
+ // stop observing the container: the observer would otherwise keep this map alive
650
+ if (this._resizeObserver) this._resizeObserver.disconnect()
651
+ this._resizeObserver = null
652
+
653
+ // remove event listeners, from the canvas they were added to
654
+ this.geoCanvas.canvas.removeEventListener('mouseover', this.mouseOverHandler)
655
+ this.geoCanvas.canvas.removeEventListener('mousemove', this.mouseMoveHandler)
656
+ this.geoCanvas.canvas.removeEventListener('mouseout', this.mouseOutHandler)
657
+
658
+ // remove canvas
659
+ this.geoCanvas.canvas.remove()
660
+
661
+ // remove legend
662
+ // only remove legend if gridviz created it
663
+ if (!this._legendProvidedByUser) {
664
+ this.legend?.remove()
665
+ }
666
+
667
+ // remove tooltip
668
+ this.tooltip.tooltip?.remove()
669
+ }
670
+ }