gridviz 3.0.37 → 3.0.39

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.37",
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.39",
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
+ }
@@ -30,7 +30,8 @@ export class Button {
30
30
  }
31
31
 
32
32
  if (opts.title) this.div.attr('title', opts.title)
33
- if (opts.class) this.div.attr('class', opts.class)
33
+ this.div.classed('gridviz-button', true)
34
+ if (opts.class) for (const c of String(opts.class).split(/\s+/)) if (c) this.div.classed(c, true)
34
35
 
35
36
  // add events
36
37
  if (opts.onClickFunction) this.div.on('click', opts.onClickFunction)
@@ -24,10 +24,12 @@ export class FullscreenButton extends Button {
24
24
 
25
25
  constructor(opts) {
26
26
  super(opts)
27
+ this.div.classed('gridviz-fullscreen-button', true)
27
28
 
28
29
  // append fullscreen icon to button container
29
30
  this.div.node().innerHTML = `
30
31
  <svg
32
+ class="gridviz-fullscreen-icon"
31
33
  style="height: 1.2rem; width: 1.2rem; fill:black; margin:0;"
32
34
  focusable="false"
33
35
  aria-hidden="true"
@@ -15,11 +15,12 @@ export class ZoomButtons extends Button {
15
15
 
16
16
  this.onZoom = opts.onZoom // custom user event handler
17
17
  this.delta = opts.delta || 0.2
18
+ this.div.classed('gridviz-zoom-buttons', true)
18
19
 
19
20
  // Create zoom in button
20
21
  this.zoomInBtn = document.createElement('a')
21
22
  this.zoomInBtn.id = 'zoom-in'
22
- this.zoomInBtn.className = 'gridviz-zoom-button'
23
+ this.zoomInBtn.className = 'gridviz-zoom-button gridviz-zoom-button--in'
23
24
  this.zoomInBtn.title = 'Zoom in'
24
25
  this.zoomInBtn.textContent = '+'
25
26
  this.zoomInBtn.addEventListener('click', (e) => {
@@ -35,7 +36,7 @@ export class ZoomButtons extends Button {
35
36
  // Create zoom out button
36
37
  this.zoomOutBtn = document.createElement('a')
37
38
  this.zoomOutBtn.id = 'zoom-out'
38
- this.zoomOutBtn.className = 'gridviz-zoom-button'
39
+ this.zoomOutBtn.className = 'gridviz-zoom-button gridviz-zoom-button--out'
39
40
  this.zoomOutBtn.title = 'Zoom out'
40
41
  this.zoomOutBtn.textContent = '-'
41
42
  this.zoomOutBtn.addEventListener('click', (e) => {
@@ -114,6 +114,7 @@ export class GeoCanvas {
114
114
  this.canvasSave.dx = 0
115
115
  this.canvasSave.dy = 0
116
116
  this.canvasSave.f = 1
117
+ this.canvasSave.view = { x: this.view.x, y: this.view.y, z: this.view.z }
117
118
  if (this.onZoomStartFun) this.onZoomStartFun(e)
118
119
  })
119
120
  .on('end', (e) => {
@@ -179,7 +180,7 @@ export class GeoCanvas {
179
180
  this.zoomExtent = opts.zoomExtent || [0, Infinity]
180
181
 
181
182
  /** Canvas state, to be used to avoid unnecessary redraws on zoom/pan
182
- * @type {{c:HTMLCanvasElement|null,dx:number,dy:number,f:number}} */
183
+ * @type {{c:HTMLCanvasElement|null,dx:number,dy:number,f:number,view?:{x:number,y:number,z:number}}} */
183
184
  this.canvasSave = { c: null, dx: 0, dy: 0, f: 1 }
184
185
  }
185
186
 
@@ -200,21 +201,7 @@ export class GeoCanvas {
200
201
  this.updateExtentGeo()
201
202
 
202
203
  if (this.canvasSave.c) {
203
- const scale = 1 / this.view.z
204
-
205
- // Update saved canvas offset
206
- this.canvasSave.dx -= dxGeo * scale
207
- this.canvasSave.dy += dyGeo * scale
208
-
209
- // clear canvas
210
- this.clear(this.backgroundColor)
211
-
212
- // this doesnt work on mobile https://github.com/eurostat/gridviz/issues/98
213
- //this.ctx.drawImage(this.canvasSave.c, this.canvasSave.dx, this.canvasSave.dy)
214
- this.offscreenCtx.drawImage(this.canvasSave.c, this.canvasSave.dx, this.canvasSave.dy)
215
-
216
- // Render the offscreen canvas to the visible context
217
- this.ctx.drawImage(this.offscreenCtx.canvas, 0, 0)
204
+ this._drawSnapshot()
218
205
  } else {
219
206
  console.log('no canvas save')
220
207
  }
@@ -258,29 +245,38 @@ export class GeoCanvas {
258
245
  this._drawZoomFrame(f, xGeo, yGeo)
259
246
  }
260
247
 
261
- _drawZoomFrame(f, xGeo, yGeo) {
248
+ _drawZoomFrame() {
262
249
  // zoom in on the current canvas state
263
- if (this.canvasSave.c) {
264
- this.clear(this.backgroundColor)
265
- this.canvasSave.f /= f
266
- this.canvasSave.dx = this.geoToPixX(xGeo) * (1 - this.canvasSave.f)
267
- this.canvasSave.dy = this.geoToPixY(yGeo) * (1 - this.canvasSave.f)
268
- this.clear(this.backgroundColor)
269
- this.offscreenCtx.drawImage(
270
- this.canvasSave.c,
271
- this.canvasSave.dx,
272
- this.canvasSave.dy,
273
- this.canvasSave.f * this.canvasSave.c.width,
274
- this.canvasSave.f * this.canvasSave.c.height
275
- )
276
- this.ctx.drawImage(
277
- this.offscreenCanvas, // Use offscreen canvas as the source
278
- 0,
279
- 0, // Position the offscreen canvas at the top-left corner of the main canvas
280
- this.canvas.width, // The width of the visible canvas
281
- this.canvas.height // The height of the visible canvas
282
- )
283
- }
250
+ if (this.canvasSave.c) this._drawSnapshot()
251
+ }
252
+
253
+ /**
254
+ * Draw the canvas saved when the pan/zoom started where it belongs in the current view.
255
+ * It is placed from the two views (the one it was taken at and the current one) rather than
256
+ * accumulated from each step: the zoom anchor follows the mouse, and an accumulation that
257
+ * assumes it stays put leaves the picture away from where the final redraw puts it.
258
+ * @private
259
+ */
260
+ _drawSnapshot() {
261
+ const sv = this.canvasSave.view
262
+ const f = sv ? sv.z / this.view.z : 1
263
+ // where the saved canvas' top left corner is in the current view
264
+ const dx = sv ? (sv.x - (this.w * 0.5) * sv.z - this.view.x) / this.view.z + this.w * 0.5 : 0
265
+ const dy = sv ? this.h * 0.5 - (sv.y + (this.h * 0.5) * sv.z - this.view.y) / this.view.z : 0
266
+ this.canvasSave.f = f
267
+ this.canvasSave.dx = dx
268
+ this.canvasSave.dy = dy
269
+
270
+ this.clear(this.backgroundColor)
271
+ // this doesnt work on mobile https://github.com/eurostat/gridviz/issues/98 : drawn on the offscreen canvas first
272
+ this.offscreenCtx.drawImage(
273
+ this.canvasSave.c,
274
+ dx,
275
+ dy,
276
+ f * this.canvasSave.c.width,
277
+ f * this.canvasSave.c.height
278
+ )
279
+ this.ctx.drawImage(this.offscreenCanvas, 0, 0, this.canvas.width, this.canvas.height)
284
280
  }
285
281
 
286
282
  /**
@@ -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,136 @@ 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
+ // flag the legend while hovered, so that the map's redraw leaves its element in place:
131
+ // a hovered element that is detached and appended again never gets its mouseleave
132
+ if (this.div) this.div.attr('data-gridviz-hovered', colors && colors.length ? '' : null)
133
+ this.style_.setHighlight(colors)
134
+ // nothing to redraw when the highlight didn't change (mouse moving between a swatch's parts)
135
+ if (before === null && this.style_.highlightColors === null) return
136
+ if (this.redraw) this.redraw()
137
+ }
138
+
139
+ /**
140
+ * Make a swatch react to the mouse: hovering it highlights the cells of a colour on the map.
141
+ * @param {*} selection The d3 selection of the swatch (any element)
142
+ * @param {string} color The colour the swatch stands for
143
+ * @param {{enter?:function, leave?:function}} [extra] Extra feedback on the swatch itself
144
+ */
145
+ bindHover(selection, color, extra) {
146
+ if (!this.interactive) return
147
+ selection
148
+ .style('cursor', 'pointer')
149
+ .on('mouseenter', () => {
150
+ if (extra && extra.enter) extra.enter()
151
+ this.highlight([color])
152
+ })
153
+ .on('mouseleave', () => {
154
+ if (extra && extra.leave) extra.leave()
155
+ this.highlight(null)
156
+ })
157
+ }
158
+
159
+ /**
160
+ * A style value with a CSS variable around it, unless the caller set the option themselves.
161
+ * @param {string} name The variable's name, after --gridviz-legend-
162
+ * @param {string} value The value to use when the variable isn't defined
163
+ * @param {boolean} custom
164
+ * @returns {string}
165
+ * @protected
166
+ */
167
+ _css(name, value, custom) {
168
+ return custom ? value : `var(--gridviz-legend-${name}, ${value})`
169
+ }
170
+
171
+ /** The font size of the labels, for the style() of a label element.
172
+ * @returns {string}
173
+ * @protected */
174
+ labelFontSizeCSS() {
175
+ return this._css('label-font-size', this.labelFontSize, this._set.labelFontSize)
176
+ }
177
+
178
+ /**
179
+ * Mark the root element as a type of legend: the CSS class gridviz-legend--<type>.
180
+ * @param {string} type
181
+ * @protected
182
+ */
183
+ setType(type) {
184
+ this.div.classed('gridviz-legend--' + type, true)
185
+ }
186
+
187
+ /**
188
+ * The width to draw at: this.width, or with width:'fill' the width of the container the legends are in
189
+ * (the legendContainer of the map), less this legend's own padding.
190
+ * @param {number} fallback Used when the container has no width (yet, or hidden)
191
+ * @returns {number}
192
+ * @protected
193
+ */
194
+ availableWidth(fallback) {
195
+ if (!this.fill) return this.width
196
+ const container = this.container
197
+ const w = container && container.clientWidth
198
+ if (!w) return fallback
199
+ const p = parseFloat(this.padding) || 0
200
+ return Math.max(60, Math.floor(w - 2 * p))
201
+ }
202
+
203
+ /**
204
+ * Set the text of a d3 selection: as HTML if the legend has labelsAsHTML, as plain text otherwise.
205
+ * @param {*} selection
206
+ * @param {string} text
207
+ * @protected
208
+ */
209
+ setText(selection, text) {
210
+ return this.labelsAsHTML ? selection.html(text) : selection.text(text)
77
211
  }
78
212
 
79
213
  makeTitle() {
80
214
  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)
215
+ this.setText(
216
+ this.div
217
+ .append('div')
218
+ .classed('gridviz-legend-title', true)
219
+ .style('font-size', this._css('title-font-size', this.titleFontSize, this._set.titleFontSize))
220
+ .style('font-weight', this._css('title-font-weight', this.titleFontWeight, this._set.titleFontWeight))
221
+ .style('margin-bottom', '7px'),
222
+ this.title
223
+ )
87
224
  }
88
225
 
89
226
  /**
package/src/core/Map.js CHANGED
@@ -46,6 +46,7 @@ export class Map {
46
46
  console.error('Cannot find gridviz container element.')
47
47
  return
48
48
  }
49
+ this.container.classList.add('gridviz-map')
49
50
 
50
51
  //https://css-tricks.com/absolute-positioning-inside-relative-positioning/
51
52
  this.container.style.position = 'relative' // container element must have relative positioning
@@ -87,6 +88,13 @@ export class Map {
87
88
  ? select(opts.legendContainer) // Wrap the provided HTML node in a D3 selection
88
89
  : null
89
90
  this._legendProvidedByUser = !!opts.legendContainer
91
+
92
+ /**
93
+ * Called with the legend container (an HTML element) each time the map has drawn and its legends
94
+ * are in the container, for a host that needs to adjust them (fit their width, restyle...).
95
+ * It can also be set after the map is created.
96
+ * @type {function(HTMLElement):void|undefined} */
97
+ this.onLegendUpdate = opts.onLegendUpdate
90
98
  if (!this.legend) this.initialiseLegend()
91
99
 
92
100
  //tooltip
@@ -167,6 +175,7 @@ export class Map {
167
175
  */
168
176
  initialiseCanvas() {
169
177
  const canvas = document.createElement('canvas')
178
+ canvas.classList.add('gridviz-canvas')
170
179
  canvas.setAttribute('width', '' + this.w)
171
180
  canvas.setAttribute('height', '' + this.h)
172
181
  this.container.appendChild(canvas)
@@ -177,6 +186,7 @@ export class Map {
177
186
  this.legend = select(this.container)
178
187
  .append('div') // Create a new container
179
188
  .attr('id', 'gridviz-legend')
189
+ .classed('gridviz-legends', true)
180
190
  .style('position', 'absolute')
181
191
  .style('width', 'auto')
182
192
  .style('height', 'auto')
@@ -232,8 +242,10 @@ export class Map {
232
242
  // a destroyed map can still be asked to redraw by a dataset it shared (see destroy)
233
243
  if (this._destroyed) return this
234
244
 
235
- //remove legend elements
236
- if (this.legend) this.legend.selectAll('*').remove()
245
+ //remove legend elements: the legends themselves only, not what is inside them
246
+ //(a legend keeps its content while one of its swatches is hovered, see Style.updateLegends)
247
+ //except the one being hovered, which would lose its mouseleave (see Legend.highlight)
248
+ if (this.legend) this.legend.selectAll(':scope > *:not([data-gridviz-hovered])').remove()
237
249
 
238
250
  //clear
239
251
  this.geoCanvas.initCanvasTransform()
@@ -277,6 +289,9 @@ export class Map {
277
289
  // the canvas holds no focus rectangle any more
278
290
  this._focusedCell = undefined
279
291
 
292
+ // the legends are in the container now
293
+ if (this.onLegendUpdate && this.legend) this.onLegendUpdate(this.legend.node())
294
+
280
295
  return this
281
296
  }
282
297
 
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()
@@ -75,6 +75,9 @@ export class Tooltip {
75
75
  this.tooltip.style('left', '0')
76
76
  this.tooltip.style('top', '0')
77
77
 
78
+ // a tooltip element given by the user gets the class too
79
+ this.tooltip.classed('gridviz-tooltip', true)
80
+
78
81
  // aria-labels (thanks to wahlatlas)
79
82
  this.tooltip.attr('role', 'tooltip').attr('aria-live', 'polite')
80
83
  }