@uwdata/mosaic-plot 0.8.0 → 0.10.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.
@@ -25,72 +25,62 @@ export class HexbinMark extends Mark {
25
25
  if (this.hasOwnData()) return null;
26
26
  const { plot, binWidth, channels, source } = this;
27
27
 
28
- // get x / y extents, may update plot domainX / domainY
29
- const [x1, x2] = extentX(this, filter);
30
- const [y1, y2] = extentY(this, filter);
31
-
32
- // Adjust screen-space coordinates by top/left
33
- // margins as this is what Observable Plot does.
34
- // TODO use zero margins when faceted?
35
- const ox = 0.5 - plot.getAttribute('marginLeft');
36
- const oy = 0 - plot.getAttribute('marginTop');
37
- const dx = `${binWidth}::DOUBLE`;
38
- const dy = `${binWidth * (1.5 / Math.sqrt(3))}::DOUBLE`;
39
- const xr = `${plot.innerWidth() / (x2 - x1)}::DOUBLE`;
40
- const yr = `${plot.innerHeight() / (y2 - y1)}::DOUBLE`;
41
-
42
28
  // Extract channel information, update top-level query
43
29
  // and extract dependent columns for aggregates
44
30
  let x, y;
45
- const aggr = new Set;
31
+ const dims = new Set;
46
32
  const cols = {};
47
- let orderby;
48
33
  for (const c of channels) {
49
34
  if (c.channel === 'orderby') {
50
- orderby = c.value; // TODO revisit once groupby is added
35
+ // ignore ordering, as we will aggregate
51
36
  } else if (c.channel === 'x') {
52
37
  x = c;
53
38
  } else if (c.channel === 'y') {
54
39
  y = c;
55
40
  } else if (Object.hasOwn(c, 'field')) {
56
- cols[c.as] = c.field;
57
- if (c.field.aggregate) {
58
- c.field.columns.forEach(col => aggr.add(col));
41
+ const { as, field } = c;
42
+ cols[as] = field;
43
+ if (!field.aggregate) {
44
+ dims.add(as);
59
45
  }
60
46
  }
61
47
  }
62
48
 
63
- // Top-level query; we add a hex binning subquery below
64
- // Maps binned screen space coordinates back to data
65
- // values to ensure we get correct data-driven scales
66
- const q = Query.select({
67
- [x.as]: sql`${x1}::DOUBLE + ((x + 0.5 * (y & 1)) * ${dx} + ${ox})::DOUBLE / ${xr}`,
68
- [y.as]: sql`${y2}::DOUBLE - (y * ${dy} + ${oy})::DOUBLE / ${yr}`,
69
- ...cols
70
- }).groupby('x', 'y');
71
-
72
- if (orderby) q.orderby(orderby);
49
+ // get x / y extents, may update plot xDomain / yDomain
50
+ const [x1, x2] = extentX(this, filter);
51
+ const [y1, y2] = extentY(this, filter);
73
52
 
74
- // Map x/y channels to screen space
75
- const xx = `${xr} * (${x.field} - ${x1}::DOUBLE)`;
76
- const yy = `${yr} * (${y2}::DOUBLE - ${y.field})`;
53
+ // Adjust screen-space coordinates by top/left
54
+ // margins as this is what Observable Plot does.
55
+ const ox = 0.5 - plot.getAttribute('marginLeft');
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`;
77
61
 
78
- // Perform hex binning of x/y coordinates
79
- // TODO add groupby dims
80
- const hex = Query
81
- .select({
82
- py: sql`(${yy} - ${oy}) / ${dy}`,
83
- pj: sql`ROUND(py)::INTEGER`,
84
- px: sql`(${xx} - ${ox}) / ${dx} - 0.5 * (pj & 1)`,
85
- pi: sql`ROUND(px)::INTEGER`,
86
- 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`,
87
- 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`,
88
- y: sql`CASE WHEN tt THEN (pj + CASE WHEN py < pj THEN -1 ELSE 1 END)::INTEGER ELSE pj END`
62
+ // Top-level query maps from screen space back to data values.
63
+ // Doing so ensures that Plot generates correct data-driven scales.
64
+ 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}`,
67
+ ...cols
89
68
  })
