@uwdata/mosaic-plot 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-plot",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "A Mosaic-powered plotting framework based on Observable Plot.",
5
5
  "keywords": [
6
6
  "data",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@observablehq/plot": "^0.6.14",
32
- "@uwdata/mosaic-core": "^0.8.0",
33
- "@uwdata/mosaic-sql": "^0.8.0",
32
+ "@uwdata/mosaic-core": "^0.9.0",
33
+ "@uwdata/mosaic-sql": "^0.9.0",
34
34
  "d3": "^7.9.0",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "a24b4c9f7dfa1c38c6af96ec17e075326c1af9b0"
37
+ "gitHead": "89bb9b0dfa747aed691eaeba35379525a6764c61"
38
38
  }
package/src/index.js CHANGED
@@ -8,6 +8,7 @@ export { ContourMark } from './marks/ContourMark.js';
8
8
  export { DenseLineMark } from './marks/DenseLineMark.js';
9
9
  export { Density1DMark } from './marks/Density1DMark.js';
10
10
  export { Density2DMark } from './marks/Density2DMark.js';
11
+ export { ErrorBarMark } from './marks/ErrorBarMark.js';
11
12
  export { GeoMark } from './marks/GeoMark.js';
12
13
  export { Grid2DMark } from './marks/Grid2DMark.js';
13
14
  export { HexbinMark } from './marks/HexbinMark.js';
