gridviz 3.1.0 → 3.1.1

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