gridviz 3.0.34 → 3.0.36

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