90
- .select(Array.from(aggr))
91
- .from(source.table)
92
- .where(isNotNull(x.field), isNotNull(y.field), filter)
93
-
94
- return q.from(hex);
69
+ .groupby('_x', '_y', ...dims)
70
+ .from(
71
+ // Subquery performs hex binning in screen space and also passes
72
+ // original columns through (the DB should optimize this).
73
+ 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`
81
+ }, '*')
82
+ .from(source.table)
83
+ .where(isNotNull(x.field), isNotNull(y.field), filter)
84
+ );
95
85
  }
96
86
  }
package/src/marks/Mark.js CHANGED
@@ -1,9 +1,8 @@
1
- import { MosaicClient } from '@uwdata/mosaic-core';
1
+ import { MosaicClient, toDataColumns } from '@uwdata/mosaic-core';
2
2
  import { Query, Ref, column, 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';
6
- import { toDataColumns } from './util/to-data-columns.js';
7
6
  import { Transform } from '../symbols.js';
8
7
 
9
8
  const isColorChannel = channel => channel === 'stroke' || channel === 'fill';
@@ -91,7 +90,7 @@ export class Mark extends MosaicClient {
91
90
 
92
91
  /**
93
92
  * @param {import('../plot.js').Plot} plot The plot.
94
- * @param {number} index
93
+ * @param {number} index
95
94
  */
96
95
  setPlot(plot, index) {
97
96
  this.plot = plot;
@@ -181,24 +180,8 @@ export class Mark extends MosaicClient {
181
180
  * @returns {object[]}
182
181
  */
183
182
  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;
183
+ const { type, data, detail, channels } = this;
184
+ return markPlotSpec(type, detail, channels, data);
202
185
  }
203
186
  }
204
187
 
@@ -258,3 +241,27 @@ export function markQuery(channels, table, skip = []) {
258
241
 
259
242
  return q;
260
243
  }
244
+
245
+
246
+ /**
247
+ * Generate an array of Plot mark specifications.
248
+ * @returns {object[]}
249
+ */
250
+ export function markPlotSpec(type, detail, channels, data, options = {}) {
251
+ // @ts-ignore
252
+ const { numRows: length, values, columns } = data ?? {};
253
+
254
+ // populate plot specification options
255
+ const side = {};
256
+ for (const c of channels) {
257
+ const obj = detail.has(c.channel) ? side : options;
258
+ obj[c.channel] = channelOption(c, columns);
259
+ }
260
+ if (detail.size) options.channels = side;
261
+
262
+ // if provided raw source values (not objects) pass as-is
263
+ // otherwise we pass columnar data directy in the options
264
+ const specData = values ?? (data ? { length } : null);
265
+ const spec = [{ type, data: specData, options }];
266
+ return spec;
267
+ }
@@ -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
  })
@@ -1,4 +1,5 @@
1
1
  import { range } from 'd3';
2
+ import { toDataColumns } from '@uwdata/mosaic-core';
2
3
  import {
3
4
  Query, max, min, castDouble, isNotNull,
4
5
  regrIntercept, regrSlope, regrCount,
@@ -7,7 +8,6 @@ import {
7
8
  import { qt } from './util/stats.js';
8
9
  import { Mark, channelOption } from './Mark.js';
9
10
  import { handleParam } from './util/handle-param.js';
10
- import { toDataColumns } from './util/to-data-columns.js';
11
11
 
12
12
  export class RegressionMark extends Mark {
13
13
  constructor(source, options) {
@@ -118,7 +118,7 @@ function concat(a, b) {
118
118
 
119
119
  function linePoints(fit) {
120
120
  // eslint-disable-next-line no-unused-vars
121
- const { x0, x1, xm, intercept, slope, n, ssx, ssy, ...rest } = fit.columns;
121
+ const { x0 = [], x1 = [], xm, intercept, slope, n, ssx, ssy, ...rest } = fit.columns;
122
122
  const predict = (x, i) => intercept[i] + x * slope[i];
123
123
  const x = concat(x0, x1);
124
124
  const y = concat(x0.map(predict), x1.map(predict));
@@ -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
+ }
@@ -163,3 +163,91 @@ export function qt(p, dof) {
163
163
  x = Math.sqrt((dof * (1 - x)) / x);
164
164
  return p > 0.5 ? x : -x;
165
165
  }
166
+
167
+ /**
168
+ * Approximate inverse error function.
169
+ * @param {number} x
170
+ * @returns {number}
171
+ */
172
+ export function erfinv(x) {
173
+ // Implementation from "Approximating the erfinv function" by Mike Giles,
174
+ // GPU Computing Gems, volume 2, 2010.
175
+ // Ported from Apache Commons Math, http://www.apache.org/licenses/LICENSE-2.0
176
+
177
+ // beware that the logarithm argument must be
178
+ // computed as (1.0 - x) * (1.0 + x),
179
+ // it must NOT be simplified as 1.0 - x * x as this
180
+ // would induce rounding errors near the boundaries +/-1
181
+ let w = - Math.log((1 - x) * (1 + x));
182
+ let p;
183
+
184
+ if (w < 6.25) {
185
+ w -= 3.125;
186
+ p = -3.6444120640178196996e-21;
187
+ p = -1.685059138182016589e-19 + p * w;
188
+ p = 1.2858480715256400167e-18 + p * w;
189
+ p = 1.115787767802518096e-17 + p * w;
190
+ p = -1.333171662854620906e-16 + p * w;
191
+ p = 2.0972767875968561637e-17 + p * w;
192
+ p = 6.6376381343583238325e-15 + p * w;
193
+ p = -4.0545662729752068639e-14 + p * w;
194
+ p = -8.1519341976054721522e-14 + p * w;
195
+ p = 2.6335093153082322977e-12 + p * w;
196
+ p = -1.2975133253453532498e-11 + p * w;
197
+ p = -5.4154120542946279317e-11 + p * w;
198
+ p = 1.051212273321532285e-09 + p * w;
199
+ p = -4.1126339803469836976e-09 + p * w;
200
+ p = -2.9070369957882005086e-08 + p * w;
201
+ p = 4.2347877827932403518e-07 + p * w;
202
+ p = -1.3654692000834678645e-06 + p * w;
203
+ p = -1.3882523362786468719e-05 + p * w;
204
+ p = 0.0001867342080340571352 + p * w;
205
+ p = -0.00074070253416626697512 + p * w;
206
+ p = -0.0060336708714301490533 + p * w;
207
+ p = 0.24015818242558961693 + p * w;
208
+ p = 1.6536545626831027356 + p * w;
209
+ } else if (w < 16.0) {
210
+ w = Math.sqrt(w) - 3.25;
211
+ p = 2.2137376921775787049e-09;
212
+ p = 9.0756561938885390979e-08 + p * w;
213
+ p = -2.7517406297064545428e-07 + p * w;
214
+ p = 1.8239629214389227755e-08 + p * w;
215
+ p = 1.5027403968909827627e-06 + p * w;
216
+ p = -4.013867526981545969e-06 + p * w;
217
+ p = 2.9234449089955446044e-06 + p * w;
218
+ p = 1.2475304481671778723e-05 + p * w;
219
+ p = -4.7318229009055733981e-05 + p * w;
220
+ p = 6.8284851459573175448e-05 + p * w;
221
+ p = 2.4031110387097893999e-05 + p * w;
222
+ p = -0.0003550375203628474796 + p * w;
223
+ p = 0.00095328937973738049703 + p * w;
224
+ p = -0.0016882755560235047313 + p * w;
225
+ p = 0.0024914420961078508066 + p * w;
226
+ p = -0.0037512085075692412107 + p * w;
227
+ p = 0.005370914553590063617 + p * w;
228
+ p = 1.0052589676941592334 + p * w;
229
+ p = 3.0838856104922207635 + p * w;
230
+ } else if (Number.isFinite(w)) {
231
+ w = Math.sqrt(w) - 5.0;
232
+ p = -2.7109920616438573243e-11;
233
+ p = -2.5556418169965252055e-10 + p * w;
234
+ p = 1.5076572693500548083e-09 + p * w;
235
+ p = -3.7894654401267369937e-09 + p * w;
236
+ p = 7.6157012080783393804e-09 + p * w;
237
+ p = -1.4960026627149240478e-08 + p * w;
238
+ p = 2.9147953450901080826e-08 + p * w;
239
+ p = -6.7711997758452339498e-08 + p * w;
240
+ p = 2.2900482228026654717e-07 + p * w;
241
+ p = -9.9298272942317002539e-07 + p * w;
242
+ p = 4.5260625972231537039e-06 + p * w;
243
+ p = -1.9681778105531670567e-05 + p * w;
244
+ p = 7.5995277030017761139e-05 + p * w;
245
+ p = -0.00021503011930044477347 + p * w;
246
+ p = -0.00013871931833623122026 + p * w;
247
+ p = 1.0103004648645343977 + p * w;
248
+ p = 4.8499064014085844221 + p * w;
249
+ } else {
250
+ p = Infinity;
251
+ }
252
+ return p * x;
253
+ }
@@ -182,6 +182,7 @@ export const attributeMap = new Map([
182
182
  ['rRange', 'r.range'],
183
183
  ['rClamp', 'r.clamp'],
184
184
  ['rNice', 'r.nice'],
185
+ ['rLabel', 'r.label'],
185
186
  ['rPercent', 'r.percent'],
186
187
  ['rZero', 'r.zero'],
187
188
  ['rBase', 'r.base'],
@@ -1,7 +1,6 @@
1
1
  import * as Plot from '@observablehq/plot';
2
2
  import { setAttributes } from './plot-attributes.js';
3
3
  import { Fixed } from './symbols.js';
4
- import { isArrowTable } from '@uwdata/mosaic-core';
5
4
 
6
5
  const OPTIONS_ONLY_MARKS = new Set([
7
6
  'frame',
@@ -10,6 +9,19 @@ const OPTIONS_ONLY_MARKS = new Set([
10
9
  'graticule'
11
10
  ]);
12
11
 
12
+ // @ts-ignore
13
+ const SELECT_TRANSFORMS = new Map([
14
+ ['first', Plot.selectFirst],
15
+ ['last', Plot.selectLast],
16
+ ['maxX', Plot.selectMaxX],
17
+ ['maxY', Plot.selectMaxY],
18
+ ['minX', Plot.selectMinX],
19
+ ['minY', Plot.selectMinY],
20
+ ['nearest', Plot.pointer],
21
+ ['nearestX', Plot.pointerX],
22
+ ['nearestXY', Plot.pointerY]
23
+ ]);
24
+
13
25
  // construct Plot output
14
26
  // see https://github.com/observablehq/plot
15
27
  export async function plotRenderer(plot) {
@@ -24,27 +36,13 @@ export async function plotRenderer(plot) {
24
36
  const indices = [];
25
37
  for (const mark of marks) {
26
38
  for (const { type, data, options } of mark.plotSpecs()) {
27
- if (OPTIONS_ONLY_MARKS.has(type)) {
28
- spec.marks.push(Plot[type](options));
29
- } else if (isArrowTable(data)) {
30
- // optimized calls to Plot for Arrow:
31
- // https://github.com/observablehq/plot/issues/191#issuecomment-2010986851
32
- const opts = Object.fromEntries(
33
- Object.entries(options).map(([k, v]) => {
34
- let val = v;
35
- if (typeof v === 'string') {
36
- val = data.getChild(v) ?? v;
37
- } else if (typeof v === 'object') {
38
- const value = data.getChild(v.value);
39
- val = value ? {value} : v;
40
- }
41
- return [k, val]
42
- })
43
- );
44
- spec.marks.push(Plot[type]({length: data.numRows}, opts));
45
- } else {
46
- spec.marks.push(Plot[type](data, options));
47
- }
39
+ // prepare mark options
40
+ const { select, ...rest } = options;
41
+ const opt = SELECT_TRANSFORMS.get(select)?.(rest) ?? rest;
42
+ const arg = OPTIONS_ONLY_MARKS.has(type) ? [opt] : [data, opt];
43
+
44
+ // instantiate Plot mark and add to spec
45
+ spec.marks.push(Plot[type](...arg));
48
46
  indices.push(mark.index);
49
47
  }
50
48
  }
@@ -165,6 +163,7 @@ function annotateMarks(svg, indices) {
165
163
  }
166
164
 
167
165
  function getType(data, channel) {
166
+ if (!data) return;
168
167
  const { columns } = data;
169
168
  const col = columns[channel] ?? columns[channel+'1'] ?? columns[channel+'2'];
170
169
  if (col) {
package/src/plot.js CHANGED
@@ -42,8 +42,9 @@ export class Plot {
42
42
  innerHeight(defaultValue = 400) {
43
43
  const { top, bottom } = this.margins();
44
44
  let h = this.getAttribute('height');
45
- if (h == null && defaultValue != null) {
46
- h = defaultValue; // TODO could apply more nuanced logic here
45
+ if (h == null) {
46
+ // TODO could apply more nuanced logic here?
47
+ h = maybeAspectRatio(this, top, bottom) || defaultValue;
47
48
  this.setAttribute('height', h, { silent: true });
48
49
  }
49
50
  return h - top - bottom;
@@ -159,3 +160,14 @@ export class Plot {
159
160
  this.legends.push({ legend, include });
160
161
  }
161
162
  }
163
+
164
+ function maybeAspectRatio(plot, top, bottom) {
165
+ const ar = plot.getAttribute('aspectRatio');
166
+ if (ar == null) return;
167
+ const x = plot.getAttribute('xDomain');
168
+ const y = plot.getAttribute('yDomain');
169
+ if (!x || !y) return;
170
+ const dx = Math.abs(x[1] - x[0]);
171
+ const dy = Math.abs(y[1] - y[0]);
172
+ return dy * plot.innerWidth() / (ar * dx) + top + bottom;
173
+ }
@@ -0,0 +1,43 @@
1
+ export function binStep(span, steps, minstep = 0, logb = Math.LN10) {
2
+ let v;
3
+
4
+ const level = Math.ceil(Math.log(steps) / logb);
5
+ let step = Math.max(
6
+ minstep,
7
+ Math.pow(10, Math.round(Math.log(span) / logb) - level)
8
+ );
9
+
10
+ // increase step size if too many bins
11
+ while (Math.ceil(span / step) > steps) { step *= 10; }
12
+
13
+ // decrease step size if allowed
14
+ const div = [5, 2];
15
+ for (let i = 0, n = div.length; i < n; ++i) {
16
+ v = step / div[i];
17
+ if (v >= minstep && span / v <= steps) step = v;
18
+ }
19
+
20
+ return step;
21
+ }
22
+
23
+ export function bins(min, max, options) {
24
+ let { step, steps, minstep = 0, nice = true } = options;
25
+
26
+ if (nice !== false) {
27
+ // use span to determine step size
28
+ const span = max - min;
29
+ const logb = Math.LN10;
30
+ step = step || binStep(span, steps || 25, minstep, logb);
31
+
32
+ // adjust min/max relative to step
33
+ let v = Math.log(step);
34
+ const precision = v >= 0 ? 0 : ~~(-v / logb) + 1;
35
+ const eps = Math.pow(10, -precision - 1);
36
+ v = Math.floor(min / step + eps) * step;
37
+ min = min < v ? v - step : v;
38
+ max = Math.ceil(max / step) * step;
39
+ steps = Math.round((max - min) / step);
40
+ }
41
+
42
+ return { min, max, steps };
43
+ }
@@ -1,11 +1,20 @@
1
+ import { dateBin } from '@uwdata/mosaic-sql';
1
2
  import { Transform } from '../symbols.js';
2
3
  import { channelScale } from '../marks/util/channel-scale.js';
4
+ import { bins } from './bin-step.js';
5
+ import { timeInterval } from './time-interval.js';
3
6
 
4
- const EXTENT = new Set(['rectY-x', 'rectX-y', 'rect-x', 'rect-y']);
7
+ const EXTENT = new Set([
8
+ 'rectY-x', 'rectX-y', 'rect-x', 'rect-y', 'ruleY-x', 'ruleX-y'
9
+ ]);
5
10
 
6
- export function bin(field, options = { steps: 25 }) {
11
+ export function hasExtent(mark, channel) {
12
+ return EXTENT.has(`${mark.type}-${channel}`);
13
+ }
14
+
15
+ export function bin(field, options = {}) {
7
16
  const fn = (mark, channel) => {
8
- if (EXTENT.has(`${mark.type}-${channel}`)) {
17
+ if (hasExtent(mark, channel)) {
9
18
  return {
10
19
  [`${channel}1`]: binField(mark, channel, field, options),
11
20
  [`${channel}2`]: binField(mark, channel, field, { ...options, offset: 1 })
@@ -24,56 +33,39 @@ function binField(mark, channel, column, options) {
24
33
  return {
25
34
  column,
26
35
  label: column,
27
- get stats() { return { column, stats: ['min', 'max'] }; },
28
36
  get columns() { return [column]; },
29
37
  get basis() { return column; },
38
+ get stats() { return { column, stats: ['min', 'max'] }; },
30
39
  toString() {
31
- const { apply, sqlApply, sqlInvert } = channelScale(mark, channel);
32
- const { min, max } = mark.channelField(channel);
33
- const b = bins(apply(min), apply(max), options);
34
- const col = sqlApply(column);
35
- const base = b.min === 0 ? col : `(${col} - ${b.min})`;
36
- const alpha = `${(b.max - b.min) / b.steps}::DOUBLE`;
37
- const off = options.offset ? `${options.offset} + ` : '';
38
- const expr = `${b.min} + ${alpha} * (${off}FLOOR(${base} / ${alpha}))`;
39
- return `${sqlInvert(expr)}`;
40
+ const { type, min, max } = mark.channelField(channel);
41
+ const { interval: i, steps, offset = 0 } = options;
42
+ const interval = i ?? (
43
+ type === 'date' || hasTimeScale(mark, channel) ? 'date' : 'number'
44
+ );
45
+
46
+ if (interval === 'number') {
47
+ // perform number binning
48
+ const { apply, sqlApply, sqlInvert } = channelScale(mark, channel);
49
+ const b = bins(apply(min), apply(max), options);
50
+ const col = sqlApply(column);
51
+ const base = b.min === 0 ? col : `(${col} - ${b.min})`;
52
+ const alpha = `${(b.max - b.min) / b.steps}::DOUBLE`;
53
+ const off = offset ? `${offset} + ` : '';
54
+ const expr = `${b.min} + ${alpha} * (${off}FLOOR(${base} / ${alpha}))`;
55
+ return `${sqlInvert(expr)}`;
56
+ } else {
57
+ // perform date/time binning
58
+ const { interval: unit, step = 1 } = interval === 'date'
59
+ ? timeInterval(min, max, steps || 40)
60
+ : options;
61
+ const off = offset ? ` + INTERVAL ${offset * step} ${unit}` : '';
62
+ return `(${dateBin(column, unit, step)}${off})`;
63
+ }
40
64
  }
41
65
  };
42
66
  }
43
67
 
44
- export function bins(min, max, options) {
45
- let { steps = 25, minstep = 0, nice = true } = options;
46
-
47
- if (nice !== false) {
48
- // use span to determine step size
49
- const span = max - min;
50
- const maxb = steps;
51
- const logb = Math.LN10;
52
- const level = Math.ceil(Math.log(maxb) / logb);
53
- let step = Math.max(
54
- minstep,
55
- Math.pow(10, Math.round(Math.log(span) / logb) - level)
56
- );
57
-
58
- // increase step size if too many bins
59
- while (Math.ceil(span / step) > maxb) { step *= 10; }
60
-
61
- // decrease step size if allowed
62
- const div = [5, 2];
63
- let v;
64
- for (let i = 0, n = div.length; i < n; ++i) {
65
- v = step / div[i];
66
- if (v >= minstep && span / v <= maxb) step = v;
67
- }
68
-
69
- v = Math.log(step);
70
- const precision = v >= 0 ? 0 : ~~(-v / logb) + 1;
71
- const eps = Math.pow(10, -precision - 1);
72
- v = Math.floor(min / step + eps) * step;
73
- min = min < v ? v - step : v;
74
- max = Math.ceil(max / step) * step;
75
- steps = Math.round((max - min) / step);
76
- }
77
-
78
- return { min, max, steps };
68
+ function hasTimeScale(mark, channel) {
69
+ const scale = mark.plot.getAttribute(`${channel}Scale`);
70
+ return scale === 'utc' || scale === 'time';
79
71
  }