@uwdata/mosaic-plot 0.7.0 → 0.8.0
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/mosaic-plot.js +3127 -4498
- package/dist/mosaic-plot.min.js +14 -14
- package/package.json +7 -7
- package/src/interactors/Interval1D.js +6 -4
- package/src/interactors/Interval2D.js +3 -1
- package/src/interactors/Nearest.js +11 -10
- package/src/interactors/PanZoom.js +3 -2
- package/src/interactors/Toggle.js +12 -11
- package/src/interactors/util/patchScreenCTM.js +2 -0
- package/src/legend.js +135 -27
- package/src/marks/ConnectedMark.js +6 -0
- package/src/marks/ContourMark.js +36 -16
- package/src/marks/DenseLineMark.js +9 -5
- package/src/marks/Density1DMark.js +22 -13
- package/src/marks/Density2DMark.js +33 -18
- package/src/marks/GeoMark.js +7 -8
- package/src/marks/Grid2DMark.js +58 -28
- package/src/marks/HexbinMark.js +10 -2
- package/src/marks/Mark.js +43 -11
- package/src/marks/RasterMark.js +55 -23
- package/src/marks/RasterTileMark.js +33 -20
- package/src/marks/RegressionMark.js +69 -34
- package/src/marks/util/grid.js +94 -86
- package/src/marks/util/handle-param.js +10 -11
- package/src/marks/util/stats.js +33 -1
- package/src/marks/util/to-data-columns.js +71 -0
- package/src/plot-attributes.js +11 -3
- package/src/plot-renderer.js +24 -4
- package/src/plot.js +20 -0
- package/src/marks/util/interpolate.js +0 -205
- package/src/marks/util/to-data-array.js +0 -50
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uwdata/mosaic-plot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A Mosaic-powered plotting framework based on Observable Plot.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"data",
|
|
@@ -23,16 +23,16 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"prebuild": "rimraf dist && mkdir dist",
|
|
25
25
|
"build": "node ../../esbuild.js mosaic-plot",
|
|
26
|
-
"lint": "eslint src test
|
|
26
|
+
"lint": "eslint src test",
|
|
27
27
|
"test": "mocha 'test/**/*-test.js'",
|
|
28
28
|
"prepublishOnly": "npm run test && npm run lint && npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@observablehq/plot": "^0.6.
|
|
32
|
-
"@uwdata/mosaic-core": "^0.
|
|
33
|
-
"@uwdata/mosaic-sql": "^0.
|
|
34
|
-
"d3": "^7.
|
|
31
|
+
"@observablehq/plot": "^0.6.14",
|
|
32
|
+
"@uwdata/mosaic-core": "^0.8.0",
|
|
33
|
+
"@uwdata/mosaic-sql": "^0.8.0",
|
|
34
|
+
"d3": "^7.9.0",
|
|
35
35
|
"isoformat": "^0.2.1"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "a24b4c9f7dfa1c38c6af96ec17e075326c1af9b0"
|
|
38
38
|
}
|
|
@@ -11,7 +11,7 @@ export class Interval1D {
|
|
|
11
11
|
constructor(mark, {
|
|
12
12
|
channel,
|
|
13
13
|
selection,
|
|
14
|
-
field,
|
|
14
|
+
field = undefined,
|
|
15
15
|
pixelSize = 1,
|
|
16
16
|
peers = true,
|
|
17
17
|
brush: style
|
|
@@ -61,7 +61,7 @@ export class Interval1D {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
init(svg) {
|
|
64
|
+
init(svg, root) {
|
|
65
65
|
const { brush, channel, style } = this;
|
|
66
66
|
this.scale = svg.scale(channel);
|
|
67
67
|
|
|
@@ -70,7 +70,7 @@ export class Interval1D {
|
|
|
70
70
|
brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
|
|
71
71
|
|
|
72
72
|
const facets = select(svg).selectAll('g[aria-label="facet"]');
|
|
73
|
-
|
|
73
|
+
root = facets.size() ? facets : select(root ?? svg);
|
|
74
74
|
this.g = root
|
|
75
75
|
.append('g')
|
|
76
76
|
.attr('class', `interval-${channel}`)
|
|
@@ -85,6 +85,8 @@ export class Interval1D {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
svg.addEventListener('pointerenter',
|
|
88
|
+
svg.addEventListener('pointerenter', evt => {
|
|
89
|
+
if (!evt.buttons) this.activate();
|
|
90
|
+
});
|
|
89
91
|
}
|
|
90
92
|
}
|
|
@@ -97,6 +97,8 @@ export class Interval2D {
|
|
|
97
97
|
this.g.call(brush.moveSilent, [[x1, y1], [x2, y2]]);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
svg.addEventListener('pointerenter',
|
|
100
|
+
svg.addEventListener('pointerenter', evt => {
|
|
101
|
+
if (!evt.buttons) this.activate();
|
|
102
|
+
});
|
|
101
103
|
}
|
|
102
104
|
}
|
|
@@ -41,26 +41,27 @@ export class Nearest {
|
|
|
41
41
|
|
|
42
42
|
root.on('pointerdown pointermove', function(evt) {
|
|
43
43
|
const [x, y] = pointer(evt, this);
|
|
44
|
-
const z = findNearest(data
|
|
44
|
+
const z = findNearest(data.columns[key], scale.invert(channel === 'x' ? x : y));
|
|
45
45
|
selection.update(param ? z : that.clause(z));
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
if (param) return;
|
|
49
|
-
svg.addEventListener('pointerenter',
|
|
50
|
-
this.selection.activate(this.clause(0));
|
|
49
|
+
svg.addEventListener('pointerenter', evt => {
|
|
50
|
+
if (!evt.buttons) this.selection.activate(this.clause(0));
|
|
51
51
|
});
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function findNearest(
|
|
55
|
+
function findNearest(values, value) {
|
|
56
56
|
let dist = Infinity;
|
|
57
|
-
let
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
let nearest;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < values.length; ++i) {
|
|
60
|
+
const delta = Math.abs(values[i] - value);
|
|
60
61
|
if (delta < dist) {
|
|
61
62
|
dist = delta;
|
|
62
|
-
|
|
63
|
+
nearest = values[i];
|
|
63
64
|
}
|
|
64
|
-
}
|
|
65
|
-
return
|
|
65
|
+
}
|
|
66
|
+
return nearest;
|
|
66
67
|
}
|
|
@@ -86,8 +86,9 @@ export class PanZoom {
|
|
|
86
86
|
|
|
87
87
|
if (panx || pany) {
|
|
88
88
|
let enter = false;
|
|
89
|
-
element.addEventListener('
|
|
89
|
+
element.addEventListener('pointerenter', evt => {
|
|
90
90
|
if (enter) return; else enter = true;
|
|
91
|
+
if (evt.buttons) return; // don't activate if mouse down
|
|
91
92
|
if (panx) {
|
|
92
93
|
const { xscale, xfield } = this;
|
|
93
94
|
xsel.activate(this.clause(xscale.domain, xfield, xscale));
|
|
@@ -97,7 +98,7 @@ export class PanZoom {
|
|
|
97
98
|
ysel.activate(this.clause(yscale.domain, yfield, yscale));
|
|
98
99
|
}
|
|
99
100
|
});
|
|
100
|
-
element.addEventListener('
|
|
101
|
+
element.addEventListener('pointerleave', () => enter = false);
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { and, or, isNotDistinct, literal } from '@uwdata/mosaic-sql';
|
|
2
2
|
|
|
3
3
|
export class Toggle {
|
|
4
|
+
/**
|
|
5
|
+
* @param {*} mark The mark to interact with.
|
|
6
|
+
* @param {*} options The interactor options.
|
|
7
|
+
*/
|
|
4
8
|
constructor(mark, {
|
|
5
9
|
selection,
|
|
6
10
|
channels,
|
|
@@ -11,12 +15,12 @@ export class Toggle {
|
|
|
11
15
|
this.selection = selection;
|
|
12
16
|
this.peers = peers;
|
|
13
17
|
this.channels = channels.map(c => {
|
|
14
|
-
const q = c === 'color' ? ['fill', 'stroke']
|
|
18
|
+
const q = c === 'color' ? ['color', 'fill', 'stroke']
|
|
15
19
|
: c === 'x' ? ['x', 'x1', 'x2']
|
|
16
20
|
: c === 'y' ? ['y', 'y1', 'y2']
|
|
17
21
|
: [c];
|
|
18
22
|
for (let i = 0; i < q.length; ++i) {
|
|
19
|
-
const f = mark.channelField(q[i]);
|
|
23
|
+
const f = mark.channelField(q[i], { exact: true });
|
|
20
24
|
if (f) return {
|
|
21
25
|
field: f.field?.basis || f.field,
|
|
22
26
|
as: f.as
|
|
@@ -51,13 +55,9 @@ export class Toggle {
|
|
|
51
55
|
|
|
52
56
|
init(svg, selector, accessor) {
|
|
53
57
|
const { mark, channels, selection } = this;
|
|
54
|
-
const { data } = mark;
|
|
55
|
-
accessor
|
|
56
|
-
|
|
57
|
-
return channels.map(c => datum[c.as]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
selector = selector || `[data-index="${mark.index}"]`;
|
|
58
|
+
const { data: { columns = {} } = {} } = mark;
|
|
59
|
+
accessor ??= target => channels.map(c => columns[c.as][target.__data__]);
|
|
60
|
+
selector ??= `[data-index="${mark.index}"]`;
|
|
61
61
|
const groups = new Set(svg.querySelectorAll(selector));
|
|
62
62
|
|
|
63
63
|
svg.addEventListener('pointerdown', evt => {
|
|
@@ -67,7 +67,7 @@ export class Toggle {
|
|
|
67
67
|
|
|
68
68
|
if (isTargetElement(groups, target)) {
|
|
69
69
|
const point = accessor(target);
|
|
70
|
-
if (evt.shiftKey && state?.length) {
|
|
70
|
+
if ((evt.shiftKey || evt.metaKey) && state?.length) {
|
|
71
71
|
value = state.filter(s => neq(s, point));
|
|
72
72
|
if (value.length === state.length) value.push(point);
|
|
73
73
|
} else if (state?.length === 1 && !neq(state[0], point)) {
|
|
@@ -83,7 +83,8 @@ export class Toggle {
|
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
svg.addEventListener('pointerenter',
|
|
86
|
+
svg.addEventListener('pointerenter', evt => {
|
|
87
|
+
if (evt.buttons) return;
|
|
87
88
|
this.selection.activate(this.clause([this.channels.map(() => 0)]));
|
|
88
89
|
});
|
|
89
90
|
}
|
package/src/legend.js
CHANGED
|
@@ -1,40 +1,35 @@
|
|
|
1
|
+
import { scale } from '@observablehq/plot';
|
|
2
|
+
import { Interval1D } from './interactors/Interval1D.js';
|
|
1
3
|
import { Toggle } from './interactors/Toggle.js';
|
|
2
4
|
|
|
5
|
+
const TOGGLE_SELECTOR = ':scope > div, :scope > span';
|
|
6
|
+
const SWATCH = 'swatch';
|
|
7
|
+
const RAMP = 'ramp';
|
|
8
|
+
|
|
3
9
|
export class Legend {
|
|
4
10
|
constructor(channel, options) {
|
|
5
|
-
const { as, ...rest } = options;
|
|
11
|
+
const { as, field, ...rest } = options;
|
|
6
12
|
this.channel = channel;
|
|
7
13
|
this.options = { label: null, ...rest };
|
|
14
|
+
this.type = null;
|
|
15
|
+
this.handler = null;
|
|
8
16
|
this.selection = as;
|
|
17
|
+
this.field = field;
|
|
18
|
+
this.legend = null;
|
|
9
19
|
|
|
10
20
|
this.element = document.createElement('div');
|
|
11
21
|
this.element.setAttribute('class', 'legend');
|
|
12
|
-
this.element
|
|
22
|
+
Object.assign(this.element, { value: this });
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
setPlot(plot) {
|
|
16
|
-
|
|
17
|
-
const mark = findMark(plot, channel);
|
|
18
|
-
if (this.selection && mark) {
|
|
19
|
-
this.handler = new Toggle(mark, { selection, channels: [channel] });
|
|
20
|
-
this.selection.addEventListener('value', () => this.update());
|
|
21
|
-
}
|
|
26
|
+
this.plot = plot;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
init(svg) {
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
? options
|
|
29
|
-
: { marginTop: 1, tickSize: 2, height: 28, ...options };
|
|
30
|
-
this.legend = svg.legend(channel, opt);
|
|
31
|
-
|
|
32
|
-
if (handler) {
|
|
33
|
-
handler.init(this.legend, ':scope > div', el => [el.__data__]);
|
|
34
|
-
this.update();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
this.element.replaceChildren(this.legend);
|
|
30
|
+
// createLegend sets this.legend, may set this.handler
|
|
31
|
+
const el = createLegend(this, svg);
|
|
32
|
+
this.element.replaceChildren(el);
|
|
38
33
|
return this.element;
|
|
39
34
|
}
|
|
40
35
|
|
|
@@ -42,7 +37,7 @@ export class Legend {
|
|
|
42
37
|
if (!this.legend) return;
|
|
43
38
|
const { value } = this.selection;
|
|
44
39
|
const curr = value && value.length ? new Set(value.map(v => v[0])) : null;
|
|
45
|
-
const nodes = this.legend.querySelectorAll(
|
|
40
|
+
const nodes = this.legend.querySelectorAll(TOGGLE_SELECTOR);
|
|
46
41
|
for (const node of nodes) {
|
|
47
42
|
const selected = curr ? curr.has(node.__data__) : true;
|
|
48
43
|
node.style.opacity = selected ? 1 : 0.2;
|
|
@@ -50,17 +45,130 @@ export class Legend {
|
|
|
50
45
|
}
|
|
51
46
|
}
|
|
52
47
|
|
|
53
|
-
function
|
|
48
|
+
function createLegend(legend, svg) {
|
|
49
|
+
const { channel, options, selection } = legend;
|
|
50
|
+
const scale = svg.scale(channel);
|
|
51
|
+
const type = scale.type === 'ordinal' ? SWATCH : RAMP;
|
|
52
|
+
|
|
53
|
+
// labels for swatch legends are not yet supported by Plot
|
|
54
|
+
// track here: https://github.com/observablehq/plot/issues/834
|
|
55
|
+
// for consistent layout, adjust sizing when there is no label
|
|
56
|
+
const opt = type === SWATCH ? options
|
|
57
|
+
: options.label ? { tickSize: 2, ...options }
|
|
58
|
+
: { tickSize: 2, marginTop: 1, height: 29, ...options };
|
|
59
|
+
|
|
60
|
+
// instantiate new legend element, bind to Legend class
|
|
61
|
+
const el = svg.legend(channel, opt);
|
|
62
|
+
legend.legend = el;
|
|
63
|
+
|
|
64
|
+
// if this is an interactive legend, add a scale lookup function
|
|
65
|
+
// this allows interval interactors to access encoding information
|
|
66
|
+
let interactive = !!selection;
|
|
67
|
+
if (interactive && type === RAMP) {
|
|
68
|
+
const width = opt.width ?? 240; // 240 is default ramp length
|
|
69
|
+
const spatial = spatialScale(scale, width);
|
|
70
|
+
if (spatial) {
|
|
71
|
+
el.scale = function(type) {
|
|
72
|
+
return type === 'x' ? { range: [0, width] }
|
|
73
|
+
: type === 'y' ? { range: [-10, 0] }
|
|
74
|
+
: type === channel ? spatial
|
|
75
|
+
: undefined;
|
|
76
|
+
};
|
|
77
|
+
} else {
|
|
78
|
+
// spatial scale construction failed, disable interaction
|
|
79
|
+
interactive = false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// initialize interactors to use updated legend element
|
|
84
|
+
if (interactive) {
|
|
85
|
+
const handler = getInteractor(legend, type);
|
|
86
|
+
if (type === SWATCH) {
|
|
87
|
+
handler.init(el, TOGGLE_SELECTOR, el => [el.__data__]);
|
|
88
|
+
legend.update();
|
|
89
|
+
} else {
|
|
90
|
+
handler.init(el, el.querySelector('g:last-of-type'));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return el;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getInteractor(legend, type) {
|
|
98
|
+
const { channel, handler, selection } = legend;
|
|
99
|
+
|
|
100
|
+
// exit early if already instantiated
|
|
101
|
+
if (handler) return handler;
|
|
102
|
+
|
|
103
|
+
// otherwise instantiate an appropriate interactor
|
|
104
|
+
const mark = interactorMark(legend);
|
|
105
|
+
if (type === SWATCH) {
|
|
106
|
+
legend.handler = new Toggle(mark, { selection, channels: [channel] });
|
|
107
|
+
selection.addEventListener('value', () => legend.update());
|
|
108
|
+
} else {
|
|
109
|
+
const brush = { fill: 'none', stroke: 'currentColor' };
|
|
110
|
+
legend.handler = new Interval1D(mark, { selection, channel, brush });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return legend.handler;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// generate a faux mark to pass to an interactor
|
|
117
|
+
function interactorMark(legend) {
|
|
118
|
+
const { channel, plot } = legend;
|
|
119
|
+
const field = legend.field ?? findField(plot.marks, channel) ?? 'value';
|
|
120
|
+
if (field) {
|
|
121
|
+
const f = { field };
|
|
122
|
+
return { plot, channelField: c => channel === c ? f : undefined };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// search marks for a backing data field for the legend
|
|
127
|
+
function findField(marks, channel) {
|
|
54
128
|
const channels = channel === 'color' ? ['fill', 'stroke']
|
|
55
129
|
: channel === 'opacity' ? ['opacity', 'fillOpacity', 'strokeOpacity']
|
|
56
130
|
: null;
|
|
57
131
|
if (channels == null) return null;
|
|
58
132
|
for (let i = marks.length - 1; i > -1; --i) {
|
|
59
|
-
for (const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
133
|
+
for (const c of channels) {
|
|
134
|
+
const field = marks[i].channelField(c, { exact: true });
|
|
135
|
+
if (field) return field.field;
|
|
63
136
|
}
|
|
64
137
|
}
|
|
65
138
|
return null;
|
|
66
139
|
}
|
|
140
|
+
|
|
141
|
+
// generate a spatial scale to brush within color or opacity ramps
|
|
142
|
+
function spatialScale(sourceScale, width) {
|
|
143
|
+
// separate out reusable parts of the scale definition
|
|
144
|
+
// eslint-disable-next-line no-unused-vars
|
|
145
|
+
const { apply, invert, interpolate, ...rest } = sourceScale;
|
|
146
|
+
|
|
147
|
+
// extract basic source scale type
|
|
148
|
+
let src = sourceScale.type;
|
|
149
|
+
if (src.startsWith('diverging-')) src = src.slice(11);
|
|
150
|
+
|
|
151
|
+
// determine spatial scale type
|
|
152
|
+
let type;
|
|
153
|
+
switch (src) {
|
|
154
|
+
case 'log':
|
|
155
|
+
case 'pow':
|
|
156
|
+
case 'sqrt':
|
|
157
|
+
case 'symlog':
|
|
158
|
+
type = src;
|
|
159
|
+
break;
|
|
160
|
+
case 'threshold':
|
|
161
|
+
case 'quantize':
|
|
162
|
+
case 'quantile':
|
|
163
|
+
// these scales do not expose an invert method
|
|
164
|
+
// the legends use color ramps with discrete swatches
|
|
165
|
+
// in the future we could try to support toggle-style
|
|
166
|
+
// interactions that map to threshold range selections
|
|
167
|
+
console.warn(`Legends do not yet support ${src} scales.`);
|
|
168
|
+
return null;
|
|
169
|
+
default:
|
|
170
|
+
type = 'linear';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return scale({ x: { ...rest, type, range: [0, width] } });
|
|
174
|
+
}
|
|
@@ -11,6 +11,11 @@ export class ConnectedMark extends Mark {
|
|
|
11
11
|
this.dim = dim;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Return a query specifying the data needed by this Mark client.
|
|
16
|
+
* @param {*} [filter] The filtering criteria to apply in the query.
|
|
17
|
+
* @returns {*} The client query
|
|
18
|
+
*/
|
|
14
19
|
query(filter = []) {
|
|
15
20
|
const { plot, dim, source } = this;
|
|
16
21
|
const { optimize = true } = source.options || {};
|
|
@@ -28,6 +33,7 @@ export class ConnectedMark extends Mark {
|
|
|
28
33
|
const [lo, hi] = filteredExtent(filter, field) || [min, max];
|
|
29
34
|
const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
|
|
30
35
|
const cols = q.select()
|
|
36
|
+
// @ts-ignore
|
|
31
37
|
.map(c => c.as)
|
|
32
38
|
.filter(c => c !== as && c !== value);
|
|
33
39
|
return m4(q, expr, as, value, cols);
|
package/src/marks/ContourMark.js
CHANGED
|
@@ -13,8 +13,11 @@ export class ContourMark extends Grid2DMark {
|
|
|
13
13
|
pixelSize: 2,
|
|
14
14
|
...channels
|
|
15
15
|
});
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
|
|
17
|
+
/** @type {number|number[]} */
|
|
18
|
+
this.thresholds = handleParam(thresholds, value => {
|
|
19
|
+
this.thresholds = value;
|
|
20
|
+
return this.grids ? this.contours().update() : null;
|
|
18
21
|
});
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -23,12 +26,16 @@ export class ContourMark extends Grid2DMark {
|
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
contours() {
|
|
26
|
-
const { bins, densityMap,
|
|
29
|
+
const { bins, densityMap, grids, thresholds, plot } = this;
|
|
30
|
+
const { numRows, columns } = grids;
|
|
27
31
|
|
|
28
|
-
let
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
tz =
|
|
32
|
+
let t = thresholds;
|
|
33
|
+
let tz;
|
|
34
|
+
if (Array.isArray(t)) {
|
|
35
|
+
tz = t;
|
|
36
|
+
} else {
|
|
37
|
+
const [, hi] = gridDomainContinuous(columns.density);
|
|
38
|
+
tz = Array.from({length: t - 1}, (_, i) => (hi * (i + 1)) / t);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
if (densityMap.fill || densityMap.stroke) {
|
|
@@ -51,18 +58,27 @@ export class ContourMark extends Grid2DMark {
|
|
|
51
58
|
const contour = contours().size(bins);
|
|
52
59
|
|
|
53
60
|
// generate contours
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
const data = this.contourData = Array(numRows * tz.length);
|
|
62
|
+
const { density, ...groupby } = columns;
|
|
63
|
+
const groups = Object.entries(groupby);
|
|
64
|
+
for (let i = 0, k = 0; i < numRows; ++i) {
|
|
65
|
+
const grid = density[i];
|
|
66
|
+
const rest = groups.reduce((o, [name, col]) => (o[name] = col[i], o), {});
|
|
67
|
+
for (let j = 0; j < tz.length; ++j, ++k) {
|
|
68
|
+
// annotate contour geojson with cell groupby fields
|
|
69
|
+
// d3-contour already adds a threshold "value" property
|
|
70
|
+
data[k] = Object.assign(
|
|
71
|
+
transform(contour.contour(grid, tz[j]), x, y),
|
|
72
|
+
rest
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
60
76
|
|
|
61
77
|
return this;
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
plotSpecs() {
|
|
65
|
-
const { type, channels, densityMap, data } = this;
|
|
81
|
+
const { type, channels, densityMap, contourData: data } = this;
|
|
66
82
|
const options = {};
|
|
67
83
|
for (const c of channels) {
|
|
68
84
|
const { channel } = c;
|
|
@@ -70,8 +86,12 @@ export class ContourMark extends Grid2DMark {
|
|
|
70
86
|
options[channel] = channelOption(c);
|
|
71
87
|
}
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
// d3-contour adds a threshold "value" property
|
|
90
|
+
// here we ensure requested density values are encoded
|
|
91
|
+
for (const channel in densityMap) {
|
|
92
|
+
if (!densityMap[channel]) continue;
|
|
93
|
+
options[channel] = channelOption({ channel, as: 'value' });
|
|
94
|
+
}
|
|
75
95
|
return [{ type, data, options }];
|
|
76
96
|
}
|
|
77
97
|
}
|
|
@@ -8,14 +8,18 @@ export class DenseLineMark extends RasterMark {
|
|
|
8
8
|
constructor(source, options) {
|
|
9
9
|
const { normalize = true, ...rest } = options;
|
|
10
10
|
super(source, rest);
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
/** @type {boolean} */
|
|
13
|
+
this.normalize = handleParam(normalize, value => {
|
|
14
|
+
return (this.normalize = value, this.requestUpdate());
|
|
15
|
+
});
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
query(filter = []) {
|
|
15
|
-
const { channels, normalize, source,
|
|
16
|
-
const [nx, ny] = this.bins = this.binDimensions(
|
|
17
|
-
const [x] = binExpr(this, 'x', nx, extentX(this, filter),
|
|
18
|
-
const [y] = binExpr(this, 'y', ny, extentY(this, filter),
|
|
19
|
+
const { channels, normalize, source, pad } = this;
|
|
20
|
+
const [nx, ny] = this.bins = this.binDimensions();
|
|
21
|
+
const [x] = binExpr(this, 'x', nx, extentX(this, filter), pad);
|
|
22
|
+
const [y] = binExpr(this, 'y', ny, extentY(this, filter), pad);
|
|
19
23
|
|
|
20
24
|
const q = Query
|
|
21
25
|
.from(source.table)
|
|
@@ -6,6 +6,7 @@ import { extentX, extentY, xext, yext } from './util/extent.js';
|
|
|
6
6
|
import { grid1d } from './util/grid.js';
|
|
7
7
|
import { handleParam } from './util/handle-param.js';
|
|
8
8
|
import { Mark, channelOption, markQuery } from './Mark.js';
|
|
9
|
+
import { toDataColumns } from './util/to-data-columns.js';
|
|
9
10
|
|
|
10
11
|
export class Density1DMark extends Mark {
|
|
11
12
|
constructor(type, source, options) {
|
|
@@ -15,9 +16,15 @@ export class Density1DMark extends Mark {
|
|
|
15
16
|
super(type, source, channels, dim === 'x' ? xext : yext);
|
|
16
17
|
this.dim = dim;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return this.
|
|
19
|
+
/** @type {number} */
|
|
20
|
+
this.bins = handleParam(bins, value => {
|
|
21
|
+
return (this.bins = value, this.requestUpdate());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** @type {number} */
|
|
25
|
+
this.bandwidth = handleParam(bandwidth, value => {
|
|
26
|
+
this.bandwidth = value;
|
|
27
|
+
return this.grid ? this.convolve().update() : null;
|
|
21
28
|
});
|
|
22
29
|
}
|
|
23
30
|
|
|
@@ -39,7 +46,8 @@ export class Density1DMark extends Mark {
|
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
queryResult(data) {
|
|
42
|
-
|
|
49
|
+
const { columns: { index, density } } = toDataColumns(data);
|
|
50
|
+
this.grid = grid1d(this.bins, index, density);
|
|
43
51
|
return this.convolve();
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -53,29 +61,30 @@ export class Density1DMark extends Mark {
|
|
|
53
61
|
const result = dericheConv1d(config, grid, bins);
|
|
54
62
|
|
|
55
63
|
// map smoothed grid values to sample data points
|
|
56
|
-
const points = this.data = [];
|
|
57
64
|
const v = dim === 'x' ? 'y' : 'x';
|
|
58
65
|
const b = this.channelField(dim).as;
|
|
59
66
|
const b0 = +lo;
|
|
60
67
|
const delta = (hi - b0) / (bins - 1);
|
|
61
68
|
const scale = 1 / delta;
|
|
69
|
+
|
|
70
|
+
const _b = new Float64Array(bins);
|
|
71
|
+
const _v = new Float64Array(bins);
|
|
62
72
|
for (let i = 0; i < bins; ++i) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
[v]: result[i] * scale
|
|
66
|
-
});
|
|
73
|
+
_b[i] = b0 + i * delta;
|
|
74
|
+
_v[i] = result[i] * scale;
|
|
67
75
|
}
|
|
76
|
+
this.data = { numRows: bins, columns: { [b]: _b, [v]: _v } };
|
|
68
77
|
|
|
69
78
|
return this;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
plotSpecs() {
|
|
73
|
-
const { type, data, channels, dim } = this;
|
|
74
|
-
const options = dim === 'x' ? { y:
|
|
82
|
+
const { type, data: { numRows: length, columns }, channels, dim } = this;
|
|
83
|
+
const options = dim === 'x' ? { y: columns.y } : { x: columns.x };
|
|
75
84
|
for (const c of channels) {
|
|
76
|
-
options[c.channel] = channelOption(c);
|
|
85
|
+
options[c.channel] = channelOption(c, columns);
|
|
77
86
|
}
|
|
78
|
-
return [{ type, data, options }];
|
|
87
|
+
return [{ type, data: { length }, options }];
|
|
79
88
|
}
|
|
80
89
|
}
|
|
81
90
|
|