@uwdata/mosaic-plot 0.6.1 → 0.7.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.6.1",
3
+ "version": "0.7.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.13",
32
- "@uwdata/mosaic-core": "^0.6.1",
33
- "@uwdata/mosaic-sql": "^0.6.0",
32
+ "@uwdata/mosaic-core": "^0.7.0",
33
+ "@uwdata/mosaic-sql": "^0.7.0",
34
34
  "d3": "^7.8.5",
35
35
  "isoformat": "^0.2.1"
36
36
  },
37
- "gitHead": "9e788e6dc5241fa1c54967a25fd9599f97da1a41"
37
+ "gitHead": "4680b922f15579b7b527f31507ed71a12230ec35"
38
38
  }
@@ -21,7 +21,7 @@ export class Interval1D {
21
21
  this.pixelSize = pixelSize || 1;
22
22
  this.selection = selection;
23
23
  this.peers = peers;
24
- this.field = field || getField(mark, [channel, channel+'1', channel+'2']);
24
+ this.field = field || getField(mark, channel);
25
25
  this.style = style && sanitizeStyles(style);
26
26
  this.brush = channel === 'y' ? brushY() : brushX();
27
27
  this.brush.on('brush end', ({ selection }) => this.publish(selection));
@@ -22,8 +22,8 @@ export class Interval2D {
22
22
  this.pixelSize = pixelSize || 1;
23
23
  this.selection = selection;
24
24
  this.peers = peers;
25
- this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
26
- this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
25
+ this.xfield = xfield || getField(mark, 'x');
26
+ this.yfield = yfield || getField(mark, 'y');
27
27
  this.style = style && sanitizeStyles(style);
28
28
  this.brush = brush();
29
29
  this.brush.on('brush end', ({ selection }) => this.publish(selection));
@@ -18,8 +18,8 @@ export class PanZoom {
18
18
  this.mark = mark;
19
19
  this.xsel = x;
20
20
  this.ysel = y;
21
- this.xfield = xfield || getField(mark, ['x', 'x1', 'x2']);
22
- this.yfield = yfield || getField(mark, ['y', 'y1', 'y2']);
21
+ this.xfield = xfield || getField(mark, 'x');
22
+ this.yfield = yfield || getField(mark, 'y');
23
23
  this.zoom = extent(zoom, [0, Infinity], [1, 1]);
24
24
  this.panx = this.xsel && panx;
25
25
  this.pany = this.ysel && pany;
@@ -1,4 +1,4 @@
1
- export function getField(mark, channels) {
2
- const field = mark.channelField(channels)?.field;
1
+ export function getField(mark, channel) {
2
+ const field = mark.channelField(channel)?.field;
3
3
  return field?.basis || field;
4
4
  }
package/src/legend.js CHANGED
@@ -56,8 +56,10 @@ function findMark({ marks }, channel) {
56
56
  : null;
57
57
  if (channels == null) return null;
58
58
  for (let i = marks.length - 1; i > -1; --i) {
59
- if (marks[i].channelField(channels)) {
60
- return marks[i];
59
+ for (const channel of channels) {
60
+ if (marks[i].channelField(channel, { exact: true })) {
61
+ return marks[i];
62
+ }
61
63
  }
62
64
  }
63
65
  return null;
@@ -6,29 +6,26 @@ import { Mark } from './Mark.js';
6
6
  export class ConnectedMark extends Mark {
7
7
  constructor(type, source, encodings) {
8
8
  const dim = type.endsWith('X') ? 'y' : type.endsWith('Y') ? 'x' : null;
9
- const req = { [dim]: ['min', 'max'] };
9
+ const req = dim ? { [dim]: ['min', 'max'] } : undefined;
10
10
  super(type, source, encodings, req);
11
11
  this.dim = dim;
12
12
  }
13
13
 
14
14
  query(filter = []) {
15
- const { plot, dim, source, stats } = this;
15
+ const { plot, dim, source } = this;
16
16
  const { optimize = true } = source.options || {};
17
17
  const q = super.query(filter);
18
18
  if (!dim) return q;
19
19
 
20
20
  const ortho = dim === 'x' ? 'y' : 'x';
21
- const value = this.channelField(ortho)?.as;
22
- const { field, as } = this.channelField(dim);
23
- const { type } = stats[field.column];
21
+ const value = this.channelField(ortho, { exact: true })?.as;
22
+ const { field, as, type, min, max } = this.channelField(dim);
24
23
  const isContinuous = type === 'date' || type === 'number';
25
24
 
26
25
  if (optimize && isContinuous && value) {
27
26
  // TODO: handle stacked data!
28
- const { column } = field;
29
- const { max, min } = stats[column];
30
27
  const size = dim === 'x' ? plot.innerWidth() : plot.innerHeight();
31
- const [lo, hi] = filteredExtent(filter, column) || [min, max];
28
+ const [lo, hi] = filteredExtent(filter, field) || [min, max];
32
29
  const [expr] = binExpr(this, dim, size, [lo, hi], 1, as);
33
30
  const cols = q.select()
34
31
  .map(c => c.as)
@@ -47,10 +47,17 @@ export class DenseLineMark extends RasterMark {
47
47
  function stripXY(mark, filter) {
48
48
  if (Array.isArray(filter) && !filter.length) return filter;
49
49
 
50
- const xc = mark.channelField('x').field.column;
51
- const yc = mark.channelField('y').field.column;
52
- const test = p => p.op !== 'BETWEEN'
53
- || p.field.column !== xc && p.field.column !== yc;
50
+ // get column expressions for x and y encoding channels
51
+ const { column: xc } = mark.channelField('x');
52
+ const { column: yc } = mark.channelField('y');
53
+
54
+ // test if a range predicate filters the x or y channels
55
+ const test = p => {
56
+ const col = `${p.field}`;
57
+ return p.op !== 'BETWEEN' || col !== xc && col !== yc;
58
+ };
59
+
60
+ // filter boolean 'and' operations
54
61
  const filterAnd = p => p.op === 'AND'
55
62
  ? and(p.children.filter(c => test(c)))
56
63
  : p;
@@ -39,7 +39,7 @@ export class Grid2DMark extends Mark {
39
39
  }
40
40
 
41
41
  setPlot(plot, index) {
42
- const update = () => { if (this.stats) this.requestUpdate(); };
42
+ const update = () => { if (this.hasFieldInfo()) this.requestUpdate(); };
43
43
  plot.addAttributeListener('domainX', update);
44
44
  plot.addAttributeListener('domainY', update);
45
45
  return super.setPlot(plot, index);
package/src/marks/Mark.js CHANGED
@@ -19,6 +19,8 @@ const fieldEntry = (channel, field) => ({
19
19
  });
20
20
  const valueEntry = (channel, value) => ({ channel, value });
21
21
 
22
+ // checks if a data source is an explicit array of values
23
+ // as opposed to a database table refernece
22
24
  export const isDataArray = source => Array.isArray(source);
23
25
 
24
26
  export class Mark extends MosaicClient {
@@ -94,45 +96,47 @@ export class Mark extends MosaicClient {
94
96
  return this.source == null || isDataArray(this.source);
95
97
  }
96
98
 
99
+ hasFieldInfo() {
100
+ return !!this._fieldInfo;
101
+ }
102
+
97
103
  channel(channel) {
98
104
  return this.channels.find(c => c.channel === channel);
99
105
  }
100
106
 
101
- channelField(...channels) {
102
- const list = channels.flat();
103
- for (const channel of list) {
104
- const c = this.channel(channel);
105
- if (c?.field) return c;
106
- }
107
- return null;
107
+ channelField(channel, { exact } = {}) {
108
+ const c = exact
109
+ ? this.channel(channel)
110
+ : this.channels.find(c => c.channel.startsWith(channel));
111
+ return c?.field ? c : null;
108
112
  }
109
113
 
110
114
  fields() {
111
115
  if (this.hasOwnData()) return null;
112
- const { source: { table }, channels, reqs } = this;
113
116
 
117
+ const { source: { table }, channels, reqs } = this;
114
118
  const fields = new Map;
115
119
  for (const { channel, field } of channels) {
116
- const column = field?.column;
117
- if (!column) {
118
- continue; // no column to lookup
119
- } else if (field.stats?.length || reqs[channel]) {
120
- if (!fields.has(column)) fields.set(column, new Set);
121
- const entry = fields.get(column);
122
- reqs[channel]?.forEach(s => entry.add(s));
123
- field.stats?.forEach(s => entry.add(s));
124
- }
120
+ if (!field) continue;
121
+ const stats = field.stats?.stats || [];
122
+ const key = field.stats?.column ?? field;
123
+ const entry = fields.get(key) ?? fields.set(key, new Set).get(key);
124
+ stats.forEach(s => entry.add(s));
125
+ reqs[channel]?.forEach(s => entry.add(s));
125
126
  }
126
- return Array.from(fields, ([column, stats]) => {
127
- return { table, column, stats: Array.from(stats) };
128
- });
127
+
128
+ return Array.from(fields, ([c, s]) => ({ table, column: c, stats: s }));
129
129
  }
130
130
 
131
131
  fieldInfo(info) {
132
- this.stats = info.reduce(
133
- (o, d) => (o[d.column] = d, o),
134
- Object.create(null)
135
- );
132
+ const lookup = Object.fromEntries(info.map(x => [x.column, x]));
133
+ for (const entry of this.channels) {
134
+ const { field } = entry;
135
+ if (field) {
136
+ Object.assign(entry, lookup[field.stats?.column ?? field]);
137
+ }
138
+ }
139
+ this._fieldInfo = true;
136
140
  return this;
137
141
  }
138
142
 
@@ -169,6 +173,13 @@ export class Mark extends MosaicClient {
169
173
  }
170
174
  }
171
175
 
176
+ /**
177
+ * Helper method for setting a channel option in a Plot specification.
178
+ * Checks if a constant value or a data field is needed.
179
+ * Also avoids misinterpretation of data values as color names.
180
+ * @param {*} c a visual encoding channel spec
181
+ * @returns the Plot channel option
182
+ */
172
183
  export function channelOption(c) {
173
184
  // use a scale override for color channels to sidestep
174
185
  // https://github.com/observablehq/plot/issues/1593
@@ -177,6 +188,16 @@ export function channelOption(c) {
177
188
  : c.as;
178
189
  }
179
190
 
191
+ /**
192
+ * Default query construction for a mark.
193
+ * Tracks aggregates by checking fields for an aggregate flag.
194
+ * If aggregates are found, groups by all non-aggregate fields.
195
+ * @param {*} channels array of visual encoding channel specs.
196
+ * @param {*} table the table to query.
197
+ * @param {*} skip an optional array of channels to skip.
198
+ * Mark subclasses can skip channels that require special handling.
199
+ * @returns a Query instance
200
+ */
180
201
  export function markQuery(channels, table, skip = []) {
181
202
  const q = Query.from({ source: table });
182
203
  const dims = new Set;
@@ -23,7 +23,7 @@ export class RasterMark extends Grid2DMark {
23
23
  }
24
24
 
25
25
  setPlot(plot, index) {
26
- const update = () => { if (this.stats) this.rasterize(); };
26
+ const update = () => { if (this.hasFieldInfo()) this.rasterize(); };
27
27
  plot.addAttributeListener('schemeColor', update);
28
28
  super.setPlot(plot, index);
29
29
  }
@@ -18,7 +18,7 @@ export class RasterTileMark extends Grid2DMark {
18
18
  }
19
19
 
20
20
  setPlot(plot, index) {
21
- const update = () => { if (this.stats) this.rasterize(); };
21
+ const update = () => { if (this.hasFieldInfo()) this.rasterize(); };
22
22
  plot.addAttributeListener('schemeColor', update);
23
23
  super.setPlot(plot, index);
24
24
  }
@@ -5,8 +5,7 @@ export function channelScale(mark, channel) {
5
5
 
6
6
  let scaleType = plot.getAttribute(`${channel}Scale`);
7
7
  if (!scaleType) {
8
- const { field } = mark.channelField(channel, `${channel}1`, `${channel}2`);
9
- const { type } = mark.stats[field.column];
8
+ const { type } = mark.channelField(channel);
10
9
  scaleType = type === 'date' ? 'time' : 'linear';
11
10
  }
12
11
 
@@ -6,16 +6,14 @@ export const yext = { y: ['min', 'max'] };
6
6
  export const xyext = { ...xext, ...yext };
7
7
 
8
8
  export function plotExtent(mark, filter, channel, domainAttr, niceAttr) {
9
- const { plot, stats } = mark;
9
+ const { plot } = mark;
10
10
  const domain = plot.getAttribute(domainAttr);
11
11
  const nice = plot.getAttribute(niceAttr);
12
12
 
13
13
  if (Array.isArray(domain) && !domain[Transient]) {
14
14
  return domain;
15
15
  } else {
16
- const { field } = mark.channelField(channel);
17
- const { column } = field;
18
- const { min, max } = stats[column];
16
+ const { column, min, max } = mark.channelField(channel);
19
17
  const dom = filteredExtent(filter, column) || (nice
20
18
  ? scaleLinear().domain([min, max]).nice().domain()
21
19
  : [min, max]);
@@ -39,7 +37,7 @@ export function filteredExtent(filter, column) {
39
37
  let lo;
40
38
  let hi;
41
39
  const visitor = (type, clause) => {
42
- if (type === 'BETWEEN' && clause.field.column === column) {
40
+ if (type === 'BETWEEN' && `${clause.field}` === column) {
43
41
  const { range } = clause;
44
42
  if (range && (lo == null || range[0] < lo)) lo = range[0];
45
43
  if (range && (hi == null || range[1] > hi)) hi = range[1];
@@ -1,20 +1,18 @@
1
1
  import { InternSet, ascending } from 'd3';
2
- import { convertArrowColumn, convertArrowType, isArrowTable } from './arrow.js';
2
+ import {
3
+ convertArrowArrayType,
4
+ convertArrowColumn,
5
+ isArrowTable
6
+ } from '@uwdata/mosaic-core';
3
7
 
4
8
  function arrayType(values, name = 'density') {
5
- if (isArrowTable(values)) {
6
- return convertArrowType(values.getChild(name).type);
7
- } else {
8
- return typeof values[0][name] === 'number' ? Float64Array : Array;
9
- }
10
- }
11
-
12
- export function grid1d(n, values) {
13
- const Type = arrayType(values);
14
- return valuesToGrid(new Type(n), values);
9
+ return isArrowTable(values)
10
+ ? convertArrowArrayType(values.getChild(name).type)
11
+ : typeof values[0]?.[name] === 'number' ? Float64Array : Array;
15
12
  }
16
13
 
17
- function valuesToGrid(grid, values, name = 'density') {
14
+ export function grid1d(n, values, name = 'density') {
15
+ const grid = new (arrayType(values))(n);
18
16
  if (isArrowTable(values)) {
19
17
  // optimize access for Arrow tables
20
18
  const numRows = values.numRows;
@@ -1,5 +1,8 @@
1
1
  import { Delaunay, randomLcg } from 'd3';
2
2
 
3
+ // Derived from Observable Plot’s interpolatorBarycentric:
4
+ // https://github.com/observablehq/plot/blob/41a63e372453d2f95e7a046839dfd245d21e7660/src/marks/raster.js#L283-L334
5
+
3
6
  export function interpolatorBarycentric({random = randomLcg(42)} = {}) {
4
7
  return (index, width, height, X, Y, V, W) => {
5
8
  // Interpolate the interior of all triangles with barycentric coordinates
@@ -122,6 +125,9 @@ function ray(j, X, Y) {
122
125
  };
123
126
  }
124
127
 
128
+ // Derived from Observable Plot’s interpolateNearest:
129
+ // https://github.com/observablehq/plot/blob/41a63e372453d2f95e7a046839dfd245d21e7660/src/marks/raster.js#L410-L428
130
+
125
131
  export function interpolateNearest(index, width, height, X, Y, V, W) {
126
132
  const delaunay = Delaunay.from(index, (i) => X[i], (i) => Y[i]);
127
133
  // memoization of delaunay.find for the line start (iy) and pixel (ix)
@@ -137,6 +143,9 @@ export function interpolateNearest(index, width, height, X, Y, V, W) {
137
143
  return W;
138
144
  }
139
145
 
146
+ // Derived from Observable Plot’s interpolatorRandomWalk:
147
+ // https://github.com/observablehq/plot/blob/41a63e372453d2f95e7a046839dfd245d21e7660/src/marks/raster.js#L430-L462
148
+
140
149
  // https://observablehq.com/@observablehq/walk-on-spheres-precision
141
150
  export function interpolatorRandomWalk({random = randomLcg(42), minDistance = 0.5, maxSteps = 2} = {}) {
142
151
  return (index, width, height, X, Y, V, W) => {
@@ -1,5 +1,8 @@
1
1
  import { color } from 'd3';
2
2
 
3
+ // Derived from Observable Plot’s isColor:
4
+ // https://github.com/observablehq/plot/blob/a063b226fec284c5b0e973701fdbbb244ef9ac2c/src/options.js#L462-L477
5
+
3
6
  // Mostly relies on d3-color, with a few extra color keywords. Currently this
4
7
  // strictly requires that the value be a string; we might want to apply string
5
8
  // coercion here, though note that d3-color instances would need to support
@@ -1,4 +1,4 @@
1
- import { convertArrow, isArrowTable } from './arrow.js';
1
+ import { convertArrowValue, isArrowTable } from '@uwdata/mosaic-core';
2
2
 
3
3
  export function toDataArray(data) {
4
4
  return isArrowTable(data) ? arrowToObjects(data) : data;
@@ -34,7 +34,7 @@ export function arrowToObjects(data) {
34
34
  for (let j = 0; j < numCols; ++j) {
35
35
  const child = batch.getChildAt(j);
36
36
  const { name, type } = schema.fields[j];
37
- const valueOf = convertArrow(type);
37
+ const valueOf = convertArrowValue(type);
38
38
 
39
39
  // for each row in the current batch...
40
40
  for (let o = k, i = 0; i < numRows; ++i, ++o) {
@@ -9,8 +9,6 @@ const OPTIONS_ONLY_MARKS = new Set([
9
9
  'graticule'
10
10
  ]);
11
11
 
12
-
13
-
14
12
  // construct Plot output
15
13
  // see https://github.com/observablehq/plot
16
14
  export async function plotRenderer(plot) {
@@ -76,17 +74,17 @@ function setSymbolAttributes(plot, svg, attributes, symbols) {
76
74
 
77
75
  function inferLabels(spec, plot) {
78
76
  const { marks } = plot;
79
- inferLabel('x', spec, marks, ['x', 'x1', 'x2']);
80
- inferLabel('y', spec, marks, ['y', 'y1', 'y2']);
77
+ inferLabel('x', spec, marks);
78
+ inferLabel('y', spec, marks);
81
79
  inferLabel('fx', spec, marks);
82
80
  inferLabel('fy', spec, marks);
83
81
  }
84
82
 
85
- function inferLabel(key, spec, marks, channels = [key]) {
83
+ function inferLabel(key, spec, marks) {
86
84
  const scale = spec[key] || {};
87
85
  if (scale.axis === null || scale.label !== undefined) return; // nothing to do
88
86
 
89
- const fields = marks.map(mark => mark.channelField(channels)?.field);
87
+ const fields = marks.map(mark => mark.channelField(key)?.field);
90
88
  if (fields.every(x => x == null)) return; // no columns found
91
89
 
92
90
  // check for consistent columns / labels
@@ -100,7 +98,7 @@ function inferLabel(key, spec, marks, channels = [key]) {
100
98
  } else if (candCol === undefined && candLabel === undefined) {
101
99
  candCol = column;
102
100
  candLabel = label;
103
- type = getType(marks[i].data, channels) || 'number';
101
+ type = getType(marks[i].data, key) || 'number';
104
102
  } else if (candLabel !== label) {
105
103
  candLabel = undefined;
106
104
  } else if (candCol !== column) {
@@ -149,13 +147,11 @@ function annotateMarks(svg, indices) {
149
147
  }
150
148
  }
151
149
 
152
- function getType(data, channels) {
150
+ function getType(data, channel) {
153
151
  for (const row of data) {
154
- for (let j = 0; j < channels.length; ++j) {
155
- const v = row[channels[j]];
156
- if (v != null) {
157
- return v instanceof Date ? 'date' : typeof v;
158
- }
152
+ const v = row[channel] ?? row[channel+'1'] ?? row[channel+'2'];
153
+ if (v != null) {
154
+ return v instanceof Date ? 'date' : typeof v;
159
155
  }
160
156
  }
161
157
  }
@@ -24,12 +24,12 @@ function binField(mark, channel, column, options) {
24
24
  return {
25
25
  column,
26
26
  label: column,
27
- get stats() { return ['min', 'max']; },
27
+ get stats() { return { column, stats: ['min', 'max'] }; },
28
28
  get columns() { return [column]; },
29
29
  get basis() { return column; },
30
30
  toString() {
31
31
  const { apply, sqlApply, sqlInvert } = channelScale(mark, channel);
32
- const { min, max } = mark.stats[column];
32
+ const { min, max } = mark.channelField(channel);
33
33
  const b = bins(apply(min), apply(max), options);
34
34
  const col = sqlApply(column);
35
35
  const base = b.min === 0 ? col : `(${col} - ${b.min})`;
@@ -1,55 +0,0 @@
1
- export const INTEGER = 2;
2
- export const FLOAT = 3;
3
- export const DECIMAL = 7;
4
- export const TIMESTAMP = 10;
5
-
6
- export function isArrowTable(values) {
7
- return typeof values?.getChild === 'function';
8
- }
9
-
10
- export function convertArrowType(type) {
11
- switch (type.typeId) {
12
- case INTEGER:
13
- case FLOAT:
14
- case DECIMAL:
15
- return Float64Array;
16
- default:
17
- return Array;
18
- }
19
- }
20
-
21
- export function convertArrow(type) {
22
- const { typeId } = type;
23
-
24
- // map timestamp numbers to date objects
25
- if (typeId === TIMESTAMP) {
26
- return v => v == null ? v : new Date(v);
27
- }
28
-
29
- // map bignum to number
30
- if (typeId === INTEGER && type.bitWidth >= 64) {
31
- return v => v == null ? v : Number(v);
32
- }
33
-
34
- // otherwise use Arrow JS defaults
35
- return v => v;
36
- }
37
-
38
- export function convertArrowColumn(column) {
39
- const { type } = column;
40
- const { typeId } = type;
41
-
42
- // map bignum to number
43
- if (typeId === INTEGER && type.bitWidth >= 64) {
44
- const size = column.length;
45
- const array = new Float64Array(size);
46
- for (let row = 0; row < size; ++row) {
47
- const v = column.get(row);
48
- array[row] = v == null ? NaN : Number(v);
49
- }
50
- return array;
51
- }
52
-
53
- // otherwise use Arrow JS defaults
54
- return column.toArray();
55
- }