gridviz 3.0.22 → 3.0.24

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 (66) hide show
  1. package/README.md +128 -128
  2. package/dist/gridviz.js +8618 -8579
  3. package/dist/gridviz.min.js +1 -1
  4. package/package.json +74 -74
  5. package/src/button/Button.js +67 -67
  6. package/src/button/FullscreenButton.js +121 -121
  7. package/src/button/ZoomButtons.js +103 -101
  8. package/src/core/Dataset.js +154 -154
  9. package/src/core/Drawable.js +78 -78
  10. package/src/core/GeoCanvas.js +437 -430
  11. package/src/core/Layer.js +23 -23
  12. package/src/core/Legend.js +108 -108
  13. package/src/core/Map.js +542 -516
  14. package/src/core/MultiResolutionDataset.js +93 -93
  15. package/src/core/Style.js +98 -98
  16. package/src/core/Tooltip.js +182 -182
  17. package/src/dataset/COGeo.js +25 -25
  18. package/src/dataset/CSVGrid.js +112 -112
  19. package/src/dataset/GeoTIFF.js +122 -122
  20. package/src/dataset/JSGrid.js +55 -55
  21. package/src/dataset/TiledGrid.js +302 -302
  22. package/src/dataset/worker.js +18 -18
  23. package/src/index.js +77 -77
  24. package/src/layer/BackgroundLayer.js +173 -173
  25. package/src/layer/BackgroundLayerImage.js +81 -81
  26. package/src/layer/BackgroundLayerWMS.js +104 -104
  27. package/src/layer/GeoJSONLayer.js +214 -214
  28. package/src/layer/GridLayer.js +148 -148
  29. package/src/layer/LabelLayer.js +194 -194
  30. package/src/legend/ColorCategoryLegend.js +109 -109
  31. package/src/legend/ColorDiscreteLegend.js +100 -100
  32. package/src/legend/ColorLegend.js +115 -115
  33. package/src/legend/OrientationLegend.js +89 -89
  34. package/src/legend/SizeLegend.js +224 -224
  35. package/src/legend/TernaryLegend.js +302 -302
  36. package/src/style/CompositionStyle.js +291 -291
  37. package/src/style/ContourStyle.js +21 -21
  38. package/src/style/DotDensityStyle.js +133 -133
  39. package/src/style/ImageStyle.js +100 -100
  40. package/src/style/IsoFenceStyle.js +217 -217
  41. package/src/style/JoyPlotStyle.js +127 -127
  42. package/src/style/LegoStyle.js +110 -110
  43. package/src/style/MosaicStyle.js +99 -99
  44. package/src/style/NinjaStarStyle.js +102 -102
  45. package/src/style/PillarStyle.js +207 -207
  46. package/src/style/SegmentStyle.js +105 -105
  47. package/src/style/ShapeColorSizeStyle.js +147 -147
  48. package/src/style/SideCategoryStyle.js +112 -112
  49. package/src/style/SideStyle.js +212 -212
  50. package/src/style/SquareColorCategoryWebGLStyle.js +107 -107
  51. package/src/style/SquareColorWebGLStyle.js +126 -126
  52. package/src/style/StrokeStyle.js +135 -135
  53. package/src/style/TanakaStyle.js +69 -69
  54. package/src/style/TextStyle.js +110 -110
  55. package/src/style/TimeSeriesStyle.js +222 -222
  56. package/src/utils/WebGLRectangleColoring.js +106 -106
  57. package/src/utils/WebGLSquareColoring.js +118 -118
  58. package/src/utils/WebGLSquareColoringAdvanced.js +230 -230
  59. package/src/utils/WebGLSquareColoringCatAdvanced.js +133 -133
  60. package/src/utils/WebGLSquareColoringKS.js +153 -153
  61. package/src/utils/scale.js +218 -218
  62. package/src/utils/stretching.js +120 -120
  63. package/src/utils/ternary.js +208 -208
  64. package/src/utils/utils.js +58 -58
  65. package/src/utils/webGLUtils.js +72 -72
  66. package/src/utils/zoomUtils.js +142 -142