@@ -69,7 +69,8 @@ export class Highlight {
69
69
  for (let i = 0; i < nodes.length; ++i) {
70
70
  const node = nodes[i];
71
71
  const base = values[i];
72
- const t = test(node.__data__);
72
+ const data = node.__data__;
73
+ const t = test(Array.isArray(data) ? data[0] : data);
73
74
  // TODO? handle inherited values / remove attributes
74
75
  for (let j = 0; j < channels.length; ++j) {
75
76
  const [attr, value] = channels[j];
@@ -91,9 +92,11 @@ async function predicateFunction(mark, selection) {
91
92
 
92
93
  const s = { __: and(pred) };
93
94
  const q = mark.query(filter);
94
- const p = q.groupby().length ? q.select(s) : q.$select(s);
95
+ (q.queries || [q]).forEach(q => {
96
+ q.groupby().length ? q.select(s) : q.$select(s);
97
+ });
95
98
 
96
- const data = await mark.coordinator.query(p);
99
+ const data = await mark.coordinator.query(q);
97
100
  const v = data.getChild?.('__');
98
101
  return !(data.numRows || data.length) ? (() => false)
99
102
  : v ? (i => v.get(i))
@@ -1,5 +1,5 @@
1
- import { select, min, max } from 'd3';
2
- import { isBetween } from '@uwdata/mosaic-sql';
1
+ import { interval } from '@uwdata/mosaic-core';
2
+ import { ascending, min, max, select } from 'd3';
3
3
  import { brushX, brushY } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
@@ -52,13 +52,12 @@ export class Interval1D {
52
52
 
53
53
  clause(value) {
54
54
  const { mark, pixelSize, field, scale } = this;
55
- return {
55
+ return interval(field, value, {
56
56
  source: this,
57
- schema: { type: 'interval', pixelSize, scales: [scale] },
58
57
  clients: this.peers ? mark.plot.markSet : new Set().add(mark),
59
- value,
60
- predicate: value ? isBetween(field, value) : null
61
- };
58
+ scale,
59
+ pixelSize
60
+ });
62
61
  }
63
62
 
64
63
  init(svg, root) {
@@ -69,6 +68,7 @@ export class Interval1D {
69
68
  const ry = svg.scale('y').range;
70
69
  brush.extent([[min(rx), min(ry)], [max(rx), max(ry)]]);
71
70
 
71
+ const range = this.value?.map(this.scale.apply).sort(ascending);
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
@@ -76,7 +76,7 @@ export class Interval1D {
76
76
  .attr('class', `interval-${channel}`)
77
77
  .each(patchScreenCTM)
78
78
  .call(brush)
79
- .call(brush.moveSilent, this.value?.map(this.scale.apply));
79
+ .call(brush.moveSilent, range);
80
80
 
81
81
  if (style) {
82
82
  const brushes = this.g.selectAll('rect.selection');
@@ -1,5 +1,5 @@
1
- import { select, min, max } from 'd3';
2
- import { and, isBetween } from '@uwdata/mosaic-sql';
1
+ import { intervals } from '@uwdata/mosaic-core';
2
+ import { ascending, min, max, select } from 'd3';
3
3
  import { brush } from './util/brush.js';
4
4
  import { closeTo } from './util/close-to.js';
5
5
  import { getField } from './util/get-field.js';
@@ -7,8 +7,6 @@ import { invert } from './util/invert.js';
7
7
  import { patchScreenCTM } from './util/patchScreenCTM.js';
8
8
  import { sanitizeStyles } from './util/sanitize-styles.js';
9
9
 
10
- const asc = (a, b) => a - b;
11
-
12
10
  export class Interval2D {
13
11
  constructor(mark, {
14
12
  selection,
@@ -44,8 +42,8 @@ export class Interval2D {
44
42
  let yr = undefined;
45
43
  if (extent) {
46
44
  const [a, b] = extent;
47
- xr = [a[0], b[0]].map(v => invert(v, xscale, pixelSize)).sort(asc);
48
- yr = [a[1], b[1]].map(v => invert(v, yscale, pixelSize)).sort(asc);
45
+ xr = [a[0], b[0]].map(v => invert(v, xscale, pixelSize)).sort(ascending);
46
+ yr = [a[1], b[1]].map(v => invert(v, yscale, pixelSize)).sort(ascending);
49
47
  }
50
48
 
51
49
  if (!closeTo(xr, value?.[0]) || !closeTo(yr, value?.[1])) {
@@ -57,15 +55,12 @@ export class Interval2D {
57
55
 
58
56
  clause(value) {
59
57
  const { mark, pixelSize, xfield, yfield, xscale, yscale } = this;
60
- return {
58
+ return intervals([xfield, yfield], value, {
61
59
  source: this,
62
- schema: { type: 'interval', pixelSize, scales: [xscale, yscale] },
63
60
  clients: this.peers ? mark.plot.markSet : new Set().add(mark),
64
- value,
65
- predicate: value
66
- ? and(isBetween(xfield, value[0]), isBetween(yfield, value[1]))
67
- : null
68
- };
61
+ scales: [xscale, yscale],
62
+ pixelSize
63
+ });
69
64
  }
70
65
 
71
66
  init(svg) {
@@ -92,8 +87,8 @@ export class Interval2D {
92
87
  }
93
88
 
94
89
  if (this.value) {
95
- const [x1, x2] = this.value[0].map(xscale.apply).sort(asc);
96
- const [y1, y2] = this.value[1].map(yscale.apply).sort(asc);
90
+ const [x1, x2] = this.value[0].map(xscale.apply).sort(ascending);
91
+ const [y1, y2] = this.value[1].map(yscale.apply).sort(ascending);
97
92
  this.g.call(brush.moveSilent, [[x1, y1], [x2, y2]]);
98
93
  }
99
94
 
@@ -1,66 +1,109 @@
1
- import { isSelection } from '@uwdata/mosaic-core';
2
- import { eq, literal } from '@uwdata/mosaic-sql';
1
+ import { isSelection, points } from '@uwdata/mosaic-core';
3
2
  import { select, pointer } from 'd3';
4
3
  import { getField } from './util/get-field.js';
5
4
 
6
5
  export class Nearest {
7
6
  constructor(mark, {
8
7
  selection,
9
- channel,
10
- field
8
+ pointer,
9
+ channels,
10
+ fields,
11
+ maxRadius = 40
11
12
  }) {
12
13
  this.mark = mark;
13
14
  this.selection = selection;
14
15
  this.clients = new Set().add(mark);
15
- this.channel = channel;
16
- this.field = field || getField(mark, [channel]);
16
+ this.pointer = pointer;
17
+ this.channels = channels || (
18
+ pointer === 'x' ? ['x'] : pointer === 'y' ? ['y'] : ['x', 'y']
19
+ );
20
+ this.fields = fields || this.channels.map(c => getField(mark, [c]));
21
+ this.maxRadius = maxRadius;
22
+ this.valueIndex = -1;
17
23
  }
18
24
 
19
25
  clause(value) {
20
- const { clients, field } = this;
21
- const predicate = value ? eq(field, literal(value)) : null;
22
- return {
23
- source: this,
24
- schema: { type: 'point' },
25
- clients,
26
- value,
27
- predicate
28
- };
26
+ const { clients, fields } = this;
27
+ return points(fields, value ? [value] : value, { source: this, clients });
29
28
  }
30
29
 
31
30
  init(svg) {
32
31
  const that = this;
33
- const { mark, channel, selection } = this;
34
- const { data } = mark;
35
- const key = mark.channelField(channel).as;
32
+ const { mark, channels, selection, maxRadius } = this;
33
+ const { data: { columns } } = mark;
34
+ const keys = channels.map(c => mark.channelField(c).as);
35
+ const param = !isSelection(selection);
36
36
 
37
37
  const facets = select(svg).selectAll('g[aria-label="facet"]');
38
38
  const root = facets.size() ? facets : select(svg);
39
- const scale = svg.scale(channel);
40
- const param = !isSelection(selection);
41
39
 
42
- root.on('pointerdown pointermove', function(evt) {
43
- const [x, y] = pointer(evt, this);
44
- const z = findNearest(data.columns[key], scale.invert(channel === 'x' ? x : y));
45
- selection.update(param ? z : that.clause(z));
40
+ // extract x, y coordinates for data values and determine scale factors
41
+ const xscale = svg.scale('x').apply;
42
+ const yscale = svg.scale('y').apply;
43
+ const X = Array.from(columns[mark.channelField('x').as], xscale);
44
+ const Y = Array.from(columns[mark.channelField('y').as], yscale);
45
+ const sx = this.pointer === 'y' ? 0.01 : 1;
46
+ const sy = this.pointer === 'x' ? 0.01 : 1;
47
+
48
+ // find value nearest to pointer and update param or selection
49
+ // we don't pass undefined values to params, but do allow empty selections
50
+ root.on('pointerenter pointerdown pointermove', function(evt) {
51
+ const [px, py] = pointer(evt, this);
52
+ const i = findNearest(X, Y, px, py, sx, sy, maxRadius);
53
+ if (i !== this.valueIndex) {
54
+ this.valueIndex = i;
55
+ const v = i < 0 ? undefined : keys.map(k => columns[k][i]);
56
+ if (param) {
57
+ if (i > -1) selection.update(v.length > 1 ? v : v[0]);
58
+ } else {
59
+ selection.update(that.clause(v));
60
+ }
61
+ }
46
62
  });
47
63
 
64
+ // if not a selection, we're done
48
65
  if (param) return;
66
+
67
+ // clear selection upon pointer exit
68
+ root.on('pointerleave', () => {
69
+ selection.update(that.clause(undefined));
70
+ });
71
+
72
+ // trigger activation updates
49
73
  svg.addEventListener('pointerenter', evt => {
50
- if (!evt.buttons) this.selection.activate(this.clause(0));
74
+ if (!evt.buttons) {
75
+ const v = this.channels.map(() => 0);
76
+ selection.activate(this.clause(v));
77
+ }
51
78
  });
52
79
  }
53
80
  }
54
81
 
55
- function findNearest(values, value) {
56
- let dist = Infinity;
57
- let nearest;
58
-
59
- for (let i = 0; i < values.length; ++i) {
60
- const delta = Math.abs(values[i] - value);
61
- if (delta < dist) {
62
- dist = delta;
63
- nearest = values[i];
82
+ /**
83
+ * Find the nearest data point to the pointer. The nearest point
84
+ * is found via Euclidean distance, but with scale factors *sx* and
85
+ * *sy* applied to the x and y distances. For example, to prioritize
86
+ * selection along the x-axis, use *sx* = 1, *sy* = 0.01.
87
+ * @param {number[]} x Array of data point x coordinate values.
88
+ * @param {number[]} y Array of data point y coordinate values.
89
+ * @param {number} px The x coordinate of the pointer.
90
+ * @param {number} py The y coordinate of the pointer.
91
+ * @param {number} sx A scale factor for x coordinate spans.
92
+ * @param {number} sy A scale factor for y coordinate spans.
93
+ * @param {number} maxRadius The maximum pointer distance for selection.
94
+ * @returns {number} An integer index into the data array corresponding
95
+ * to the nearest data point, or -1 if no nearest point is found.
96
+ */
97
+ function findNearest(x, y, px, py, sx, sy, maxRadius) {
98
+ let dist = maxRadius * maxRadius;
99
+ let nearest = -1;
100
+ for (let i = 0; i < x.length; ++i) {
101
+ const dx = sx * (x[i] - px);
102
+ const dy = sy * (y[i] - py);
103
+ const dd = dx * dx + dy * dy;
104
+ if (dd <= dist) {
105
+ dist = dd;
106
+ nearest = i;
64
107
  }
65
108
  }
66
109
  return nearest;
@@ -1,6 +1,5 @@
1
+ import { interval, Selection } from '@uwdata/mosaic-core';
1
2
  import { select, zoom, ZoomTransform } from 'd3';
2
- import { Selection } from '@uwdata/mosaic-core';
3
- import { isBetween } from '@uwdata/mosaic-sql';
4
3
  import { getField } from './util/get-field.js';
5
4
 
6
5
  const asc = (a, b) => a - b;
@@ -49,13 +48,11 @@ export class PanZoom {
49
48
  }
50
49
 
51
50
  clause(value, field, scale) {
52
- return {
51
+ return interval(field, value, {
53
52
  source: this,
54
- schema: { type: 'interval', scales: [scale] },
55
53
  clients: this.mark.plot.markSet,
56
- value,
57
- predicate: value ? isBetween(field, value) : null
58
- };
54
+ scale
55
+ });
59
56
  }
60
57
 
61
58
  init(svg) {
@@ -1,4 +1,4 @@
1
- import { and, or, isNotDistinct, literal } from '@uwdata/mosaic-sql';
1
+ import { points } from '@uwdata/mosaic-core';
2
2
 
3
3
  export class Toggle {
4
4
  /**
@@ -14,49 +14,40 @@ export class Toggle {
14
14
  this.mark = mark;
15
15
  this.selection = selection;
16
16
  this.peers = peers;
17
- this.channels = channels.map(c => {
17
+ const fields = this.fields = [];
18
+ const as = this.as = [];
19
+ channels.forEach(c => {
18
20
  const q = c === 'color' ? ['color', 'fill', 'stroke']
19
21
  : c === 'x' ? ['x', 'x1', 'x2']
20
22
  : c === 'y' ? ['y', 'y1', 'y2']
21
23
  : [c];
22
24
  for (let i = 0; i < q.length; ++i) {
23
25
  const f = mark.channelField(q[i], { exact: true });
24
- if (f) return {
25
- field: f.field?.basis || f.field,
26
- as: f.as
27
- };
26
+ if (f) {
27
+ fields.push(f.field?.basis || f.field);
28
+ as.push(f.as);
29
+ return;
30
+ }
28
31
  }
29
32
  throw new Error(`Missing channel: ${c}`);
30
33
  });
31
34
  }
32
35
 
33
36
  clause(value) {
34
- const { channels, mark } = this;
35
- let predicate = null;
36
-
37
- if (value) {
38
- const clauses = value.map(vals => {
39
- const list = vals.map((v, i) => {
40
- return isNotDistinct(channels[i].field, literal(v));
41
- });
42
- return list.length > 1 ? and(list) : list[0];
43
- });
44
- predicate = clauses.length > 1 ? or(clauses) : clauses[0];
45
- }
46
-
47
- return {
37
+ const { fields, mark } = this;
38
+ return points(fields, value, {
48
39
  source: this,
49
- schema: { type: 'point' },
50
- clients: this.peers ? mark.plot.markSet : new Set().add(mark),
51
- value,
52
- predicate
53
- };
40
+ clients: this.peers ? mark.plot.markSet : new Set().add(mark)
41
+ });
54
42
  }
55
43
 
56
44
  init(svg, selector, accessor) {
57
- const { mark, channels, selection } = this;
45
+ const { mark, as, selection } = this;
58
46
  const { data: { columns = {} } = {} } = mark;
59
- accessor ??= target => channels.map(c => columns[c.as][target.__data__]);
47
+ accessor ??= target => as.map(name => {
48
+ const data = target.__data__;
49
+ return columns[name][Array.isArray(data) ? data[0] : data];
50
+ });
60
51
  selector ??= `[data-index="${mark.index}"]`;
61
52
  const groups = new Set(svg.querySelectorAll(selector));
62
53
 
@@ -85,7 +76,7 @@ export class Toggle {
85
76
 
86
77
  svg.addEventListener('pointerenter', evt => {
87
78
  if (evt.buttons) return;
88
- this.selection.activate(this.clause([this.channels.map(() => 0)]));
79
+ this.selection.activate(this.clause([this.fields.map(() => 0)]));
89
80
  });
90
81
  }
91
82
  }
package/src/legend.js CHANGED
@@ -19,7 +19,7 @@ export class Legend {
19
19
 
20
20
  this.element = document.createElement('div');
21
21
  this.element.setAttribute('class', 'legend');
22
- Object.assign(this.element, { value: this });
22
+ Object.defineProperty(this.element, 'value', { value: this });
23
23
  }
24
24
 
25
25
  setPlot(plot) {
@@ -35,8 +35,13 @@ export class Legend {
35
35
 
36
36
  update() {
37
37
  if (!this.legend) return;
38
- const { value } = this.selection;
39
- const curr = value && value.length ? new Set(value.map(v => v[0])) : null;
38
+ const { selection, handler } = this;
39
+ const { single, value } = selection;
40
+
41
+ // extract currently selected values
42
+ const vals = single ? value : selection.valueFor(handler);
43
+ const curr = vals && vals.length ? new Set(vals.map(v => v[0])) : null;
44
+
40
45
  const nodes = this.legend.querySelectorAll(TOGGLE_SELECTOR);
41
46
  for (const node of nodes) {
42
47
  const selected = curr ? curr.has(node.__data__) : true;
@@ -103,11 +108,19 @@ function getInteractor(legend, type) {
103
108
  // otherwise instantiate an appropriate interactor
104
109
  const mark = interactorMark(legend);
105
110
  if (type === SWATCH) {
106
- legend.handler = new Toggle(mark, { selection, channels: [channel] });
111
+ legend.handler = new Toggle(mark, {
112
+ selection,
113
+ channels: [channel],
114
+ peers: false
115
+ });
107
116
  selection.addEventListener('value', () => legend.update());
108
117
  } else {
109
- const brush = { fill: 'none', stroke: 'currentColor' };
110
- legend.handler = new Interval1D(mark, { selection, channel, brush });
118
+ legend.handler = new Interval1D(mark, {
119
+ selection,
120
+ channel,
121
+ brush: { fill: 'none', stroke: 'currentColor' },
122
+ peers: false
123
+ });
111
124
  }
112
125
 
113
126
  return legend.handler;
@@ -0,0 +1,50 @@
1
+ import { avg, count, stddev } from '@uwdata/mosaic-sql';
2
+ import { erfinv } from './util/stats.js';
3
+ import { Mark, markPlotSpec, markQuery } from './Mark.js';
4
+ import { handleParam } from './util/handle-param.js';
5
+ import { toDataColumns } from './util/to-data-columns.js';
6
+
7
+ export class ErrorBarMark extends Mark {
8
+ constructor(type, source, options) {
9
+ const dim = type.endsWith('X') ? 'y' : 'x';
10
+ const { ci = 0.95, ...channels } = options;
11
+ super(type, source, channels);
12
+ this.dim = dim;
13
+ this.field = this.channelField(dim).field;
14
+ this.channels = this.channels.filter(c => c.channel !== dim);
15
+
16
+ /** @type {number} */
17
+ this.ci = handleParam(ci, value => {
18
+ return (this.ci = value, this.update());
19
+ });
20
+ }
21
+
22
+ query(filter = []) {
23
+ const { channels, field, source: { table } } = this;
24
+ const fields = channels.concat([
25
+ { field: avg(field), as: '__avg__' },
26
+ { field: count(field), as: '__n__', },
27
+ { field: stddev(field), as: '__sd__' }
28
+ ]);
29
+ return markQuery(fields, table).where(filter);
30
+ }
31
+
32
+ queryResult(data) {
33
+ this.data = toDataColumns(data);
34
+ return this;
35
+ }
36
+
37
+ plotSpecs() {
38
+ const { type, dim, detail, data, ci, channels } = this;
39
+
40
+ // compute confidence interval channels
41
+ const p = Math.SQRT2 * erfinv(ci);
42
+ const { columns: { __avg__: u, __sd__: s, __n__: n } } = data;
43
+ const options = {
44
+ [`${dim}1`]: u.map((u, i) => u - p * s[i] / Math.sqrt(n[i])),
45
+ [`${dim}2`]: u.map((u, i) => u + p * s[i] / Math.sqrt(n[i]))
46
+ };
47
+
48
+ return markPlotSpec(type, detail, channels, data, options);
49
+ }
50
+ }
@@ -61,7 +61,7 @@ export class Grid2DMark extends Mark {
61
61
 
62
62
  /**
63
63
  * @param {import('../plot.js').Plot} plot The plot.
64
- * @param {number} index
64
+ * @param {number} index
65
65
  */
66
66
  setPlot(plot, index) {
67
67
  const update = () => { if (this.hasFieldInfo()) this.requestUpdate(); };
package/src/marks/Mark.js CHANGED
@@ -91,7 +91,7 @@ export class Mark extends MosaicClient {
91
91
 
92
92
  /**
93
93
  * @param {import('../plot.js').Plot} plot The plot.
94
- * @param {number} index
94
+ * @param {number} index
95
95
  */
96
96
  setPlot(plot, index) {
97
97
  this.plot = plot;
@@ -181,24 +181,8 @@ export class Mark extends MosaicClient {
181
181
  * @returns {object[]}
182
182
  */
183
183
  plotSpecs() {
184
- const { type, detail, channels } = this;
185
- // @ts-ignore
186
- const { numRows: length, values, columns } = this.data || {};
187
-
188
- // populate plot specification options
189
- const options = {};
190
- const side = {};
191
- for (const c of channels) {
192
- const obj = detail.has(c.channel) ? side : options;
193
- obj[c.channel] = channelOption(c, columns);
194
- }
195
- if (detail.size) options.channels = side;
196
-
197
- // if provided raw source values (not objects) pass as-is
198
- // otherwise we pass columnar data directy in the options
199
- const data = values ?? (this.data ? { length } : null);
200
- const spec = [{ type, data, options }];
201
- return spec;
184
+ const { type, data, detail, channels } = this;
185
+ return markPlotSpec(type, detail, channels, data);
202
186
  }
203
187
  }
204
188
 
@@ -258,3 +242,27 @@ export function markQuery(channels, table, skip = []) {
258
242
 
259
243
  return q;
260
244
  }
245
+
246
+
247
+ /**
248
+ * Generate an array of Plot mark specifications.
249
+ * @returns {object[]}
250
+ */
251
+ export function markPlotSpec(type, detail, channels, data, options = {}) {
252
+ // @ts-ignore
253
+ const { numRows: length, values, columns } = data ?? {};
254
+
255
+ // populate plot specification options
256
+ const side = {};
257
+ for (const c of channels) {
258
+ const obj = detail.has(c.channel) ? side : options;
259
+ obj[c.channel] = channelOption(c, columns);
260
+ }
261
+ if (detail.size) options.channels = side;
262
+
263
+ // if provided raw source values (not objects) pass as-is
264
+ // otherwise we pass columnar data directy in the options
265
+ const specData = values ?? (data ? { length } : null);
266
+ const spec = [{ type, data: specData, options }];
267
+ return spec;
268
+ }
@@ -2,6 +2,7 @@ import { ascending } from 'd3';
2
2
  import { scale } from '@observablehq/plot';
3
3
  import { gridDomainContinuous, gridDomainDiscrete } from './util/grid.js';
4
4
  import { isColor } from './util/is-color.js';
5
+ import { indices, permute } from './util/permute.js';
5
6
  import { alphaScheme, alphaConstant, colorConstant, colorCategory, colorScheme, createCanvas } from './util/raster.js';
6
7
  import { DENSITY, Grid2DMark } from './Grid2DMark.js';
7
8
  import { Fixed, Transient } from '../symbols.js';
@@ -46,13 +47,18 @@ export class RasterMark extends Grid2DMark {
46
47
  const alphaData = columns[alphaProp] ?? [];
47
48
  const colorData = columns[colorProp] ?? [];
48
49
 
50
+ // determine raster order
51
+ const idx = numRows > 1 && colorProp && this.groupby?.includes(colorProp)
52
+ ? permute(colorData, this.plot.getAttribute('colorDomain'))
53
+ : indices(numRows);
54
+
49
55
  // generate rasters
50
56
  this.data = {
51
57
  numRows,
52
58
  columns: {
53
59
  src: Array.from({ length: numRows }, (_, i) => {
54
- color?.(img.data, w, h, colorData[i]);
55
- alpha?.(img.data, w, h, alphaData[i]);
60
+ color?.(img.data, w, h, colorData[idx[i]]);
61
+ alpha?.(img.data, w, h, alphaData[idx[i]]);
56
62
  ctx.putImageData(img, 0, 0);
57
63
  return canvas.toDataURL();
58
64
  })
@@ -196,7 +202,7 @@ function colorScale(mark, prop) {
196
202
  const domainFixed = domainAttr === Fixed;
197
203
  const domainTransient = domainAttr?.[Transient];
198
204
  const domain = (!domainFixed && !domainTransient && domainAttr) || (
199
- flat ? data.sort(ascending)
205
+ flat ? data.slice().sort(ascending)
200
206
  : discrete ? gridDomainDiscrete(data)
201
207
  : gridDomainContinuous(data)
202
208
  );
@@ -2,6 +2,7 @@ import { coordinator } from '@uwdata/mosaic-core';
2
2
  import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
3
3
  import { binExpr } from './util/bin-expr.js';
4
4
  import { extentX, extentY } from './util/extent.js';
5
+ import { indices, permute } from './util/permute.js';
5
6
  import { createCanvas } from './util/raster.js';
6
7
  import { Grid2DMark } from './Grid2DMark.js';
7
8
  import { rasterEncoding } from './RasterMark.js';
@@ -181,13 +182,18 @@ export class RasterTileMark extends Grid2DMark {
181
182
  const alphaData = columns[alphaProp] ?? [];
182
183
  const colorData = columns[colorProp] ?? [];
183
184
 
185
+ // determine raster order
186
+ const idx = numRows > 1 && colorProp && this.groupby?.includes(colorProp)
187
+ ? permute(colorData, this.plot.getAttribute('colorDomain'))
188
+ : indices(numRows);
189
+
184
190
  // generate rasters
185
191
  this.data = {
186
192
  numRows,
187
193
  columns: {
188
194
  src: Array.from({ length: numRows }, (_, i) => {
189
- color?.(img.data, w, h, colorData[i]);
190
- alpha?.(img.data, w, h, alphaData[i]);
195
+ color?.(img.data, w, h, colorData[idx[i]]);
196
+ alpha?.(img.data, w, h, alphaData[idx[i]]);
191
197
  ctx.putImageData(img, 0, 0);
192
198
  return canvas.toDataURL();
193
199
  })
@@ -33,7 +33,8 @@ const constantOptions = new Set([
33
33
  'crossOrigin',
34
34
  'paintOrder',
35
35
  'pointerEvents',
36
- 'target'
36
+ 'target',
37
+ 'select'
37
38
  ]);
38
39
 
39
40
  export function isConstantOption(value) {
@@ -0,0 +1,10 @@
1
+ export function indices(length) {
2
+ return Array.from({ length }, (_, i) => i);
3
+ }
4
+
5
+ export function permute(data, order) {
6
+ const ord = order.reduce((acc, val, i) => (acc[val] = i, acc), {});
7
+ const idx = indices(data.length);
8
+ idx.sort((a, b) => ord[data[a]] - ord[data[b]]);
9
+ return idx;
10
+ }