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/dist/gridviz.js +8841 -8385
- package/dist/gridviz.min.js +2 -2
- package/package.json +74 -74
- package/src/core/Dataset.js +78 -0
- package/src/core/Legend.js +142 -8
- package/src/core/Map.js +104 -11
- package/src/core/Style.js +48 -0
- package/src/dataset/TiledGrid.js +24 -15
- package/src/layer/GridLayer.js +24 -5
- package/src/legend/ColorCategoryLegend.js +21 -9
- package/src/legend/ColorDiscreteLegend.js +18 -5
- package/src/legend/ColorLegend.js +9 -3
- package/src/legend/OrientationLegend.js +4 -2
- package/src/legend/SizeLegend.js +4 -2
- package/src/legend/TernaryLegend.js +10 -0
- package/src/style/DotDensityStyle.js +3 -3
- package/src/style/ShapeColorSizeStyle.js +4 -0
- package/src/style/SquareColorCategoryWebGLStyle.js +131 -119
- package/src/style/SquareColorWebGLStyle.js +5 -5
- package/src/utils/WebGLSquareColoring.js +28 -32
- package/src/utils/WebGLSquareColoringAdvanced.js +26 -30
- package/src/utils/webGLUtils.js +105 -0
package/package.json
CHANGED
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "gridviz",
|
|
3
|
-
"version": "3.0.
|
|
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
|
+
}
|
package/src/core/Dataset.js
CHANGED
|
@@ -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
|
+
}
|
package/src/core/Legend.js
CHANGED
|
@@ -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.
|
|
82
|
-
.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
//
|
|
552
|
-
this.
|
|
553
|
-
this.
|
|
554
|
-
this.
|
|
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()
|