gridviz 3.0.36 → 3.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,74 +1,74 @@
1
- {
2
- "name": "gridviz",
3
- "version": "3.0.36",
4
- "description": "Visualization tool for gridded statistics",
5
- "keywords": [
6
- "statistics",
7
- "grid",
8
- "gridded",
9
- "grids",
10
- "gridded statistics",
11
- "dataviz",
12
- "csv",
13
- "GIS",
14
- "WebGL",
15
- "d3",
16
- "cartography",
17
- "bertin",
18
- "geospatial",
19
- "mapping"
20
- ],
21
- "homepage": "https://github.com/eurostat/gridviz",
22
- "author": "Joseph Davies, Julien Gaffuri",
23
- "license": "EUPL-1.2",
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/eurostat/gridviz.git"
27
- },
28
- "bugs": {
29
- "url": "https://github.com/eurostat/gridviz/issues",
30
- "email": "Joseph.DAVIES@ext.ec.europa.eu"
31
- },
32
- "type": "module",
33
- "main": "dist/gridviz.min.js",
34
- "module": "dist/gridviz.min.js",
35
- "exports": {
36
- "umd": "./dist/gridviz.min.js",
37
- "default": "./src/index.js"
38
- },
39
- "scripts": {
40
- "start": "webpack --config webpack.config.dev.cjs",
41
- "server": "webpack serve -c webpack.config.dev.cjs",
42
- "doc": "jsdoc -c docs/jsdoc/jsdoc.conf.json",
43
- "build-prod": "webpack --config webpack.config.prod.cjs",
44
- "test": "jest",
45
- "format": "prettier --write ."
46
- },
47
- "files": [
48
- "dist/**/*.js",
49
- "src/**/*.js"
50
- ],
51
- "dependencies": {
52
- "d3-array": "^3.2.4",
53
- "d3-color": "^3.1.0",
54
- "d3-fetch": "^3.0.1",
55
- "d3-random": "^3.0.1",
56
- "d3-scale": "^4.0.2",
57
- "d3-selection": "^3.0.0",
58
- "d3-zoom": "^3.0.0",
59
- "jsdoc": "^4.0.4"
60
- },
61
- "devDependencies": {
62
- "@babel/preset-env": "^7.25.4",
63
- "babel-loader": "^8.1.0",
64
- "jshint": "^2.12.0",
65
- "prettier": "^2.8.8",
66
- "webpack": "^5.96.1",
67
- "webpack-cli": "^5.1.4",
68
- "webpack-dev-server": "^5.1.0",
69
- "webpack-livereload-plugin": "^3.0.2"
70
- },
71
- "engines": {
72
- "node": ">=14"
73
- }
74
- }
1
+ {
2
+ "name": "gridviz",
3
+ "version": "3.0.38",
4
+ "description": "Visualization tool for gridded statistics",
5
+ "keywords": [
6
+ "statistics",
7
+ "grid",
8
+ "gridded",
9
+ "grids",
10
+ "gridded statistics",
11
+ "dataviz",
12
+ "csv",
13
+ "GIS",
14
+ "WebGL",
15
+ "d3",
16
+ "cartography",
17
+ "bertin",
18
+ "geospatial",
19
+ "mapping"
20
+ ],
21
+ "homepage": "https://github.com/eurostat/gridviz",
22
+ "author": "Joseph Davies, Julien Gaffuri",
23
+ "license": "EUPL-1.2",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/eurostat/gridviz.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/eurostat/gridviz/issues",
30
+ "email": "Joseph.DAVIES@ext.ec.europa.eu"
31
+ },
32
+ "type": "module",
33
+ "main": "dist/gridviz.min.js",
34
+ "module": "dist/gridviz.min.js",
35
+ "exports": {
36
+ "umd": "./dist/gridviz.min.js",
37
+ "default": "./src/index.js"
38
+ },
39
+ "scripts": {
40
+ "start": "webpack --config webpack.config.dev.cjs",
41
+ "server": "webpack serve -c webpack.config.dev.cjs",
42
+ "doc": "jsdoc -c docs/jsdoc/jsdoc.conf.json",
43
+ "build-prod": "webpack --config webpack.config.prod.cjs",
44
+ "test": "jest",
45
+ "format": "prettier --write ."
46
+ },
47
+ "files": [
48
+ "dist/**/*.js",
49
+ "src/**/*.js"
50
+ ],
51
+ "dependencies": {
52
+ "d3-array": "^3.2.4",
53
+ "d3-color": "^3.1.0",
54
+ "d3-fetch": "^3.0.1",
55
+ "d3-random": "^3.0.1",
56
+ "d3-scale": "^4.0.2",
57
+ "d3-selection": "^3.0.0",
58
+ "d3-zoom": "^3.0.0",
59
+ "jsdoc": "^4.0.4"
60
+ },
61
+ "devDependencies": {
62
+ "@babel/preset-env": "^7.25.4",
63
+ "babel-loader": "^8.1.0",
64
+ "jshint": "^2.12.0",
65
+ "prettier": "^2.8.8",
66
+ "webpack": "^5.96.1",
67
+ "webpack-cli": "^5.1.4",
68
+ "webpack-dev-server": "^5.1.0",
69
+ "webpack-livereload-plugin": "^3.0.2"
70
+ },
71
+ "engines": {
72
+ "node": ">=14"
73
+ }
74
+ }
@@ -120,6 +120,32 @@ export class Dataset {
120
120
 
121
121
  /** @type {number} */
122
122
  const r = this.getResolution()
123
+
124
+ // Look the cell up in an index of the cells by grid position, built once per cell
125
+ // list (the view cache is a new array after each redraw) instead of scanning every
126
+ // cell on every mouse move.
127
+ const index = this.cellIndex(cells, r)
128
+ if (index) {
129
+ const fx = (posGeo.x - index.ox) / r
130
+ const fy = (posGeo.y - index.oy) / r
131
+ const ix = Math.floor(fx)
132
+ const iy = Math.floor(fy)
133
+ // a position on a cell's right/top edge also belongs to that cell (inclusive
134
+ // bounds, as in the scan below): check the cells left of and below it too
135
+ for (const [dx, dy] of [
136
+ [0, 0],
137
+ [-1, 0],
138
+ [0, -1],
139
+ [-1, -1],
140
+ ]) {
141
+ if (dx && fx !== ix) continue
142
+ if (dy && fy !== iy) continue
143
+ const cell = index.cells.get(cellKey(ix + dx, iy + dy))
144
+ if (cell) return cell
145
+ }
146
+ return undefined
147
+ }
148
+
123
149
  for (const cell of cells) {
124
150
  if (posGeo.x < cell.x) continue
125
151
  else if (cell.x + r < posGeo.x) continue
@@ -130,6 +156,47 @@ export class Dataset {
130
156
  return undefined
131
157
  }
132
158
 
159
+ /**
160
+ * An index of cells by their position on the grid of resolution r, or null when some
161
+ * cell isn't on a common grid (then the caller scans the cells). Cached for the last
162
+ * cell list given.
163
+ *
164
+ * @param {Array.<Cell>} cells
165
+ * @param {number} r
166
+ * @returns {{cells: Map<number, Cell>, ox: number, oy: number} | null}
167
+ * @protected
168
+ */
169
+ cellIndex(cells, r) {
170
+ if (this._index && this._indexCells === cells && this._indexLength === cells.length && this._indexR === r)
171
+ return this._index
172
+ let index = null
173
+ if (cells.length && r > 0) {
174
+ const ox = +cells[0].x
175
+ const oy = +cells[0].y
176
+ const map = new Map()
177
+ index = { cells: map, ox, oy }
178
+ for (const cell of cells) {
179
+ const fx = (+cell.x - ox) / r
180
+ const fy = (+cell.y - oy) / r
181
+ const ix = Math.round(fx)
182
+ const iy = Math.round(fy)
183
+ // not on the grid: no index, scan instead
184
+ if (Math.abs(fx - ix) > 1e-6 || Math.abs(fy - iy) > 1e-6) {
185
+ index = null
186
+ break
187
+ }
188
+ const key = cellKey(ix, iy)
189
+ // the first cell at a position wins, as in the scan
190
+ if (!map.has(key)) map.set(key, cell)
191
+ }
192
+ }
193
+ this._index = index
194
+ this._indexCells = cells
195
+ this._indexLength = cells.length
196
+ this._indexR = r
197
+ return index
198
+ }
199
+
133
200
  //getters and setters
134
201
 
135
202
  /** @returns {number} */
@@ -152,3 +219,14 @@ export class Dataset {
152
219
  return this
153
220
  }
154
221
  }
222
+
223
+ /**
224
+ * One number per grid position: cell positions relative to the first cell stay well
225
+ * within +-2^21, so x * 2^22 + y is exact.
226
+ * @param {number} ix
227
+ * @param {number} iy
228
+ * @returns {number}
229
+ */
230
+ function cellKey(ix, iy) {
231
+ return ix * 4194304 + iy
232
+ }
@@ -54,13 +54,30 @@ export class Legend {
54
54
  if (this.id) this.div.attr('id', this.id)
55
55
  }
56
56
 
57
+ // Which style options the caller set. The ones they didn't set are written as a CSS variable with the
58
+ // usual value as its fallback (--gridviz-legend-font-family, ...): nothing changes unless a page defines the variable.
59
+ /** @private */
60
+ this._set = {
61
+ padding: opts.padding !== undefined,
62
+ fontFamily: opts['font-family'] !== undefined,
63
+ titleFontSize: opts.titleFontSize !== undefined,
64
+ titleFontWeight: opts.titleFontWeight !== undefined,
65
+ labelFontSize: opts.labelFontSize !== undefined,
66
+ }
67
+
68
+ // CSS classes, to style legends from a stylesheet: gridviz-legend on every legend, a modifier for the
69
+ // type (see each legend class), and whatever the caller asks for with the className option.
70
+ this.div.classed('gridviz-legend', true)
71
+ if (opts.className)
72
+ for (const c of String(opts.className).split(/\s+/)) if (c) this.div.classed(c, true)
73
+
57
74
  //set style
58
75
  this.div.style('background', this.background)
59
- this.div.style('padding', this.padding)
76
+ this.div.style('padding', this._css('padding', this.padding, this._set.padding))
60
77
  this.div.style('border', this.border)
61
78
  this.div.style('border-radius', this['border-radius'])
62
79
  this.div.style('box-shadow', this['box-shadow'])
63
- this.div.style('font-family', this['font-family'])
80
+ this.div.style('font-family', this._css('font-family', this['font-family'], this._set.fontFamily))
64
81
 
65
82
  //if (this.width) this.div.style('width', this.width)
66
83
  //if (this.height) this.div.style('height', this.height)
@@ -74,16 +91,133 @@ export class Legend {
74
91
  this.labelFontSize = opts.labelFontSize || '0.8em'
75
92
  this.labelUnitText = opts.labelUnitText || ''
76
93
  this.labelFormat = opts.labelFormat
94
+
95
+ /**
96
+ * Whether the title and the labels are HTML (to italicise a word, for example) instead of plain text.
97
+ * Off by default, which is plain text. Only turn it on for text you trust: it is not escaped.
98
+ * @type {boolean} */
99
+ this.labelsAsHTML = opts.labelsAsHTML === true
100
+
101
+ /**
102
+ * Whether hovering a swatch of the legend shows only the matching cells on the map.
103
+ * @type {boolean} */
104
+ this.interactive = opts.interactive !== false
105
+
106
+ /**
107
+ * The style the legend belongs to, and a function to redraw the map. Both set by the layer when it draws.
108
+ * @type {import('./Style.js').Style|undefined} */
109
+ this.style_ = undefined
110
+ /** @type {function|undefined} */
111
+ this.redraw = undefined
112
+ /**
113
+ * The element the legends are drawn in, set by the layer when it draws.
114
+ * @type {HTMLElement|undefined} */
115
+ this.container = undefined
116
+
117
+ /**
118
+ * Draw at the width of the container instead of this.width (width:'fill').
119
+ * @type {boolean} @protected */
120
+ this.fill = opts.width === 'fill'
121
+ }
122
+
123
+ /**
124
+ * Show only the cells drawn in some colours on the map, or all of them again.
125
+ * @param {Array.<string>|null} colors
126
+ */
127
+ highlight(colors) {
128
+ if (!this.interactive || !this.style_) return
129
+ const before = this.style_.highlightColors
130
+ this.style_.setHighlight(colors)
131
+ // nothing to redraw when the highlight didn't change (mouse moving between a swatch's parts)
132
+ if (before === null && this.style_.highlightColors === null) return
133
+ if (this.redraw) this.redraw()
134
+ }
135
+
136
+ /**
137
+ * Make a swatch react to the mouse: hovering it highlights the cells of a colour on the map.
138
+ * @param {*} selection The d3 selection of the swatch (any element)
139
+ * @param {string} color The colour the swatch stands for
140
+ * @param {{enter?:function, leave?:function}} [extra] Extra feedback on the swatch itself
141
+ */
142
+ bindHover(selection, color, extra) {
143
+ if (!this.interactive) return
144
+ selection
145
+ .style('cursor', 'pointer')
146
+ .on('mouseenter', () => {
147
+ if (extra && extra.enter) extra.enter()
148
+ this.highlight([color])
149
+ })
150
+ .on('mouseleave', () => {
151
+ if (extra && extra.leave) extra.leave()
152
+ this.highlight(null)
153
+ })
154
+ }
155
+
156
+ /**
157
+ * A style value with a CSS variable around it, unless the caller set the option themselves.
158
+ * @param {string} name The variable's name, after --gridviz-legend-
159
+ * @param {string} value The value to use when the variable isn't defined
160
+ * @param {boolean} custom
161
+ * @returns {string}
162
+ * @protected
163
+ */
164
+ _css(name, value, custom) {
165
+ return custom ? value : `var(--gridviz-legend-${name}, ${value})`
166
+ }
167
+
168
+ /** The font size of the labels, for the style() of a label element.
169
+ * @returns {string}
170
+ * @protected */
171
+ labelFontSizeCSS() {
172
+ return this._css('label-font-size', this.labelFontSize, this._set.labelFontSize)
173
+ }
174
+
175
+ /**
176
+ * Mark the root element as a type of legend: the CSS class gridviz-legend--<type>.
177
+ * @param {string} type
178
+ * @protected
179
+ */
180
+ setType(type) {
181
+ this.div.classed('gridviz-legend--' + type, true)
182
+ }
183
+
184
+ /**
185
+ * The width to draw at: this.width, or with width:'fill' the width of the container the legends are in
186
+ * (the legendContainer of the map), less this legend's own padding.
187
+ * @param {number} fallback Used when the container has no width (yet, or hidden)
188
+ * @returns {number}
189
+ * @protected
190
+ */
191
+ availableWidth(fallback) {
192
+ if (!this.fill) return this.width
193
+ const container = this.container
194
+ const w = container && container.clientWidth
195
+ if (!w) return fallback
196
+ const p = parseFloat(this.padding) || 0
197
+ return Math.max(60, Math.floor(w - 2 * p))
198
+ }
199
+
200
+ /**
201
+ * Set the text of a d3 selection: as HTML if the legend has labelsAsHTML, as plain text otherwise.
202
+ * @param {*} selection
203
+ * @param {string} text
204
+ * @protected
205
+ */
206
+ setText(selection, text) {
207
+ return this.labelsAsHTML ? selection.html(text) : selection.text(text)
77
208
  }
78
209
 
79
210
  makeTitle() {
80
211
  if (!this.title) return
81
- this.div
82
- .append('div')
83
- .style('font-size', this.titleFontSize)
84
- .style('font-weight', this.titleFontWeight)
85
- .style('margin-bottom', '7px')
86
- .text(this.title)
212
+ this.setText(
213
+ this.div
214
+ .append('div')
215
+ .classed('gridviz-legend-title', true)
216
+ .style('font-size', this._css('title-font-size', this.titleFontSize, this._set.titleFontSize))
217
+ .style('font-weight', this._css('title-font-weight', this.titleFontWeight, this._set.titleFontWeight))
218
+ .style('margin-bottom', '7px'),
219
+ this.title
220
+ )
87
221
  }
88
222
 
89
223
  /**
package/src/core/Map.js CHANGED
@@ -30,6 +30,16 @@ export class Map {
30
30
  * */
31
31
  this.layers = opts.layers || []
32
32
 
33
+ /**
34
+ * When true, redraw() schedules one redraw for the next animation frame instead of
35
+ * drawing at once, so the many redraw requests of a busy moment (every tile that
36
+ * arrives, every info.json, every setting change) are drawn once. redrawNow() still
37
+ * draws at once. Off by default: redraw() stays synchronous, as it always was.
38
+ * @type {boolean} */
39
+ this.asyncRedraw = !!opts.asyncRedraw
40
+ /** @private @type {number|null} */
41
+ this._redrawFrame = null
42
+
33
43
  //get container element
34
44
  this.container = container || document.getElementById('gridviz')
35
45
  if (!this.container) {
@@ -77,6 +87,13 @@ export class Map {
77
87
  ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
78
88
  : null
79
89
  this._legendProvidedByUser = !!opts.legendContainer
90
+
91
+ /**
92
+ * Called with the legend container (an HTML element) each time the map has drawn and its legends
93
+ * are in the container, for a host that needs to adjust them (fit their width, restyle...).
94
+ * It can also be set after the map is created.
95
+ * @type {function(HTMLElement):void|undefined} */
96
+ this.onLegendUpdate = opts.onLegendUpdate
80
97
  if (!this.legend) this.initialiseLegend()
81
98
 
82
99
  //tooltip
@@ -96,10 +113,31 @@ export class Map {
96
113
  * @type {boolean} */
97
114
  this.digForTooltip = opts.digForTooltip || false
98
115
 
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()
116
+ // add event listeners to the canvas. Mouse moves are handled at most once per
117
+ // animation frame (the latest one): finding the cell and redrawing the highlight
118
+ // for every intermediate event only delays the one that's shown.
119
+ /** @private */
120
+ this._focusFrame = null
121
+ /** @private */
122
+ this._focusEvent = null
123
+ const scheduleFocus = (e) => {
124
+ this._focusEvent = e
125
+ if (this._focusFrame) return
126
+ this._focusFrame = requestAnimationFrame(() => {
127
+ this._focusFrame = null
128
+ const ev = this._focusEvent
129
+ this._focusEvent = null
130
+ if (ev) this.focusCell(ev)
131
+ })
132
+ }
133
+ this.mouseOverHandler = scheduleFocus
134
+ this.mouseMoveHandler = scheduleFocus
135
+ this.mouseOutHandler = (e) => {
136
+ if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
137
+ this._focusFrame = null
138
+ this._focusEvent = null
139
+ this.tooltip.hide()
140
+ }
103
141
  this.geoCanvas.canvas.addEventListener('mouseover', this.mouseOverHandler)
104
142
  this.geoCanvas.canvas.addEventListener('mousemove', this.mouseMoveHandler)
105
143
  this.geoCanvas.canvas.addEventListener('mouseout', this.mouseOutHandler)
@@ -176,10 +214,34 @@ export class Map {
176
214
  return this
177
215
  }
178
216
 
179
- /** @returns {this} */
217
+ /**
218
+ * Redraws the map: at once, or, with the asyncRedraw option, once at the next
219
+ * animation frame however many times it's called before then.
220
+ * @returns {this} */
180
221
  redraw() {
181
- //remove legend elements
182
- if (this.legend) this.legend.selectAll('*').remove()
222
+ if (this.asyncRedraw) {
223
+ if (!this._redrawFrame && !this._destroyed)
224
+ this._redrawFrame = requestAnimationFrame(() => {
225
+ this._redrawFrame = null
226
+ this.redrawNow()
227
+ })
228
+ return this
229
+ }
230
+ return this.redrawNow()
231
+ }
232
+
233
+ /**
234
+ * Redraws the map at once, whatever the asyncRedraw option (and cancels a scheduled redraw).
235
+ * @returns {this} */
236
+ redrawNow() {
237
+ if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
238
+ this._redrawFrame = null
239
+ // a destroyed map can still be asked to redraw by a dataset it shared (see destroy)
240
+ if (this._destroyed) return this
241
+
242
+ //remove legend elements: the legends themselves only, not what is inside them
243
+ //(a legend keeps its content while one of its swatches is hovered, see Style.updateLegends)
244
+ if (this.legend) this.legend.selectAll(':scope > *').remove()
183
245
 
184
246
  //clear
185
247
  this.geoCanvas.initCanvasTransform()
@@ -220,6 +282,11 @@ export class Map {
220
282
  this.geoCanvas.ctx.drawImage(this.geoCanvas.offscreenCanvas, 0, 0)
221
283
 
222
284
  this.canvasSave = null
285
+ // the canvas holds no focus rectangle any more
286
+ this._focusedCell = undefined
287
+
288
+ // the legends are in the container now
289
+ if (this.onLegendUpdate && this.legend) this.onLegendUpdate(this.legend.node())
223
290
 
224
291
  return this
225
292
  }
@@ -246,6 +313,18 @@ export class Map {
246
313
  /** @type {{cell:import('./Dataset.js').Cell,html:string,resolution:number} | undefined} */
247
314
  const focus = this.getCellFocusInfo(mousePositionGeo)
248
315
 
316
+ // Same cell as the one already highlighted (or still no cell): the canvas is right
317
+ // as it is, only the tooltip follows the mouse. Saves a full-canvas copy per move.
318
+ const focusedCell = focus ? focus.cell : null
319
+ if (focusedCell === this._focusedCell) {
320
+ if (focus) {
321
+ this.tooltip.setPosition(e)
322
+ this.tooltip.show()
323
+ } else this.tooltip.hide()
324
+ return
325
+ }
326
+ this._focusedCell = focusedCell
327
+
249
328
  // Transparent background (Leaflet): redraw base, then draw focus rect directly on the visible canvas
250
329
  if (this.transparentBackground) {
251
330
  // Always restore the base (offscreen) into the visible canvas
@@ -536,6 +615,8 @@ export class Map {
536
615
  })
537
616
 
538
617
  resizeObserver.observe(this.container)
618
+ /** @private */
619
+ this._resizeObserver = resizeObserver
539
620
  }
540
621
 
541
622
  /**
@@ -544,14 +625,26 @@ export class Map {
544
625
  * @memberof App
545
626
  */
546
627
  destroy() {
628
+ this._destroyed = true
629
+
547
630
  // clear layers
548
631
  this.layers = []
549
632
  this.bgLayers = []
550
633
 
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)
634
+ // cancel scheduled work
635
+ if (this._redrawFrame) cancelAnimationFrame(this._redrawFrame)
636
+ this._redrawFrame = null
637
+ if (this._focusFrame) cancelAnimationFrame(this._focusFrame)
638
+ this._focusFrame = null
639
+
640
+ // stop observing the container: the observer would otherwise keep this map alive
641
+ if (this._resizeObserver) this._resizeObserver.disconnect()
642
+ this._resizeObserver = null
643
+
644
+ // remove event listeners, from the canvas they were added to
645
+ this.geoCanvas.canvas.removeEventListener('mouseover', this.mouseOverHandler)
646
+ this.geoCanvas.canvas.removeEventListener('mousemove', this.mouseMoveHandler)
647
+ this.geoCanvas.canvas.removeEventListener('mouseout', this.mouseOutHandler)
555
648
 
556
649
  // remove canvas
557
650
  this.geoCanvas.canvas.remove()
package/src/core/Style.js CHANGED
@@ -1,6 +1,7 @@
1
1
  //@ts-check
2
2
  'use strict'
3
3
 
4
+ import { color as d3color } from 'd3-color'
4
5
  import { Drawable } from './Drawable.js'
5
6
 
6
7
  /** @typedef {"square"|"circle"|"diamond"|"donut"|"triangle_up"|"triangle_down"|"triangle_left"|"triangle_right"|"none"} Shape */
@@ -47,6 +48,48 @@ export class Style extends Drawable {
47
48
  * @public
48
49
  * @type {Array.<import("./Legend").Legend>} */
49
50
  this.legends = []
51
+
52
+ /**
53
+ * The colours of the cells to show while a legend swatch is hovered (see Legend.highlight),
54
+ * normalised with Style.normalizeColor. Null when nothing is highlighted.
55
+ * Styles that draw cells in a colour per class honour it with isHighlighted.
56
+ * @type {Array.<string>|null} */
57
+ this.highlightColors = null
58
+ }
59
+
60
+ /**
61
+ * A colour as a canonical string, so that '#ccc', 'rgb(204,204,204)' and '#cccccc' compare equal.
62
+ * @param {string} c
63
+ * @returns {string}
64
+ */
65
+ static normalizeColor(c) {
66
+ let n = Style._normalizedColors.get(c)
67
+ if (n === undefined) {
68
+ const parsed = d3color(c)
69
+ n = parsed ? parsed.formatRgb() : String(c)
70
+ // a style drawing millions of distinct colours must not grow this without bound
71
+ if (Style._normalizedColors.size > 10000) Style._normalizedColors.clear()
72
+ Style._normalizedColors.set(c, n)
73
+ }
74
+ return n
75
+ }
76
+
77
+ /**
78
+ * Show only the cells drawn in some colours: what hovering a legend swatch does.
79
+ * The map has to be redrawn for it to show (Legend.highlight does).
80
+ * @param {Array.<string>|null|undefined} colors The colours to show, or null to show everything again
81
+ * @returns {this} */
82
+ setHighlight(colors) {
83
+ this.highlightColors = colors && colors.length ? colors.map(Style.normalizeColor) : null
84
+ return this
85
+ }
86
+
87
+ /**
88
+ * Whether cells drawn in a colour are shown: always, unless a legend swatch is hovered and it is another colour.
89
+ * @param {string} c
90
+ * @returns {boolean} */
91
+ isHighlighted(c) {
92
+ return !this.highlightColors || this.highlightColors.includes(Style.normalizeColor(c))
50
93
  }
51
94
 
52
95
  /**
@@ -78,6 +121,8 @@ export class Style extends Drawable {
78
121
  * @param {object} opts
79
122
  * @returns {this} */
80
123
  updateLegends(opts) {
124
+ // the legend being hovered must keep its elements, or it would lose the mouse
125
+ if (this.highlightColors) return this
81
126
  Style.updateLegendsRecursive(this.legends, opts)
82
127
  return this
83
128
  }
@@ -96,3 +141,6 @@ export class Style extends Drawable {
96
141
  return this
97
142
  }
98
143
  }
144
+
145
+ /** @type {Map<string,string>} */
146
+ Style._normalizedColors = new Map()