gridviz 3.0.35 → 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 +17486 -17486
  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 -568
  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,568 +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
- /**
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
- }
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
+ }