package/src/core/Map.js CHANGED
@@ -1,516 +1,542 @@
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
- //get container element
34
- this.container = container || document.getElementById('gridviz')
35
- if (!this.container) {
36
- console.error('Cannot find gridviz container element.')
37
- return
38
- }
39
-
40
- //https://css-tricks.com/absolute-positioning-inside-relative-positioning/
41
- this.container.style.position = 'relative' // container element must have relative positioning
42
-
43
- //set dimensions
44
- /** @type {number} */
45
- this.w = opts.w || this.container.offsetWidth
46
- /** @type {number} */
47
- this.h = opts.h || this.container.offsetHeight
48
-
49
- // Create the main canvas (for rendering to screen)
50
- /** @type {HTMLCanvasElement} */
51
- this._canvas = opts.canvas || this.initialiseCanvas()
52
-
53
- /** Initialize GeoCanvas
54
- * @type {GeoCanvas}
55
- * @private */
56
- this.geoCanvas = new GeoCanvas(this._canvas, opts.x, opts.y, opts.z, opts)
57
-
58
- this.geoCanvas.redraw = () => {
59
- this.redraw()
60
- }
61
- this.geoCanvas.cancelCurrentRequests = () => {
62
- // when the zoom level changes, avoid drawing outdated tiles, and ensure that requests are properly aborted when necessary
63
- for (const layer of this.layers) {
64
- //multires
65
- if (layer.dataset?.datasets) {
66
- for (const dataset of layer.dataset?.datasets) {
67
- if (dataset?.cancelCurrentRequests) dataset.cancelCurrentRequests()
68
- }
69
- }
70
- //single res
71
- if (layer.dataset?.cancelCurrentRequests) layer.dataset?.cancelCurrentRequests()
72
- }
73
- }
74
-
75
- // legend div
76
- this.legend = opts.legendContainer
77
- ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
78
- : null
79
- if (!this.legend) this.initialiseLegend()
80
-
81
- //tooltip
82
-
83
- // set App container as default parent element for tooltip
84
- if (!opts.tooltip) opts.tooltip = {}
85
- if (!opts.tooltip.parentElement) opts.tooltip.parentElement = this.container
86
-
87
- /**
88
- * @private
89
- * @type {Tooltip} */
90
- this.tooltip = new Tooltip(opts.tooltip)
91
-
92
- // add event listeners to container
93
- this.mouseOverHandler = (e) => this.focusCell(e)
94
- this.mouseMoveHandler = (e) => this.focusCell(e)
95
- this.mouseOutHandler = (e) => this.tooltip.hide()
96
- this.geoCanvas.canvas.addEventListener('mouseover', this.mouseOverHandler)
97
- this.geoCanvas.canvas.addEventListener('mousemove', this.mouseMoveHandler)
98
- this.geoCanvas.canvas.addEventListener('mouseout', this.mouseOutHandler)
99
-
100
- // listen for resize events on the App's container and handle them
101
- this.defineResizeObserver()
102
-
103
- // add extra logic to onZoomStartFun
104
- this.geoCanvas.onZoomStartFun = (e) => {
105
- if (opts.onZoomStartFun) opts.onZoomStartFun(e)
106
- this.tooltip.hide()
107
- }
108
-
109
- //for mouse over
110
- /**
111
- * @private
112
- * @type {HTMLCanvasElement|null} */
113
- this.canvasSave = null
114
-
115
- this.selectionRectangleColor = opts.selectionRectangleColor || '#FF6347'
116
- this.selectionRectangleWidthPix = opts.selectionRectangleWidthPix || (() => 4) //(r,z) => {}
117
-
118
- // transparent background (e.g. leaflet) 'red painting' fix
119
- this.transparentBackground = opts.transparentBackground
120
-
121
- //set default globalCompositeOperation
122
- this.defaultGlobalCompositeOperation =
123
- opts.defaultGlobalCompositeOperation || this.geoCanvas.ctx.globalCompositeOperation
124
- }
125
-
126
- /**
127
- * @protected
128
- * @returns {HTMLCanvasElement}
129
- */
130
- initialiseCanvas() {
131
- const canvas = document.createElement('canvas')
132
- canvas.setAttribute('width', '' + this.w)
133
- canvas.setAttribute('height', '' + this.h)
134
- this.container.appendChild(canvas)
135
- return canvas
136
- }
137
-
138
- initialiseLegend() {
139
- this.legend = select(this.container)
140
- .append('div') // Create a new container
141
- .attr('id', 'gridviz-legend')
142
- .style('position', 'absolute')
143
- .style('width', 'auto')
144
- .style('height', 'auto')
145
- .style('background', '#FFFFFF')
146
- //.style("padding", this.padding)
147
- .style('border', '0px')
148
- //.style('border-radius', '5px')
149
- .style('box-shadow', '3px 3px 3px grey, -3px -3px 3px #ddd')
150
- .style('font-family', 'Helvetica, Arial, sans-serif')
151
- .style('bottom', '15px')
152
- .style('right', '15px')
153
- //hide
154
- //.style("visibility", "hidden")
155
- }
156
-
157
- /**
158
- * Set/get layer stack.
159
- *
160
- * @param {undefined|import("./Layer.js").Layer|import("./Layer.js").Layer[]} layers
161
- * @returns { this | import("./Layer.js").Layer[] }
162
- */
163
- layers_(layers) {
164
- if (arguments.length === 0) return this.layers
165
- if (arguments.length === 1)
166
- if (Array.isArray(layers)) this.layers = layers
167
- else this.layers = [layers]
168
- else this.layers = arguments
169
- return this
170
- }
171
-
172
- /** @returns {this} */
173
- redraw() {
174
- //remove legend elements
175
- if (this.legend) this.legend.selectAll('*').remove()
176
-
177
- //clear
178
- this.geoCanvas.initCanvasTransform()
179
- this.geoCanvas.clear(this.geoCanvas.backgroundColor)
180
-
181
- const z = this.geoCanvas.view.z
182
- this.updateExtentGeo()
183
-
184
- //go through the layers
185
- for (const layer of this.layers) {
186
- //check if layer is visible
187
- if (layer.visible && !layer.visible(z)) continue
188
-
189
- //set layer alpha and blend mode
190
- this.geoCanvas.offscreenCtx.globalAlpha = layer.alpha ? layer.alpha(z) : 1.0
191
- if (layer.blendOperation) this.geoCanvas.offscreenCtx.globalCompositeOperation = layer.blendOperation(z)
192
-
193
- //set affin transform to draw with geographical coordinates
194
- this.geoCanvas.setCanvasTransform()
195
-
196
- //draw layer
197
- layer.draw(this.geoCanvas, this.legend)
198
-
199
- //draw layer filter
200
- if (layer.filterColor) layer.drawFilter(this.geoCanvas)
201
-
202
- //restore default alpha and blend operation
203
- this.geoCanvas.offscreenCtx.globalAlpha = 1.0
204
- this.geoCanvas.offscreenCtx.globalCompositeOperation = this.defaultGlobalCompositeOperation
205
- }
206
-
207
- // one drawImage call: draw the offscreen canvas to the main canvas
208
- this.geoCanvas.initCanvasTransform()
209
- this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
210
-
211
- this.canvasSave = null
212
-
213
- return this
214
- }
215
-
216
- /**
217
- * @param {number} marginPx
218
- * @returns {import('./GeoCanvas.js').Envelope}
219
- * @public
220
- */
221
- updateExtentGeo(marginPx = 20) {
222
- return this.geoCanvas.updateExtentGeo(marginPx)
223
- }
224
-
225
- /** @param {MouseEvent} e */
226
- focusCell(e) {
227
- //compute mouse geo position
228
- const mousePositionGeo = {
229
- x: this.geoCanvas.pixToGeoX(e.offsetX + this.tooltip.xMouseOffset),
230
- y: this.geoCanvas.pixToGeoY(e.offsetY + this.tooltip.yMouseOffset),
231
- }
232
- /** @type {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined} */
233
- const focus = this.getCellFocusInfo(mousePositionGeo)
234
-
235
- // transparent background (e.g. leaflet) 'red painting' fix
236
- if (this.transparentBackground) {
237
- if (focus) {
238
- this.tooltip.html(focus.html)
239
- this.tooltip.setPosition(e)
240
- this.tooltip.show()
241
- } else {
242
- this.tooltip.hide()
243
- }
244
- this.canvasSave = document.createElement('canvas')
245
- this.canvasSave.setAttribute('width', '' + this.w)
246
- this.canvasSave.setAttribute('height', '' + this.h)
247
- this.canvasSave.getContext('2d')?.drawImage(this.geoCanvas.canvas, 0, 0)
248
- this.geoCanvas.initCanvasTransform()
249
- return
250
- }
251
-
252
- if (focus) {
253
- this.tooltip.html(focus.html)
254
- this.tooltip.setPosition(e)
255
- this.tooltip.show()
256
-
257
- //show cell position as a rectangle
258
- if (!this.canvasSave) {
259
- this.canvasSave = document.createElement('canvas')
260
- this.canvasSave.setAttribute('width', '' + this.w)
261
- this.canvasSave.setAttribute('height', '' + this.h)
262
- this.canvasSave.getContext('2d')?.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
263
- } else {
264
- this.geoCanvas.offscreenCtx.drawImage(this.canvasSave, 0, 0)
265
- }
266
-
267
- //draw image saved + draw rectangle
268
- const rectWPix = this.selectionRectangleWidthPix
269
- ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
270
- : 4
271
- this.geoCanvas.initCanvasTransform()
272
- const ctx = this.geoCanvas.offscreenCtx
273
- ctx.strokeStyle = this.selectionRectangleColor
274
- ctx.lineWidth = rectWPix
275
- ctx.beginPath()
276
-
277
- ctx.rect(
278
- this.geoCanvas.geoToPixX(focus.cell.x) - rectWPix / 2,
279
- this.geoCanvas.geoToPixY(focus.cell.y) + rectWPix / 2,
280
- focus.resolution / this.geoCanvas.view.z + rectWPix,
281
- -focus.resolution / this.geoCanvas.view.z - rectWPix
282
- )
283
- ctx.stroke()
284
- this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
285
- } else {
286
- this.tooltip.hide()
287
- if (this.canvasSave) this.geoCanvas.ctx.drawImage(this.canvasSave, 0, 0)
288
- }
289
- }
290
-
291
- /**
292
- * Return the cell HTML info at a given geo position.
293
- * This is usefull for user interactions, to show this info where the user clicks for example.
294
- *
295
- * @param {{x:number,y:number}} posGeo
296
- * @returns {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined}
297
- * @protected
298
- */
299
- getCellFocusInfo(posGeo) {
300
- //go through the layers, starting from top
301
- const z = this.geoCanvas.view.z
302
- for (let i = this.layers.length - 1; i >= 0; i--) {
303
- /** @type {import("./Layer.js").Layer} */
304
- const layer = this.layers[i]
305
- if (layer.visible && !layer.visible(z)) continue
306
- if (layer.cellInfoHTML === 'none') continue // this is necessary in order to not show tooltips for layers 'on top' (e.g. population circles on top of squares)
307
- if (!layer.cellInfoHTML) continue
308
- if (!layer.getDataset) continue
309
- const dsc = layer.getDataset(z)
310
- if (!dsc) continue
311
-
312
- //get cell at mouse position
313
- /** @type {import('./Dataset.js').Cell|undefined} */
314
- const cell = dsc.getCellFromPosition(posGeo, dsc.getViewCache())
315
- //console.log(cell, dsc.resolution)
316
- if (!cell) return undefined
317
-
318
- //rare case for a dataset with mixed resolutions
319
- if (dsc.mixedResolution) {
320
- const r = +dsc.mixedResolution(cell)
321
- const html = layer.cellInfoHTML(cell, r)
322
- if (!html) return undefined
323
- return { cell: cell, html: html, resolution: r }
324
- }
325
-
326
- const html = layer.cellInfoHTML(cell, dsc.getResolution())
327
- if (!html) return undefined
328
- return { cell: cell, html: html, resolution: dsc.getResolution() }
329
- }
330
- }
331
-
332
- /**
333
- * @param {number} x
334
- * @param {number} y
335
- * @param {number|undefined} z
336
- */
337
- setView(x, y, z = undefined) {
338
- this.geoCanvas.view.x = x
339
- this.geoCanvas.view.y = y
340
- if (z != undefined) this.geoCanvas.view.z = z
341
- return this
342
- }
343
-
344
- /** @returns {import('./GeoCanvas.js').View} */
345
- getView() {
346
- return this.geoCanvas.view
347
- }
348
-
349
- /** @returns {number} */
350
- getZoom() {
351
- return this.geoCanvas.view.z
352
- }
353
- /** @param {number} z @returns {this} */
354
- setZoom(z) {
355
- this.geoCanvas.view.z = z
356
- return this
357
- }
358
-
359
- /** @returns {Array.<number|undefined>} */
360
- getCenterExtent() {
361
- return this.geoCanvas.getCenterExtent()
362
- }
363
- /** @param {Array.<number>} val @returns {this} */
364
- setCenterExtent(val) {
365
- this.geoCanvas.setCenterExtent(val)
366
- return this
367
- }
368
-
369
- /** @returns {Array.<number>} */
370
- getZoomExtent() {
371
- return this.geoCanvas.getZoomExtent()
372
- }
373
- /** @param {Array.<number>} val @returns {this} */
374
- setZoomExtent(val) {
375
- this.geoCanvas.setZoomExtent(val)
376
- return this
377
- }
378
-
379
- /** @returns {string} */
380
- getBackgroundColor() {
381
- return this.geoCanvas.backgroundColor
382
- }
383
- /** @param {string} val @returns {this} */
384
- setBackgroundColor(val) {
385
- this.geoCanvas.backgroundColor = val
386
- return this
387
- }
388
-
389
- /**
390
- * Adds a set of zoom buttons to the map
391
- *
392
- * @param {object} opts
393
- * @returns {this}
394
- */
395
- addZoomButtons(opts) {
396
- // * opts.id
397
- // * opts.onZoom - custom event handler function
398
- // * opts.x
399
- // * opts.y
400
- // * opts.delta - zoom delta applied on each click
401
-
402
- this.zoomButtons = new ZoomButtons({
403
- map: this,
404
- id: opts?.id || 'gridviz-zoom-buttons-' + this.container.id,
405
- class: opts?.class,
406
- x: opts?.x,
407
- y: opts?.y,
408
- onZoom: opts?.onZoom,
409
- delta: opts?.delta || 0.2,
410
- })
411
-
412
- return this
413
- }
414
-
415
- /**
416
- * Adds a fullscreen toggle button to the app
417
- *
418
- * @param {object} opts
419
- * @returns {this}
420
- */
421
- addFullscreenButton(opts) {
422
- // * opts.map - the gridviz map
423
- // * opts.id
424
- // * opts.x
425
- // * opts.y
426
-
427
- this.fullscreenButton = new FullscreenButton({
428
- map: this,
429
- id: opts?.id || 'gridviz-fullscreen-button',
430
- class: opts?.class,
431
- x: opts?.x,
432
- y: opts?.y,
433
- })
434
-
435
- return this
436
- }
437
-
438
- /** @returns {this} */
439
- setViewFromURL() {
440
- this.geoCanvas.setViewFromURL()
441
- return this
442
- }
443
-
444
- /**
445
- * @description Add a resize event observer to the Apps container and update the canvas accordingly
446
- * @memberof App
447
- */
448
- defineResizeObserver() {
449
- // Track whether the observer is currently processing a resize event
450
- let resizePending = false
451
-
452
- const resizeObserver = new ResizeObserver((entries) => {
453
- if (!Array.isArray(entries) || !entries.length) return
454
-
455
- let container = this.container
456
-
457
- // Ensure the container has valid dimensions
458
- if (container.clientWidth > 0 && container.clientHeight > 0) {
459
- if (!resizePending) {
460
- resizePending = true // Prevent overlapping resize triggers
461
-
462
- window.requestAnimationFrame(() => {
463
- resizePending = false // Reset the flag after processing
464
-
465
- // Check for size changes
466
- if (this.h !== container.clientHeight || this.w !== container.clientWidth) {
467
- this.h = container.clientHeight
468
- this.w = container.clientWidth
469
-
470
- // Update geoCanvas sizes
471
- this.geoCanvas.h = this.h
472
- this.geoCanvas.w = this.w
473
- this.geoCanvas.canvas.setAttribute('width', String(this.w))
474
- this.geoCanvas.canvas.setAttribute('height', String(this.h))
475
- this.geoCanvas.offscreenCanvas.setAttribute('width', String(this.w))
476
- this.geoCanvas.offscreenCanvas.setAttribute('height', String(this.h))
477
-
478
- this.redraw()
479
-
480
- // Optionally reposition UI elements
481
- // if (this.zoomButtons) this.zoomButtons.node.style.left = this.w - 50 + 'px';
482
- // if (this.fullscreenButton) this.fullscreenButton.node.style.left = this.w - 50 + 'px';
483
- }
484
- })
485
- }
486
- }
487
- })
488
-
489
- resizeObserver.observe(this.container)
490
- }
491
-
492
- /**
493
- * @description Destroy the map and it's event listeners
494
- * This should significantly reduce the memory used when creating and destroying gridviz map instances (for example in leaflet-gridviz)
495
- * @memberof App
496
- */
497
- destroy() {
498
- // clear layers
499
- this.layers = []
500
- this.bgLayers = []
501
-
502
- // remove event listeners from container
503
- this.container.removeEventListener('mouseover', this.mouseOverHandler)
504
- this.container.removeEventListener('mousemove', this.mouseMoveHandler)
505
- this.container.removeEventListener('mouseout', this.mouseOutHandler)
506
-
507
- // remove canvas
508
- this.geoCanvas.canvas.remove()
509
-
510
- // remove legend
511
- this.legend?.remove()
512
-
513
- // remove tooltip
514
- this.tooltip.tooltip?.remove()
515
- }
516
- }
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
+ //get container element
34
+ this.container = container || document.getElementById('gridviz')
35
+ if (!this.container) {
36
+ console.error('Cannot find gridviz container element.')
37
+ return
38
+ }
39
+
40
+ //https://css-tricks.com/absolute-positioning-inside-relative-positioning/
41
+ this.container.style.position = 'relative' // container element must have relative positioning
42
+
43
+ //set dimensions
44
+ /** @type {number} */
45
+ this.w = opts.w || this.container.offsetWidth
46
+ /** @type {number} */
47
+ this.h = opts.h || this.container.offsetHeight
48
+
49
+ // Create the main canvas (for rendering to screen)
50
+ /** @type {HTMLCanvasElement} */
51
+ this._canvas = opts.canvas || this.initialiseCanvas()
52
+
53
+ /** Initialize GeoCanvas
54
+ * @type {GeoCanvas}
55
+ * @private */
56
+ this.geoCanvas = new GeoCanvas(this._canvas, opts.x, opts.y, opts.z, opts)
57
+
58
+ this.geoCanvas.redraw = () => {
59
+ this.redraw()
60
+ }
61
+ this.geoCanvas.cancelCurrentRequests = () => {
62
+ // when the zoom level changes, avoid drawing outdated tiles, and ensure that requests are properly aborted when necessary
63
+ for (const layer of this.layers) {
64
+ //multires
65
+ if (layer.dataset?.datasets) {
66
+ for (const dataset of layer.dataset?.datasets) {
67
+ if (dataset?.cancelCurrentRequests) dataset.cancelCurrentRequests()
68
+ }
69
+ }
70
+ //single res
71
+ if (layer.dataset?.cancelCurrentRequests) layer.dataset?.cancelCurrentRequests()
72
+ }
73
+ }
74
+
75
+ // legend div
76
+ this.legend = opts.legendContainer
77
+ ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
78
+ : null
79
+ if (!this.legend) this.initialiseLegend()
80
+
81
+ //tooltip
82
+
83
+ // set App container as default parent element for tooltip
84
+ if (!opts.tooltip) opts.tooltip = {}
85
+ if (!opts.tooltip.parentElement) opts.tooltip.parentElement = this.container
86
+
87
+ /**
88
+ * @private
89
+ * @type {Tooltip} */
90
+ this.tooltip = new Tooltip(opts.tooltip)
91
+
92
+ // add event listeners to container
93
+ this.mouseOverHandler = (e) => this.focusCell(e)
94
+ this.mouseMoveHandler = (e) => this.focusCell(e)
95
+ this.mouseOutHandler = (e) => this.tooltip.hide()
96
+ this.geoCanvas.canvas.addEventListener('mouseover', this.mouseOverHandler)
97
+ this.geoCanvas.canvas.addEventListener('mousemove', this.mouseMoveHandler)
98
+ this.geoCanvas.canvas.addEventListener('mouseout', this.mouseOutHandler)
99
+
100
+ // listen for resize events on the App's container and handle them
101
+ this.defineResizeObserver()
102
+
103
+ // add extra logic to onZoomStartFun
104
+ this.geoCanvas.onZoomStartFun = (e) => {
105
+ if (opts.onZoomStartFun) opts.onZoomStartFun(e)
106
+ this.tooltip.hide()
107
+ }
108
+
109
+ //for mouse over
110
+ /**
111
+ * @private
112
+ * @type {HTMLCanvasElement|null} */
113
+ this.canvasSave = null
114
+
115
+ this.selectionRectangleColor = opts.selectionRectangleColor || '#FF6347'
116
+ this.selectionRectangleWidthPix = opts.selectionRectangleWidthPix || (() => 4) //(r,z) => {}
117
+
118
+ // transparent background (e.g. leaflet) 'red painting' fix
119
+ this.transparentBackground = opts.transparentBackground
120
+
121
+ //set default globalCompositeOperation
122
+ this.defaultGlobalCompositeOperation =
123
+ opts.defaultGlobalCompositeOperation || this.geoCanvas.ctx.globalCompositeOperation
124
+ }
125
+
126
+ /**
127
+ * @protected
128
+ * @returns {HTMLCanvasElement}
129
+ */
130
+ initialiseCanvas() {
131
+ const canvas = document.createElement('canvas')
132
+ canvas.setAttribute('width', '' + this.w)
133
+ canvas.setAttribute('height', '' + this.h)
134
+ this.container.appendChild(canvas)
135
+ return canvas
136
+ }
137
+
138
+ initialiseLegend() {
139
+ this.legend = select(this.container)
140
+ .append('div') // Create a new container
141
+ .attr('id', 'gridviz-legend')
142
+ .style('position', 'absolute')
143
+ .style('width', 'auto')
144
+ .style('height', 'auto')
145
+ .style('background', '#FFFFFF')
146
+ //.style("padding", this.padding)
147
+ .style('border', '0px')
148
+ //.style('border-radius', '5px')
149
+ .style('box-shadow', '3px 3px 3px grey, -3px -3px 3px #ddd')
150
+ .style('font-family', 'Helvetica, Arial, sans-serif')
151
+ .style('bottom', '15px')
152
+ .style('right', '15px')
153
+ //hide
154
+ //.style("visibility", "hidden")
155
+ }
156
+
157
+ /**
158
+ * Set/get layer stack.
159
+ *
160
+ * @param {undefined|import("./Layer.js").Layer|import("./Layer.js").Layer[]} layers
161
+ * @returns { this | import("./Layer.js").Layer[] }
162
+ */
163
+ layers_(layers) {
164
+ if (arguments.length === 0) return this.layers
165
+ if (arguments.length === 1)
166
+ if (Array.isArray(layers)) this.layers = layers
167
+ else this.layers = [layers]
168
+ else this.layers = arguments
169
+ return this
170
+ }
171
+
172
+ /** @returns {this} */
173
+ redraw() {
174
+ //remove legend elements
175
+ if (this.legend) this.legend.selectAll('*').remove()
176
+
177
+ //clear
178
+ this.geoCanvas.initCanvasTransform()
179
+ this.geoCanvas.clear(this.geoCanvas.backgroundColor)
180
+
181
+ const z = this.geoCanvas.view.z
182
+ this.updateExtentGeo()
183
+
184
+ //go through the layers
185
+ for (const layer of this.layers) {
186
+ //check if layer is visible
187
+ if (layer.visible && !layer.visible(z)) continue
188
+
189
+ //set layer alpha and blend mode
190
+ this.geoCanvas.offscreenCtx.globalAlpha = layer.alpha ? layer.alpha(z) : 1.0
191
+ if (layer.blendOperation) this.geoCanvas.offscreenCtx.globalCompositeOperation = layer.blendOperation(z)
192
+
193
+ //set affin transform to draw with geographical coordinates
194
+ this.geoCanvas.setCanvasTransform()
195
+
196
+ //draw layer
197
+ layer.draw(this.geoCanvas, this.legend)
198
+
199
+ //draw layer filter
200
+ if (layer.filterColor) layer.drawFilter(this.geoCanvas)
201
+
202
+ //restore default alpha and blend operation
203
+ this.geoCanvas.offscreenCtx.globalAlpha = 1.0
204
+ this.geoCanvas.offscreenCtx.globalCompositeOperation = this.defaultGlobalCompositeOperation
205
+ }
206
+
207
+ // one drawImage call: draw the offscreen canvas to the main canvas
208
+ this.geoCanvas.initCanvasTransform()
209
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
210
+
211
+ this.canvasSave = null
212
+
213
+ return this
214
+ }
215
+
216
+ /**
217
+ * @param {number} marginPx
218
+ * @returns {import('./GeoCanvas.js').Envelope}
219
+ * @public
220
+ */
221
+ updateExtentGeo(marginPx = 20) {
222
+ return this.geoCanvas.updateExtentGeo(marginPx)
223
+ }
224
+
225
+ /** @param {MouseEvent} e */
226
+ focusCell(e) {
227
+ // Don’t process hover events during active zoom
228
+ if (this.geoCanvas._isZooming) return;
229
+
230
+ //compute mouse geo position
231
+ const mousePositionGeo = {
232
+ x: this.geoCanvas.pixToGeoX(e.offsetX + this.tooltip.xMouseOffset),
233
+ y: this.geoCanvas.pixToGeoY(e.offsetY + this.tooltip.yMouseOffset),
234
+ }
235
+ /** @type {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined} */
236
+ const focus = this.getCellFocusInfo(mousePositionGeo)
237
+
238
+ // Transparent background (Leaflet): redraw base, then draw focus rect directly on the visible canvas
239
+ if (this.transparentBackground) {
240
+ // Always restore the base (offscreen) into the visible canvas
241
+ this.geoCanvas.initCanvasTransform();
242
+ this.geoCanvas.ctx.clearRect(0, 0, this.w, this.h);
243
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0);
244
+
245
+ if (focus) {
246
+ this.tooltip.html(focus.html);
247
+ this.tooltip.setPosition(e);
248
+ this.tooltip.show();
249
+
250
+ const rectWPix = this.selectionRectangleWidthPix
251
+ ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
252
+ : 4;
253
+
254
+ const ctx = this.geoCanvas.ctx; // draw directly on visible canvas
255
+ ctx.save();
256
+ ctx.strokeStyle = this.selectionRectangleColor;
257
+ ctx.lineWidth = rectWPix;
258
+ ctx.beginPath();
259
+ // compute in pixel space (no geo transform on ctx)
260
+ const xPix = this.geoCanvas.geoToPixX(focus.cell.x);
261
+ const yPix = this.geoCanvas.geoToPixY(focus.cell.y);
262
+ const wPix = focus.resolution / this.geoCanvas.view.z;
263
+ const hPix = -wPix; // y axis inverted in geoToPix
264
+ ctx.rect(
265
+ xPix - rectWPix / 2,
266
+ yPix + rectWPix / 2,
267
+ wPix + rectWPix,
268
+ hPix - rectWPix
269
+ );
270
+ ctx.stroke();
271
+ ctx.restore();
272
+ } else {
273
+ this.tooltip.hide();
274
+ }
275
+ return; // handled
276
+ }
277
+
278
+ if (focus) {
279
+ this.tooltip.html(focus.html)
280
+ this.tooltip.setPosition(e)
281
+ this.tooltip.show()
282
+
283
+ //show cell position as a rectangle
284
+ if (!this.canvasSave) {
285
+ this.canvasSave = document.createElement('canvas')
286
+ this.canvasSave.setAttribute('width', '' + this.w)
287
+ this.canvasSave.setAttribute('height', '' + this.h)
288
+ this.canvasSave.getContext('2d')?.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
289
+ } else {
290
+ this.geoCanvas.offscreenCtx.drawImage(this.canvasSave, 0, 0)
291
+ }
292
+
293
+ //draw image saved + draw rectangle
294
+ const rectWPix = this.selectionRectangleWidthPix
295
+ ? this.selectionRectangleWidthPix(focus.resolution, this.geoCanvas.view.z)
296
+ : 4
297
+ this.geoCanvas.initCanvasTransform()
298
+ const ctx = this.geoCanvas.offscreenCtx
299
+ ctx.strokeStyle = this.selectionRectangleColor
300
+ ctx.lineWidth = rectWPix
301
+ ctx.beginPath()
302
+
303
+ ctx.rect(
304
+ this.geoCanvas.geoToPixX(focus.cell.x) - rectWPix / 2,
305
+ this.geoCanvas.geoToPixY(focus.cell.y) + rectWPix / 2,
306
+ focus.resolution / this.geoCanvas.view.z + rectWPix,
307
+ -focus.resolution / this.geoCanvas.view.z - rectWPix
308
+ )
309
+ ctx.stroke()
310
+ this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
311
+ } else {
312
+ this.tooltip.hide()
313
+ if (this.canvasSave) this.geoCanvas.ctx.drawImage(this.canvasSave, 0, 0)
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Return the cell HTML info at a given geo position.
319
+ * This is usefull for user interactions, to show this info where the user clicks for example.
320
+ *
321
+ * @param {{x:number,y:number}} posGeo
322
+ * @returns {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined}
323
+ * @protected
324
+ */
325
+ getCellFocusInfo(posGeo) {
326
+ //go through the layers, starting from top
327
+ const z = this.geoCanvas.view.z
328
+ for (let i = this.layers.length - 1; i >= 0; i--) {
329
+ /** @type {import("./Layer.js").Layer} */
330
+ const layer = this.layers[i]
331
+ if (layer.visible && !layer.visible(z)) continue
332
+ if (layer.cellInfoHTML === 'none') continue // this is necessary in order to not show tooltips for layers 'on top' (e.g. population circles on top of squares)
333
+ if (!layer.cellInfoHTML) continue
334
+ if (!layer.getDataset) continue
335
+ const dsc = layer.getDataset(z)
336
+ if (!dsc) continue
337
+
338
+ //get cell at mouse position
339
+ /** @type {import('./Dataset.js').Cell|undefined} */
340
+ const cell = dsc.getCellFromPosition(posGeo, dsc.getViewCache())
341
+
342
+ if (!cell) return undefined
343
+
344
+ //rare case for a dataset with mixed resolutions
345
+ if (dsc.mixedResolution) {
346
+ const r = +dsc.mixedResolution(cell)
347
+ const html = layer.cellInfoHTML(cell, r)
348
+ if (!html) return undefined
349
+ return { cell: cell, html: html, resolution: r }
350
+ }
351
+
352
+ const html = layer.cellInfoHTML(cell, dsc.getResolution())
353
+ if (!html) return undefined
354
+ return { cell: cell, html: html, resolution: dsc.getResolution() }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * @param {number} x
360
+ * @param {number} y
361
+ * @param {number|undefined} z
362
+ */
363
+ setView(x, y, z = undefined) {
364
+ this.geoCanvas.view.x = x
365
+ this.geoCanvas.view.y = y
366
+ if (z != undefined) this.geoCanvas.view.z = z
367
+ return this
368
+ }
369
+
370
+ /** @returns {import('./GeoCanvas.js').View} */
371
+ getView() {
372
+ return this.geoCanvas.view
373
+ }
374
+
375
+ /** @returns {number} */
376
+ getZoom() {
377
+ return this.geoCanvas.view.z
378
+ }
379
+ /** @param {number} z @returns {this} */
380
+ setZoom(z) {
381
+ this.geoCanvas.view.z = z
382
+ return this
383
+ }
384
+
385
+ /** @returns {Array.<number|undefined>} */
386
+ getCenterExtent() {
387
+ return this.geoCanvas.getCenterExtent()
388
+ }
389
+ /** @param {Array.<number>} val @returns {this} */
390
+ setCenterExtent(val) {
391
+ this.geoCanvas.setCenterExtent(val)
392
+ return this
393
+ }
394
+
395
+ /** @returns {Array.<number>} */
396
+ getZoomExtent() {
397
+ return this.geoCanvas.getZoomExtent()
398
+ }
399
+ /** @param {Array.<number>} val @returns {this} */
400
+ setZoomExtent(val) {
401
+ this.geoCanvas.setZoomExtent(val)
402
+ return this
403
+ }
404
+
405
+ /** @returns {string} */
406
+ getBackgroundColor() {
407
+ return this.geoCanvas.backgroundColor
408
+ }
409
+ /** @param {string} val @returns {this} */
410
+ setBackgroundColor(val) {
411
+ this.geoCanvas.backgroundColor = val
412
+ return this
413
+ }
414
+
415
+ /**
416
+ * Adds a set of zoom buttons to the map
417
+ *
418
+ * @param {object} opts
419
+ * @returns {this}
420
+ */
421
+ addZoomButtons(opts) {
422
+ // * opts.id
423
+ // * opts.onZoom - custom event handler function
424
+ // * opts.x
425
+ // * opts.y
426
+ // * opts.delta - zoom delta applied on each click
427
+
428
+ this.zoomButtons = new ZoomButtons({
429
+ map: this,
430
+ id: opts?.id || 'gridviz-zoom-buttons-' + this.container.id,
431
+ class: opts?.class,
432
+ x: opts?.x,
433
+ y: opts?.y,
434
+ onZoom: opts?.onZoom,
435
+ delta: opts?.delta || 0.2,
436
+ })
437
+
438
+ return this
439
+ }
440
+
441
+ /**
442
+ * Adds a fullscreen toggle button to the app
443
+ *
444
+ * @param {object} opts
445
+ * @returns {this}
446
+ */
447
+ addFullscreenButton(opts) {
448
+ // * opts.map - the gridviz map
449
+ // * opts.id
450
+ // * opts.x
451
+ // * opts.y
452
+
453
+ this.fullscreenButton = new FullscreenButton({
454
+ map: this,
455
+ id: opts?.id || 'gridviz-fullscreen-button',
456
+ class: opts?.class,
457
+ x: opts?.x,
458
+ y: opts?.y,
459
+ })
460
+
461
+ return this
462
+ }
463
+
464
+ /** @returns {this} */
465
+ setViewFromURL() {
466
+ this.geoCanvas.setViewFromURL()
467
+ return this
468
+ }
469
+
470
+ /**
471
+ * @description Add a resize event observer to the Apps container and update the canvas accordingly
472
+ * @memberof App
473
+ */
474
+ defineResizeObserver() {
475
+ // Track whether the observer is currently processing a resize event
476
+ let resizePending = false
477
+
478
+ const resizeObserver = new ResizeObserver((entries) => {
479
+ if (!Array.isArray(entries) || !entries.length) return
480
+
481
+ let container = this.container
482
+
483
+ // Ensure the container has valid dimensions
484
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
485
+ if (!resizePending) {
486
+ resizePending = true // Prevent overlapping resize triggers
487
+
488
+ window.requestAnimationFrame(() => {
489
+ resizePending = false // Reset the flag after processing
490
+
491
+ // Check for size changes
492
+ if (this.h !== container.clientHeight || this.w !== container.clientWidth) {
493
+ this.h = container.clientHeight
494
+ this.w = container.clientWidth
495
+
496
+ // Update geoCanvas sizes
497
+ this.geoCanvas.h = this.h
498
+ this.geoCanvas.w = this.w
499
+ this.geoCanvas.canvas.setAttribute('width', String(this.w))
500
+ this.geoCanvas.canvas.setAttribute('height', String(this.h))
501
+ this.geoCanvas.offscreenCanvas.setAttribute('width', String(this.w))
502
+ this.geoCanvas.offscreenCanvas.setAttribute('height', String(this.h))
503
+
504
+ this.redraw()
505
+
506
+ // Optionally reposition UI elements
507
+ // if (this.zoomButtons) this.zoomButtons.node.style.left = this.w - 50 + 'px';
508
+ // if (this.fullscreenButton) this.fullscreenButton.node.style.left = this.w - 50 + 'px';
509
+ }
510
+ })
511
+ }
512
+ }
513
+ })
514
+
515
+ resizeObserver.observe(this.container)
516
+ }
517
+
518
+ /**
519
+ * @description Destroy the map and it's event listeners
520
+ * This should significantly reduce the memory used when creating and destroying gridviz map instances (for example in leaflet-gridviz)
521
+ * @memberof App
522
+ */
523
+ destroy() {
524
+ // clear layers
525
+ this.layers = []
526
+ this.bgLayers = []
527
+
528
+ // remove event listeners from container
529
+ this.container.removeEventListener('mouseover', this.mouseOverHandler)
530
+ this.container.removeEventListener('mousemove', this.mouseMoveHandler)
531
+ this.container.removeEventListener('mouseout', this.mouseOutHandler)
532
+
533
+ // remove canvas
534
+ this.geoCanvas.canvas.remove()
535
+
536
+ // remove legend
537
+ this.legend?.remove()
538
+
539
+ // remove tooltip
540
+ this.tooltip.tooltip?.remove()
541
+ }
542
+ }