gridviz 3.0.38 → 3.0.40

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,6 +1,6 @@
1
1
  {
2
2
  "name": "gridviz",
3
- "version": "3.0.38",
3
+ "version": "3.0.40",
4
4
  "description": "Visualization tool for gridded statistics",
5
5
  "keywords": [
6
6
  "statistics",
@@ -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
  /**
@@ -127,6 +127,9 @@ export class Legend {
127
127
  highlight(colors) {
128
128
  if (!this.interactive || !this.style_) return
129
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)
130
133
  this.style_.setHighlight(colors)
131
134
  // nothing to redraw when the highlight didn't change (mouse moving between a swatch's parts)
132
135
  if (before === null && this.style_.highlightColors === null) return
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
@@ -174,6 +175,7 @@ export class Map {
174
175
  */
175
176
  initialiseCanvas() {
176
177
  const canvas = document.createElement('canvas')
178
+ canvas.classList.add('gridviz-canvas')
177
179
  canvas.setAttribute('width', '' + this.w)
178
180
  canvas.setAttribute('height', '' + this.h)
179
181
  this.container.appendChild(canvas)
@@ -184,6 +186,7 @@ export class Map {
184
186
  this.legend = select(this.container)
185
187
  .append('div') // Create a new container
186
188
  .attr('id', 'gridviz-legend')
189
+ .classed('gridviz-legends', true)
187
190
  .style('position', 'absolute')
188
191
  .style('width', 'auto')
189
192
  .style('height', 'auto')
@@ -241,7 +244,12 @@ export class Map {
241
244
 
242
245
  //remove legend elements: the legends themselves only, not what is inside them
243
246
  //(a legend keeps its content while one of its swatches is hovered, see Style.updateLegends)
244
- if (this.legend) this.legend.selectAll(':scope > *').remove()
247
+ //except the one being hovered, which would lose its mouseleave (see Legend.highlight)
248
+ if (this.legend) {
249
+ this.legend.selectAll(':scope > *:not([data-gridviz-hovered])').remove()
250
+ // the legends of this draw are placed one after the other (see GridLayer.addLegends)
251
+ this.legend.node()._gridvizLegendCursor = null
252
+ }
245
253
 
246
254
  //clear
247
255
  this.geoCanvas.initCanvasTransform()
@@ -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
  }
@@ -137,7 +137,16 @@ export class GridLayer extends Layer {
137
137
  // so that hovering a swatch can highlight the cells of the style on the map
138
138
  lg.style_ = style
139
139
  lg.redraw = () => (geoCanvas.redraw ? geoCanvas.redraw() : undefined)
140
- legendComp.node().append(lg.div.node())
140
+ // The legends go in the order they are drawn, after the one placed before them. A hovered legend is
141
+ // still in the container (see Map.redrawNow) and is left where it is: moving it would lose its mouseleave,
142
+ // and appending the others after it would put it above the legends that come before it.
143
+ const container = legendComp.node()
144
+ const el = lg.div.node()
145
+ const previous = container._gridvizLegendCursor
146
+ if (previous) {
147
+ if (el.previousSibling !== previous) previous.after(el)
148
+ } else if (container.firstChild !== el) container.prepend(el)
149
+ container._gridvizLegendCursor = el
141
150
  }
142
151
  }
143
152
 
@@ -64,9 +64,11 @@ export class ColorCategoryLegend extends Legend {
64
64
  const h = this.dimension.h || 15
65
65
  const w = this.dimension.w || 20
66
66
  d.append('div')
67
+ .classed('gridviz-legend-symbol-wrapper', true)
67
68
  .style('display', 'inline')
68
69
 
69
70
  .append('svg')
71
+ .classed('gridviz-legend-graphic', true)
70
72
  .attr('width', w + 2 * sw)
71
73
  .attr('height', h + 2 * sw)
72
74
 
@@ -83,9 +85,11 @@ export class ColorCategoryLegend extends Legend {
83
85
  const r = this.dimension.r || 8
84
86
  const h = 2 * r + 2 * sw
85
87
  d.append('div')
88
+ .classed('gridviz-legend-symbol-wrapper', true)
86
89
  .style('display', 'inline')
87
90
 
88
91
  .append('svg')
92
+ .classed('gridviz-legend-graphic', true)
89
93
  .attr('width', h)
90
94
  .attr('height', h)
91
95
 
@@ -56,6 +56,7 @@ export class ColorDiscreteLegend extends Legend {
56
56
  //make svg element
57
57
  const svg = this.div
58
58
  .append('svg')
59
+ .classed('gridviz-legend-graphic', true)
59
60
  .attr('width', width)
60
61
  .attr('height', this.height + this.tickSize + 2 + 10)
61
62
 
@@ -54,10 +54,10 @@ export class ColorLegend extends Legend {
54
54
  const width = this.availableWidth(this.width)
55
55
  const svgW = width + 2 * this.margin
56
56
  const svgH = this.height + this.margin + this.tickSize + 10
57
- const svg = this.div.append('svg').attr('width', svgW).attr('height', svgH)
57
+ const svg = this.div.append('svg').classed('gridviz-legend-graphic', true).attr('width', svgW).attr('height', svgH)
58
58
  // <rect width="300" height="100" style="fill:rgb(0,0,255);stroke-width:3;stroke:rgb(0,0,0)" />
59
59
 
60
- const g = svg.append('g').attr('transform', 'translate(' + this.margin + ' ' + 0 + ')')
60
+ const g = svg.append('g').classed('gridviz-legend-bar', true).attr('transform', 'translate(' + this.margin + ' ' + 0 + ')')
61
61
 
62
62
  //draw color bar
63
63
  const w = width,
@@ -48,13 +48,14 @@ export class OrientationLegend extends Legend {
48
48
 
49
49
  //draw SVG segment
50
50
  const svgS = Math.max(lengthPix, widthPix)
51
- const svg = d.append('svg').attr('width', svgS).attr('height', svgS).style('', 'inline-block')
51
+ const svg = d.append('svg').classed('gridviz-legend-graphic', true).attr('width', svgS).attr('height', svgS).style('', 'inline-block')
52
52
 
53
53
  const cos = Math.cos((-this.orientation * Math.PI) / 180)
54
54
  const sin = Math.sin((-this.orientation * Math.PI) / 180)
55
55
  const dc = svgS * 0.5,
56
56
  l2 = lengthPix * 0.5
57
57
  svg.append('line')
58
+ .classed('gridviz-legend-symbol', true)
58
59
  .attr('x1', dc - cos * l2)
59
60
  .attr('y1', dc - sin * l2)
60
61
  .attr('x2', dc + cos * l2)
@@ -71,6 +71,7 @@ export class SizeLegend extends Legend {
71
71
  const svg = () =>
72
72
  d
73
73
  .append('svg')
74
+ .classed('gridviz-legend-graphic', true)
74
75
  .attr('width', sizePix + this.strokeWidth + 2)
75
76
  .attr('height', sizePix + this.strokeWidth + 2)
76
77
  .style('', 'inline-block')
@@ -78,6 +79,7 @@ export class SizeLegend extends Legend {
78
79
  if (this.shape === 'square') {
79
80
  svg()
80
81
  .append('rect')
82
+ .classed('gridviz-legend-symbol', true)
81
83
  .attr('x', 0)
82
84
  .attr('y', 0)
83
85
  .attr('width', sizePix)
@@ -90,6 +92,7 @@ export class SizeLegend extends Legend {
90
92
  const r = (sizePix + this.strokeWidth) * 0.5
91
93
  svg()
92
94
  .append('circle')
95
+ .classed('gridviz-legend-symbol', true)
93
96
  .attr('cx', r + 1)
94
97
  .attr('cy', r + 1)
95
98
  .attr('r', r)
@@ -109,6 +112,7 @@ export class SizeLegend extends Legend {
109
112
 
110
113
  const svg = d
111
114
  .append('svg')
115
+ .classed('gridviz-legend-graphic', true)
112
116
  .attr('width', lengthPix)
113
117
  .attr('height', sizePix)
114
118
  .style('', 'inline-block')
@@ -116,6 +120,7 @@ export class SizeLegend extends Legend {
116
120
  //TODO orientation
117
121
  //<line x1="0" y1="0" x2="200" y2="200" style="stroke:rgb(255,0,0);stroke-width:2" />
118
122
  svg.append('line')
123
+ .classed('gridviz-legend-symbol', true)
119
124
  .attr('x1', 0)
120
125
  .attr('y1', sizePix / 2)
121
126
  .attr('x2', lengthPix)
@@ -57,6 +57,7 @@ export class TernaryLegend extends Legend {
57
57
  //make svg element
58
58
  const svg = this.div
59
59
  .append('svg')
60
+ .classed('gridviz-legend-graphic', true)
60
61
  .attr('width', w + selectionStrokeWidth)
61
62
  .attr('height', h + 4 * padding + 2 * fontSize)
62
63
 
@@ -88,6 +89,7 @@ export class TernaryLegend extends Legend {
88
89
  //triangle group
89
90
  const g = svg
90
91
  .append('g')
92
+ .classed('gridviz-legend-triangle', true)
91
93
  .attr(
92
94
  'transform',
93
95
  'translate(' +
@@ -108,7 +110,7 @@ export class TernaryLegend extends Legend {
108
110
  .style('cursor', this.interactive ? 'pointer' : null)
109
111
  .on('mouseover', function (e) {
110
112
  /*this.parentNode.appendChild(this); select(this).attr("stroke-width", selectionStrokeWidth);*/
111
- select(this).attr('fill', selectionColor)
113
+ select(this).classed('gridviz-legend-swatch--active', true).attr('fill', selectionColor)
112
114
  // show only the cells of this class on the map
113
115
  legend.highlight([color])
114
116
  if (!tt || !text) return
@@ -118,7 +120,7 @@ export class TernaryLegend extends Legend {
118
120
  })
119
121
  .on('mouseout', function () {
120
122
  /*select(this).attr("stroke-width", 0);*/
121
- select(this).attr('fill', color)
123
+ select(this).classed('gridviz-legend-swatch--active', false).attr('fill', color)
122
124
  legend.highlight(null)
123
125
  if (tt) tt.hide()
124
126
  })
@@ -16,10 +16,33 @@ import { scaleQuantile } from 'd3-scale'
16
16
  * @module utils
17
17
  */
18
18
 
19
+ /**
20
+ * The inverse of a stretching function, within [0,1]: its own invert function if it has one,
21
+ * else found by bisection (a stretching is increasing).
22
+ * @param {function(number):number} stretching
23
+ * @param {number} t
24
+ * @returns {number}
25
+ */
26
+ const invertStretching = (stretching, t) => {
27
+ // @ts-ignore
28
+ if (stretching.invert) return stretching.invert(t)
29
+ let lo = 0,
30
+ hi = 1
31
+ for (let i = 0; i < 50; i++) {
32
+ const mid = (lo + hi) / 2
33
+ if (stretching(mid) < t) lo = mid
34
+ else hi = mid
35
+ }
36
+ return (lo + hi) / 2
37
+ }
38
+
19
39
  /**
20
40
  * Generic function for view scale - continuous or discrete
21
41
  *
22
42
  * @param {{ valueFunction:function(import("../core/Dataset").Cell):number, minValue?:number, minSizePix?:number, maxSizeFactor?:number, range?:[number, number], domain?:[number, number], stretching?:function(number):number, classNumber?:number}} opts
43
+ * The scale has an invert(t) function, like the one of viewScaleColor: it returns the value of the domain
44
+ * for a t within [0,1] (what a legend needs to write its labels).
45
+ *
23
46
  * @returns {function(Array.<import("../core/Dataset").Cell>):Scale}
24
47
  */
25
48
  export const viewScale = (opts) => {
@@ -38,7 +61,7 @@ export const viewScale = (opts) => {
38
61
  domainMin = domain[0]
39
62
  const rangeSize = range[1] - range[0],
40
63
  rangeMin = range[0]
41
- return (t) => {
64
+ const scale = (t) => {
42
65
  //scale to [0,1]
43
66
  t = (t - domainMin) / domainSize
44
67
  //stretch
@@ -48,6 +71,12 @@ export const viewScale = (opts) => {
48
71
  //scale to range
49
72
  return rangeMin + t * rangeSize
50
73
  }
74
+ //the domain value for a position t within [0,1], as the invert of viewScaleColor
75
+ scale.invert = (t) => {
76
+ if (stretching) t = invertStretching(stretching, t)
77
+ return domainMin + t * domainSize
78
+ }
79
+ return scale
51
80
  }
52
81
  }
53
82