@uwdata/mosaic-plot 0.11.0 → 0.12.1

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.
@@ -1,6 +1,6 @@
1
1
  import { interpolatorBarycentric, interpolateNearest, interpolatorRandomWalk } from '@observablehq/plot';
2
2
  import { toDataColumns } from '@uwdata/mosaic-core';
3
- import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
3
+ import { ExprNode, Query, bin2d, binLinear2d, collectColumns, count, isAggregateExpression, isBetween, lt, lte, sum } from '@uwdata/mosaic-sql';
4
4
  import { Transient } from '../symbols.js';
5
5
  import { binExpr } from './util/bin-expr.js';
6
6
  import { dericheConfig, dericheConv2d } from './util/density.js';
@@ -70,14 +70,14 @@ export class Grid2DMark extends Mark {
70
70
  super.setPlot(plot, index);
71
71
  }
72
72
 
73
- get filterIndexable() {
73
+ get filterStable() {
74
74
  const xdom = this.plot.getAttribute('xDomain');
75
75
  const ydom = this.plot.getAttribute('yDomain');
76
76
  return xdom && ydom && !xdom[Transient] && !ydom[Transient];
77
77
  }
78
78
 
79
79
  query(filter = []) {
80
- const { interpolate, pad, channels, densityMap, source } = this;
80
+ const { interpolate, pad, channels, densityMap } = this;
81
81
  const [x0, x1] = this.extentX = extentX(this, filter);
82
82
  const [y0, y1] = this.extentY = extentY(this, filter);
83
83
  const [nx, ny] = this.bins = this.binDimensions();
@@ -91,15 +91,17 @@ export class Grid2DMark extends Mark {
91
91
  : [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
92
92
 
93
93
  const q = Query
94
- .from(source.table)
94
+ .from(this.sourceTable())
95
95
  .where(filter.concat(bounds));
96
96
 
97
+ /** @type {string[]} */
97
98
  const groupby = this.groupby = [];
99
+ /** @type {Record<string, ExprNode>} */
98
100
  const aggrMap = {};
99
101
  for (const c of channels) {
100
102
  if (Object.hasOwn(c, 'field')) {
101
103
  const { as, channel, field } = c;
102
- if (field.aggregate) {
104
+ if (isAggregateExpression(field)) {
103
105
  // include custom aggregate
104
106
  aggrMap[channel] = field;
105
107
  densityMap[channel] = true;
@@ -123,7 +125,7 @@ export class Grid2DMark extends Mark {
123
125
  // if no aggregates, default to count density
124
126
  if (!aggr.length) {
125
127
  aggr.push(DENSITY);
126
- aggrMap.density = count();
128
+ aggrMap[DENSITY] = count();
127
129
  }
128
130
 
129
131
  // generate grid binning query
@@ -131,10 +133,11 @@ export class Grid2DMark extends Mark {
131
133
  if (aggr.length > 1) {
132
134
  throw new Error('Linear binning not applicable to multiple aggregates.');
133
135
  }
134
- if (!aggrMap.density) {
136
+ if (!aggrMap[DENSITY]) {
135
137
  throw new Error('Linear binning not applicable to custom aggregates.');
136
138
  }
137
- return binLinear2d(q, x, y, aggrMap[DENSITY], nx, groupby);
139
+ const weight = collectColumns(aggrMap[DENSITY])[0];
140
+ return binLinear2d(q, x, y, weight, nx, groupby);
138
141
  } else {
139
142
  return bin2d(q, x, y, aggrMap, nx, groupby);
140
143
  }
@@ -228,47 +231,3 @@ function maybeInterpolate(interpolate = 'none') {
228
231
  }
229
232
  throw new Error(`invalid interpolate: ${interpolate}`);
230
233
  }
231
-
232
- function bin2d(q, xp, yp, aggs, xn, groupby) {
233
- return q
234
- .select({
235
- index: sql`FLOOR(${xp})::INTEGER + FLOOR(${yp})::INTEGER * ${xn}`,
236
- ...aggs
237
- })
238
- .groupby('index', groupby);
239
- }
240
-
241
- function binLinear2d(q, xp, yp, density, xn, groupby) {
242
- const w = density?.column ? `* ${density.column}` : '';
243
- const subq = (i, w) => q.clone().select({ xp, yp, i, w });
244
-
245
- // grid[xu + yu * xn] += (xv - xp) * (yv - yp) * wi;
246
- const a = subq(
247
- sql`FLOOR(xp)::INTEGER + FLOOR(yp)::INTEGER * ${xn}`,
248
- sql`(FLOOR(xp)::INTEGER + 1 - xp) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
249
- );
250
-
251
- // grid[xu + yv * xn] += (xv - xp) * (yp - yu) * wi;
252
- const b = subq(
253
- sql`FLOOR(xp)::INTEGER + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
254
- sql`(FLOOR(xp)::INTEGER + 1 - xp) * (yp - FLOOR(yp)::INTEGER)${w}`
255
- );
256
-
257
- // grid[xv + yu * xn] += (xp - xu) * (yv - yp) * wi;
258
- const c = subq(
259
- sql`FLOOR(xp)::INTEGER + 1 + FLOOR(yp)::INTEGER * ${xn}`,
260
- sql`(xp - FLOOR(xp)::INTEGER) * (FLOOR(yp)::INTEGER + 1 - yp)${w}`
261
- );
262
-
263
- // grid[xv + yv * xn] += (xp - xu) * (yp - yu) * wi;
264
- const d = subq(
265
- sql`FLOOR(xp)::INTEGER + 1 + (FLOOR(yp)::INTEGER + 1) * ${xn}`,
266
- sql`(xp - FLOOR(xp)::INTEGER) * (yp - FLOOR(yp)::INTEGER)${w}`
267
- );
268
-
269
- return Query
270
- .from(Query.unionAll(a, b, c, d))
271
- .select({ index: 'i', density: sum('w') }, groupby)
272
- .groupby('index', groupby)
273
- .having(neq('density', 0));
274
- }
@@ -1,4 +1,4 @@
1
- import { Query, isNotNull, sql } from '@uwdata/mosaic-sql';
1
+ import { Query, abs, add, and, bitAnd, cond, div, float64, gt, int32, isAggregateExpression, isNotNull, lt, mul, neq, pow, round, sub } from '@uwdata/mosaic-sql';
2
2
  import { Transient } from '../symbols.js';
3
3
  import { extentX, extentY, xyext } from './util/extent.js';
4
4
  import { Mark } from './Mark.js';
@@ -15,7 +15,7 @@ export class HexbinMark extends Mark {
15
15
  });
16
16
  }
17
17
 
18
- get filterIndexable() {
18
+ get filterStable() {
19
19
  const xdom = this.plot.getAttribute('xDomain');
20
20
  const ydom = this.plot.getAttribute('yDomain');
21
21
  return xdom && ydom && !xdom[Transient] && !ydom[Transient];
@@ -23,24 +23,24 @@ export class HexbinMark extends Mark {
23
23
 
24
24
  query(filter = []) {
25
25
  if (this.hasOwnData()) return null;
26
- const { plot, binWidth, channels, source } = this;
26
+ const { plot, binWidth, channels } = this;
27
27
 
28
28
  // Extract channel information, update top-level query
29
29
  // and extract dependent columns for aggregates
30
- let x, y;
30
+ let xc, yc;
31
31
  const dims = new Set;
32
32
  const cols = {};
33
33
  for (const c of channels) {
34
34
  if (c.channel === 'orderby') {
35
35
  // ignore ordering, as we will aggregate
36
36
  } else if (c.channel === 'x') {
37
- x = c;
37
+ xc = c;
38
38
  } else if (c.channel === 'y') {
39
- y = c;
39
+ yc = c;
40
40
  } else if (Object.hasOwn(c, 'field')) {
41
41
  const { as, field } = c;
42
42
  cols[as] = field;
43
- if (!field.aggregate) {
43
+ if (!isAggregateExpression(field)) {
44
44
  dims.add(as);
45
45
  }
46
46
  }
@@ -54,33 +54,66 @@ export class HexbinMark extends Mark {
54
54
  // margins as this is what Observable Plot does.
55
55
  const ox = 0.5 - plot.getAttribute('marginLeft');
56
56
  const oy = 0 - plot.getAttribute('marginTop');
57
- const dx = `${binWidth}::DOUBLE`;
58
- const dy = `${binWidth * (1.5 / Math.sqrt(3))}::DOUBLE`;
59
- const xr = `${plot.innerWidth() / (x2 - x1)}::DOUBLE`;
60
- const yr = `${plot.innerHeight() / (y2 - y1)}::DOUBLE`;
57
+ const dx = float64(binWidth);
58
+ const dy = float64(binWidth * (1.5 / Math.sqrt(3)));
59
+ const xr = float64(plot.innerWidth() / (x2 - x1));
60
+ const yr = float64(plot.innerHeight() / (y2 - y1));
61
+
62
+ // column references
63
+ const x ='_x';
64
+ const y = '_y';
65
+ const px = '_px';
66
+ const py = '_py';
67
+ const pi = '_pi';
68
+ const pj = '_pj';
69
+ const tt = '_tt';
61
70
 
62
71
  // Top-level query maps from screen space back to data values.
63
72
  // Doing so ensures that Plot generates correct data-driven scales.
64
73
  return Query.select({
65
- [x.as]: sql`${x1}::DOUBLE + ((_x + 0.5 * (_y & 1)) * ${dx} + ${ox})::DOUBLE / ${xr}`,
66
- [y.as]: sql`${y2}::DOUBLE - (_y * ${dy} + ${oy})::DOUBLE / ${yr}`,
74
+ [xc.as]: add(
75
+ float64(x1),
76
+ div(add(mul(add(x, mul(0.5, bitAnd(y, 1))), dx), ox), xr)
77
+ ),
78
+ [yc.as]: sub(
79
+ float64(y2),
80
+ div(add(mul(y, dy), oy), yr)
81
+ ),
67
82
  ...cols
68
83
  })
69
- .groupby('_x', '_y', ...dims)
84
+ .groupby(x, y, ...dims)
70
85
  .from(
71
86
  // Subquery performs hex binning in screen space and also passes
72
87
  // original columns through (the DB should optimize this).
73
88
  Query.select({
74
- _py: sql`(${yr} * (${y2}::DOUBLE - ${y.field}) - ${oy}) / ${dy}`,
75
- _pj: sql`ROUND(_py)::INTEGER`,
76
- _px: sql`(${xr} * (${x.field} - ${x1}::DOUBLE) - ${ox}) / ${dx} - 0.5 * (_pj & 1)`,
77
- _pi: sql`ROUND(_px)::INTEGER`,
78
- _tt: sql`ABS(_py-_pj) * 3 > 1 AND (_px-_pi)**2 + (_py-_pj)**2 > (_px - _pi - 0.5 * CASE WHEN _px < _pi THEN -1 ELSE 1 END)**2 + (_py - _pj - CASE WHEN _py < _pj THEN -1 ELSE 1 END)**2`,
79
- _x: sql`CASE WHEN _tt THEN (_pi + (CASE WHEN _px < _pi THEN -0.5 ELSE 0.5 END) + (CASE WHEN _pj & 1 <> 0 THEN 0.5 ELSE -0.5 END))::INTEGER ELSE _pi END`,
80
- _y: sql`CASE WHEN _tt THEN (_pj + CASE WHEN _py < _pj THEN -1 ELSE 1 END)::INTEGER ELSE _pj END`
89
+ [py]: div(sub(mul(yr, sub(y2, yc.field)), oy), dy),
90
+ [pj]: int32(round(py)),
91
+ [px]: sub(
92
+ div(sub(mul(xr, sub(xc.field, x1)), ox), dx),
93
+ mul(0.5, bitAnd(pj, 1))
94
+ ),
95
+ [pi]: int32(round(px)),
96
+ [tt]: and(
97
+ gt(mul(abs(sub(py, pj)), 3), 1),
98
+ gt(
99
+ add(pow(sub(px, pi), 2), pow(sub(py, pj), 2)),
100
+ add(
101
+ pow(sub(sub(px, pi), mul(0.5, cond(lt(px, pi), -1, 1))), 2),
102
+ pow(sub(sub(py, pj), cond(lt(py, pj), -1, 1)), 2)
103
+ )
104
+ )
105
+ ),
106
+ [x]: cond(tt,
107
+ int32(add(
108
+ add(pi, cond(lt(px, pi), -0.5, 0.5)),
109
+ cond(neq(bitAnd(pj, 1), 0), 0.5, -0.5)
110
+ )),
111
+ pi
112
+ ),
113
+ [y]: cond(tt, int32(add(pj, cond(lt(py, pj), -1, 1))), pj)
81
114
  }, '*')
82
- .from(source.table)
83
- .where(isNotNull(x.field), isNotNull(y.field), filter)
115
+ .from(this.sourceTable())
116
+ .where(isNotNull(xc.field), isNotNull(yc.field), filter)
84
117
  );
85
118
  }
86
119
  }
package/src/marks/Mark.js CHANGED
@@ -1,5 +1,5 @@
1
- import { MosaicClient, toDataColumns } from '@uwdata/mosaic-core';
2
- import { Query, Ref, column, isParamLike } from '@uwdata/mosaic-sql';
1
+ import { isParam, MosaicClient, toDataColumns } from '@uwdata/mosaic-core';
2
+ import { Query, SelectQuery, collectParams, column, isAggregateExpression, isColumnRef, isNode, isParamLike } from '@uwdata/mosaic-sql';
3
3
  import { isColor } from './util/is-color.js';
4
4
  import { isConstantOption } from './util/is-constant-option.js';
5
5
  import { isSymbol } from './util/is-symbol.js';
@@ -15,7 +15,7 @@ const isFieldObject = (channel, field) => {
15
15
  const fieldEntry = (channel, field) => ({
16
16
  channel,
17
17
  field,
18
- as: field instanceof Ref ? field.column : channel
18
+ as: isColumnRef(field) ? field.column : channel
19
19
  });
20
20
  const valueEntry = (channel, value) => ({ channel, value });
21
21
 
@@ -28,16 +28,18 @@ export class Mark extends MosaicClient {
28
28
  super(source?.options?.filterBy);
29
29
  this.type = type;
30
30
  this.reqs = reqs;
31
-
32
31
  this.source = source;
33
- if (isDataArray(this.source)) {
34
- this.data = toDataColumns(this.source);
35
- }
36
32
 
37
33
  const channels = this.channels = [];
38
34
  const detail = this.detail = new Set;
39
35
  const params = this.params = new Set;
40
36
 
37
+ if (isDataArray(source)) {
38
+ this.data = toDataColumns(source);
39
+ } else if (isParam(source?.table)) {
40
+ params.add(source.table);
41
+ }
42
+
41
43
  const process = (channel, entry) => {
42
44
  const type = typeof entry;
43
45
  if (channel === 'channels') {
@@ -62,20 +64,16 @@ export class Mark extends MosaicClient {
62
64
  channels.push(fieldEntry(channel, column(entry)));
63
65
  }
64
66
  } else if (isParamLike(entry)) {
65
- if (Array.isArray(entry.columns)) {
66
- // we currently duck-type to having a columns array
67
- // as a check that this is SQLExpression-compatible
68
- channels.push(fieldEntry(channel, entry));
69
- params.add(entry);
70
- } else {
71
- const c = valueEntry(channel, entry.value);
72
- channels.push(c);
73
- entry.addEventListener('value', value => {
74
- // update immediately, the value is simply passed to Plot
75
- c.value = value;
76
- return this.update();
77
- });
78
- }
67
+ const c = valueEntry(channel, entry.value);
68
+ channels.push(c);
69
+ entry.addEventListener('value', value => {
70
+ // update immediately, the value is simply passed to Plot
71
+ c.value = value;
72
+ return this.update();
73
+ });
74
+ } else if (isNode(entry)) {
75
+ collectParams(entry).forEach(p => params.add(p));
76
+ channels.push(fieldEntry(channel, entry))
79
77
  } else if (type === 'object' && isFieldObject(channel, entry)) {
80
78
  channels.push(fieldEntry(channel, entry));
81
79
  } else if (entry !== undefined) {
@@ -99,6 +97,11 @@ export class Mark extends MosaicClient {
99
97
  if (this.source?.table) this.queryPending();
100
98
  }
101
99
 
100
+ sourceTable() {
101
+ const table = this.source?.table;
102
+ return table ? (isParam(table) ? table.value : table) : null;
103
+ }
104
+
102
105
  hasOwnData() {
103
106
  return this.source == null || isDataArray(this.source);
104
107
  }
@@ -121,7 +124,7 @@ export class Mark extends MosaicClient {
121
124
  fields() {
122
125
  if (this.hasOwnData()) return null;
123
126
 
124
- const { source: { table }, channels, reqs } = this;
127
+ const { channels, reqs } = this;
125
128
  const fields = new Map;
126
129
  for (const { channel, field } of channels) {
127
130
  if (!field) continue;
@@ -132,6 +135,7 @@ export class Mark extends MosaicClient {
132
135
  reqs[channel]?.forEach(s => entry.add(s));
133
136
  }
134
137
 
138
+ const table = this.sourceTable();
135
139
  return Array.from(fields, ([c, s]) => ({ table, column: c, stats: s }));
136
140
  }
137
141
 
@@ -154,8 +158,7 @@ export class Mark extends MosaicClient {
154
158
  */
155
159
  query(filter = []) {
156
160
  if (this.hasOwnData()) return null;
157
- const { channels, source: { table } } = this;
158
- return markQuery(channels, table).where(filter);
161
+ return markQuery(this.channels, this.sourceTable()).where(filter);
159
162
  }
160
163
 
161
164
  queryPending() {
@@ -211,7 +214,7 @@ export function channelOption(c, columns) {
211
214
  * @param {*} table the table to query.
212
215
  * @param {*} skip an optional array of channels to skip.
213
216
  * Mark subclasses can skip channels that require special handling.
214
- * @returns {Query} a Query instance
217
+ * @returns {SelectQuery} a Query instance
215
218
  */
216
219
  export function markQuery(channels, table, skip = []) {
217
220
  const q = Query.from({ source: table });
@@ -225,7 +228,7 @@ export function markQuery(channels, table, skip = []) {
225
228
  if (channel === 'orderby') {
226
229
  q.orderby(c.value);
227
230
  } else if (field) {
228
- if (field.aggregate) {
231
+ if (isAggregateExpression(field)) {
229
232
  aggr = true;
230
233
  } else {
231
234
  if (dims.has(as)) continue;
@@ -173,6 +173,7 @@ function alphaScale(mark, prop) {
173
173
  opacity: {
174
174
  type: plot.getAttribute('opacityScale'),
175
175
  domain,
176
+ range: plot.getAttribute('opacityRange'),
176
177
  clamp: plot.getAttribute('opacityClamp'),
177
178
  nice: plot.getAttribute('opacityNice'),
178
179
  reverse: plot.getAttribute('opacityReverse'),
@@ -36,7 +36,7 @@ export class RasterTileMark extends Grid2DMark {
36
36
  }
37
37
 
38
38
  tileQuery(extent) {
39
- const { interpolate, pad, channels, densityMap, source } = this;
39
+ const { interpolate, pad, channels, densityMap } = this;
40
40
  const [[x0, x1], [y0, y1]] = extent;
41
41
  const [nx, ny] = this.bins;
42
42
  const [x, bx] = binExpr(this, 'x', nx, [x0, x1], pad);
@@ -49,7 +49,7 @@ export class RasterTileMark extends Grid2DMark {
49
49
  : [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
50
50
 
51
51
  const q = Query
52
- .from(source.table)
52
+ .from(this.sourceTable())
53
53
  .where(bounds);
54
54
 
55
55
  const groupby = this.groupby = [];
@@ -1,7 +1,7 @@
1
1
  import { range } from 'd3';
2
2
  import { toDataColumns } from '@uwdata/mosaic-core';
3
3
  import {
4
- Query, max, min, castDouble, isNotNull,
4
+ Query, max, min, float64, isNotNull,
5
5
  regrIntercept, regrSlope, regrCount,
6
6
  regrSYY, regrSXX, regrAvgX
7
7
  } from '@uwdata/mosaic-sql';
@@ -43,8 +43,8 @@ export class RegressionMark extends Mark {
43
43
  ssy: regrSYY(y, x),
44
44
  ssx: regrSXX(y, x),
45
45
  xm: regrAvgX(y, x),
46
- x0: castDouble(min(x).where(isNotNull(y))),
47
- x1: castDouble(max(x).where(isNotNull(y)))
46
+ x0: float64(min(x).where(isNotNull(y))),
47
+ x1: float64(max(x).where(isNotNull(y)))
48
48
  })
49
49
  .select(groupby)
50
50
  .groupby(groupby);
@@ -1,12 +1,12 @@
1
- import { sql } from '@uwdata/mosaic-sql';
1
+ import { bin1d } from '@uwdata/mosaic-sql';
2
2
  import { channelScale } from './channel-scale.js';
3
3
 
4
4
  /**
5
5
  * Generates a SQL expression for 1D pixel-level binning.
6
6
  * Adjusts for scale transformations (log, sqrt, ...).
7
7
  * Returns a [binExpression, field] array, where field is the
8
- * input value that is binned. Often the field is just a column
9
- * name. For time data, fields are mapped to numerical timestamps.
8
+ * input value being binned. Often the field is just a column
9
+ * name. For time data, fields are mapped to millisecond timestamps.
10
10
  */
11
11
  export function binExpr(mark, channel, n, extent, pad = 1, expr) {
12
12
  // get base expression, the channel field unless otherwise given
@@ -21,10 +21,5 @@ export function binExpr(mark, channel, n, extent, pad = 1, expr) {
21
21
  const [lo, hi] = extent.map(v => apply(v));
22
22
  const v = sqlApply(expr);
23
23
  const f = type === 'time' || type === 'utc' ? v : expr;
24
- const d = hi === lo ? 0 : (n - pad) / (hi - lo);
25
- const s = d !== 1 ? ` * ${d}::DOUBLE` : '';
26
- const bin = reverse
27
- ? sql`(${hi} - ${v}::DOUBLE)${s}`
28
- : sql`(${v}::DOUBLE - ${lo})${s}`;
29
- return [bin, f];
24
+ return [bin1d(v, lo, hi, n - pad, reverse), f];
30
25
  }
@@ -1,5 +1,6 @@
1
1
  import { scaleLinear } from 'd3';
2
2
  import { Fixed, Transient } from '../../symbols.js';
3
+ import { BetweenOpNode, walk } from '@uwdata/mosaic-sql';
3
4
 
4
5
  export const xext = { x: ['min', 'max'] };
5
6
  export const yext = { y: ['min', 'max'] };
@@ -36,19 +37,15 @@ export function filteredExtent(filter, column) {
36
37
 
37
38
  let lo;
38
39
  let hi;
39
- const visitor = (type, clause) => {
40
- if (type === 'BETWEEN' && `${clause.field}` === column) {
41
- const { range } = clause;
42
- if (range && (lo == null || range[0] < lo)) lo = range[0];
43
- if (range && (hi == null || range[1] > hi)) hi = range[1];
44
- }
45
- };
46
40
 
47
- if (Array.isArray(filter)) {
48
- filter.forEach(p => p.visit?.(visitor));
49
- } else if (filter.visit) {
50
- filter.visit(visitor);
51
- }
41
+ [filter].flat().forEach(p => walk(p, (node) => {
42
+ if (node instanceof BetweenOpNode && `${node.expr}` === column) {
43
+ // @ts-ignore
44
+ const extent = (node.extent ?? []).map(v => v?.value ?? v);
45
+ if (lo == null || extent[0] < lo) lo = extent[0];
46
+ if (hi == null || extent[1] > hi) hi = extent[1];
47
+ }
48
+ }));
52
49
 
53
50
  return lo != null && hi != null && lo !== hi ? [lo, hi] : undefined;
54
51
  }
@@ -102,54 +102,42 @@ function inferLabel(key, spec, marks) {
102
102
  const fields = marks.map(mark => mark.channelField(key)?.field);
103
103
  if (fields.every(x => x == null)) return; // no columns found
104
104
 
105
- // check for consistent columns / labels
106
- let candCol;
107
- let candLabel;
108
- let type;
105
+ // check for consistent label
106
+ let candidate;
109
107
  for (let i = 0; i < fields.length; ++i) {
110
- const { column, label } = fields[i] || {};
111
- if (column === undefined && label === undefined) {
108
+ const label = fieldLabel(fields[i]);
109
+ if (label === undefined) {
112
110
  continue;
113
- } else if (candCol === undefined && candLabel === undefined) {
114
- candCol = column;
115
- candLabel = label;
116
- type = getType(marks[i].data, key) || 'number';
117
- } else if (candLabel !== label) {
118
- candLabel = undefined;
119
- } else if (candCol !== column) {
120
- candCol = undefined;
111
+ } else if (candidate === undefined) {
112
+ candidate = label;
113
+ } else if (candidate !== label) {
114
+ candidate = undefined;
121
115
  }
122
116
  }
123
- let candidate = candLabel || candCol;
124
117
  if (candidate === undefined) return;
125
118
 
126
- // adjust candidate label formatting
127
- if ((type === 'number' || type === 'date') && (key === 'x' || key === 'y')) {
128
- if (scale.percent) candidate = `${candidate} (%)`;
129
- const order = (key === 'x' ? 1 : -1) * (scale.reverse ? -1 : 1);
130
- if (key === 'x' || scale.labelAnchor === 'center') {
131
- candidate = (key === 'x') === order < 0 ? `← ${candidate}` : `${candidate} →`;
132
- } else {
133
- candidate = `${order < 0 ? '↑ ' : '↓ '}${candidate}`;
134
- }
135
- }
136
-
137
119
  // add label to spec
138
120
  spec[key] = { ...scale, label: candidate };
139
121
  }
140
122
 
141
- function annotatePlot(svg, indices) {
142
- const facets = svg.querySelectorAll('g[aria-label="facet"]');
143
- if (facets.length) {
144
- for (const facet of facets) {
145
- annotateMarks(facet, indices);
146
- }
147
- } else {
148
- annotateMarks(svg, indices);
123
+ function fieldLabel(field) {
124
+ if (!field) return undefined;
125
+ switch (field.type) {
126
+ case 'COLUMN_REF': return field.column;
127
+ case 'CAST': return fieldLabel(field.expr);
128
+ case 'FUNCTION':
129
+ if (field.name === 'make_date') return 'date';
130
+ break;
149
131
  }
132
+ return exprLabel(field);
133
+ }
134
+
135
+ function exprLabel(field) {
136
+ const s = `${field}`.replaceAll('"', '').replaceAll('(*)', '()');
137
+ return s.endsWith('()') ? s.slice(0, -2) : s;
150
138
  }
151
139
 
152
- function annotateMarks(svg, indices) {
140
+ function annotatePlot(svg, indices) {
153
141
  let index = -1;
154
142
  for (const child of svg.children) {
155
143
  const aria = child.getAttribute('aria-label') || '';
@@ -161,16 +149,3 @@ function annotateMarks(svg, indices) {
161
149
  }
162
150
  }
163
151
  }
164
-
165
- function getType(data, channel) {
166
- if (!data) return;
167
- const { columns } = data;
168
- const col = columns[channel] ?? columns[channel+'1'] ?? columns[channel+'2'];
169
- if (col) {
170
- for (const v of col) {
171
- if (v != null) {
172
- return v instanceof Date ? 'date' : typeof v;
173
- }
174
- }
175
- }
176
- }