@uwdata/mosaic-plot 0.5.0 → 0.6.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,8 +1,22 @@
1
+ import { ascending } from 'd3';
1
2
  import { scale } from '@observablehq/plot';
3
+ import { gridDomainContinuous, gridDomainDiscrete } from './util/grid.js';
2
4
  import { isColor } from './util/is-color.js';
3
- import { createCanvas, raster, opacityMap, palette } from './util/raster.js';
4
- import { Grid2DMark } from './Grid2DMark.js';
5
-
5
+ import { alphaScheme, alphaConstant, colorConstant, colorCategory, colorScheme, createCanvas } from './util/raster.js';
6
+ import { DENSITY, Grid2DMark } from './Grid2DMark.js';
7
+ import { Fixed, Transient } from '../symbols.js';
8
+
9
+ /**
10
+ * Raster image mark. Data is binned to a grid based on the x and y options.
11
+ * The grid cells are then colored to form an image.
12
+ * The raster grid size defaults to the pixel width/height of the
13
+ * plot. The pixelSize option (default 1) changes the grid cell to pixel
14
+ * ratio. For example, a pixelSize of 0.5 will create a larger raster
15
+ * for higher resolution images on retina displays. The width and height
16
+ * options set the grid dimensions directly, overriding other options.
17
+ * The raster grid can optionally be smoothed (blurred) by setting
18
+ * the bandwidth option.
19
+ */
6
20
  export class RasterMark extends Grid2DMark {
7
21
  constructor(source, options) {
8
22
  super('image', source, options);
@@ -19,23 +33,19 @@ export class RasterMark extends Grid2DMark {
19
33
  }
20
34
 
21
35
  rasterize() {
22
- const { bins, kde, groupby } = this;
36
+ const { bins, kde } = this;
23
37
  const [ w, h ] = bins;
24
38
 
25
39
  // raster data
26
40
  const { canvas, ctx, img } = imageData(this, w, h);
27
41
 
28
- // scale function to map densities to [0, 1]
29
- const s = imageScale(this);
30
-
31
- // gather color domain as needed
32
- const idx = groupby.indexOf(this.channelField('fill')?.as);
33
- const domain = idx < 0 ? [] : kde.map(({ key }) => key[idx]);
42
+ // color + opacity encodings
43
+ const { alpha, alphaProp, color, colorProp } = rasterEncoding(this);
34
44
 
35
- // generate raster images
36
- this.data = kde.map(grid => {
37
- const palette = imagePalette(this, domain, grid.key?.[idx]);
38
- raster(grid, img.data, w, h, s, palette);
45
+ // generate rasters
46
+ this.data = kde.map(cell => {
47
+ color?.(img.data, w, h, cell[colorProp]);
48
+ alpha?.(img.data, w, h, cell[alphaProp]);
39
49
  ctx.putImageData(img, 0, 0);
40
50
  return { src: canvas.toDataURL() };
41
51
  });
@@ -57,65 +67,179 @@ export class RasterMark extends Grid2DMark {
57
67
  }
58
68
  }
59
69
 
60
- function imageData(mark, w, h) {
61
- if (!mark.image || mark.image.w !== w || mark.image.h !== h) {
62
- const canvas = createCanvas(w, h);
63
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
64
- const img = ctx.getImageData(0, 0, w, h);
65
- mark.image = { canvas, ctx, img, w, h };
70
+ /**
71
+ * Density heatmap image.
72
+ * This is just a raster mark with default options for
73
+ * accurate binning and smoothing for density estimation.
74
+ */
75
+ export class HeatmapMark extends RasterMark {
76
+ constructor(source, options) {
77
+ super(source, {
78
+ bandwidth: 20,
79
+ interpolate: 'linear',
80
+ pixelSize: 2,
81
+ ...options
82
+ });
66
83
  }
67
- return mark.image;
68
84
  }
69
85
 
70
- function imageScale(mark) {
71
- const { densityMap, kde, plot } = mark;
72
- let domain = densityMap.fill && plot.getAttribute('colorDomain');
73
-
74
- // compute kde grid extents if no explicit domain
75
- if (!domain) {
76
- let lo = 0, hi = 0;
77
- kde.forEach(grid => {
78
- for (const v of grid) {
79
- if (v < lo) lo = v;
80
- if (v > hi) hi = v;
81
- }
82
- });
83
- domain = (lo === 0 && hi === 0) ? [0, 1] : [lo, hi];
86
+ /**
87
+ * Utility method to generate color and alpha encoding helpers.
88
+ * The returned methods can write directly to a pixel raster.
89
+ */
90
+ export function rasterEncoding(mark) {
91
+ const { aggr, densityMap, groupby, plot } = mark;
92
+ const hasDensity = aggr.includes(DENSITY);
93
+ const hasFillOpacity = aggr.includes('fillOpacity');
94
+ const fillEntry = mark.channel('fill');
95
+ const opacEntry = mark.channel('fillOpacity');
96
+
97
+ // check constraints, raise errors
98
+ if (aggr.length > 2 || (hasDensity && hasFillOpacity)) {
99
+ throw new Error('Invalid raster encodings. Try dropping an aggregate?');
100
+ }
101
+ if (groupby.includes(opacEntry?.as)) {
102
+ throw new Error('Raster fillOpacity must be an aggregate or constant.');
103
+ }
104
+
105
+ // determine fill encoding channel use
106
+ const fill = densityMap.fill || aggr.includes('fill') ? 'grid'
107
+ : groupby.includes(fillEntry?.as) ? 'group' // groupby
108
+ : isColor(fillEntry?.value) ? fillEntry.value // constant
109
+ : hasDensity && plot.getAttribute('colorScheme') ? 'grid'
110
+ : undefined;
111
+
112
+ // determine fill opacity encoding channel use
113
+ const opac = densityMap.fillOpacity || aggr.includes('fillOpacity') ? 'grid'
114
+ : typeof opacEntry?.value === 'number' ? opacEntry.value // constant
115
+ : hasDensity && fill !== 'grid' ? 'grid'
116
+ : undefined;
117
+
118
+ if (fill !== 'grid' && opac !== 'grid') {
119
+ // TODO: use a threshold-based encoding?
120
+ throw new Error('Raster mark missing density values.');
84
121
  }
85
122
 
86
- const type = plot.getAttribute('colorScale');
87
- return scale({ x: { type, domain, range: [0, 1] } }).apply;
123
+ const colorProp = fillEntry?.as ?? (fill === 'grid' ? DENSITY : null);
124
+ const alphaProp = opacEntry?.as ?? (opac === 'grid' ? DENSITY : null);
125
+ const color = fill !== 'grid' && fill !== 'group'
126
+ ? colorConstant(fill)
127
+ : colorScale(mark, colorProp);
128
+ const alpha = opac !== 'grid'
129
+ ? alphaConstant(opac)
130
+ : alphaScale(mark, alphaProp);
131
+
132
+ return { alphaProp, colorProp, alpha, color };
88
133
  }
89
134
 
90
- function imagePalette(mark, domain, value, steps = 1024) {
91
- const { densityMap, plot } = mark;
92
- const scheme = plot.getAttribute('colorScheme');
93
-
94
- // initialize color to constant fill, if specified
95
- const fill = mark.channel('fill');
96
- let color = isColor(fill?.value) ? fill.value : undefined;
97
-
98
- if (densityMap.fill || (scheme && !color)) {
99
- if (scheme) {
100
- try {
101
- return palette(
102
- steps,
103
- scale({color: { scheme, domain: [0, 1] }}).interpolate
104
- );
105
- } catch (err) {
106
- console.warn(err);
107
- }
135
+ function alphaScale(mark, prop) {
136
+ const { plot, kde: grids } = mark;
137
+
138
+ // determine scale domain
139
+ const domainAttr = plot.getAttribute('opacityDomain');
140
+ const domainFixed = domainAttr === Fixed;
141
+ const domainTransient = domainAttr?.[Transient];
142
+ const domain = (!domainFixed && !domainTransient && domainAttr)
143
+ || gridDomainContinuous(grids, prop);
144
+ if (domainFixed || domainTransient) {
145
+ if (domainTransient) domain[Transient] = true;
146
+ plot.setAttribute('colorDomain', domain);
147
+ }
148
+
149
+ // generate opacity scale
150
+ const s = scale({
151
+ opacity: {
152
+ type: plot.getAttribute('opacityScale'),
153
+ domain,
154
+ clamp: plot.getAttribute('opacityClamp'),
155
+ nice: plot.getAttribute('opacityNice'),
156
+ reverse: plot.getAttribute('opacityReverse'),
157
+ zero: plot.getAttribute('opacityZero'),
158
+ base: plot.getAttribute('opacityBase'),
159
+ exponent: plot.getAttribute('opacityExponent'),
160
+ constant: plot.getAttribute('opacityConstant')
108
161
  }
109
- } else if (domain.length) {
110
- // fill is based on data values
111
- const range = plot.getAttribute('colorRange');
112
- const spec = {
162
+ });
163
+ return alphaScheme(s);
164
+ }
165
+
166
+ function colorScale(mark, prop) {
167
+ const { plot, kde: grids } = mark;
168
+ const flat = !grids[0][prop]?.map; // not array-like
169
+ const discrete = flat || Array.isArray(grids[0][prop]);
170
+
171
+ // determine scale domain
172
+ const domainAttr = plot.getAttribute('colorDomain');
173
+ const domainFixed = domainAttr === Fixed;
174
+ const domainTransient = domainAttr?.[Transient];
175
+ const domain = (!domainFixed && !domainTransient && domainAttr) || (
176
+ flat ? grids.map(cell => cell[prop]).sort(ascending)
177
+ : discrete ? gridDomainDiscrete(grids, prop)
178
+ : gridDomainContinuous(grids, prop)
179
+ );
180
+ if (domainFixed || domainTransient) {
181
+ if (domainTransient) domain[Transient] = true;
182
+ plot.setAttribute('colorDomain', domain);
183
+ }
184
+
185
+ // generate color scale
186
+ const s = scale({
187
+ color: {
188
+ type: plot.getAttribute('colorScale'),
113
189
  domain,
114
- range,
115
- scheme: scheme || (range ? undefined : 'tableau10')
116
- };
117
- color = scale({ color: spec }).apply(value);
190
+ range: plot.getAttribute('colorRange'),
191
+ clamp: plot.getAttribute('colorClamp'),
192
+ n: plot.getAttribute('colorN'),
193
+ nice: plot.getAttribute('colorNice'),
194
+ reverse: plot.getAttribute('colorReverse'),
195
+ scheme: plot.getAttribute('colorScheme'),
196
+ interpolate: plot.getAttribute('colorInterpolate'),
197
+ pivot: plot.getAttribute('colorPivot'),
198
+ symmetric: plot.getAttribute('colorSymmetric'),
199
+ zero: plot.getAttribute('colorZero'),
200
+ base: plot.getAttribute('colorBase'),
201
+ exponent: plot.getAttribute('colorExponent'),
202
+ constant: plot.getAttribute('colorConstant')
203
+ }
204
+ });
205
+
206
+ // TODO: add support for threshold scales?
207
+ if (discrete) {
208
+ return colorCategory(s);
209
+ } else {
210
+ // Plot scales do not expose intermediate transformation of
211
+ // values to [0, 1] fractions. So we hobble together our own.
212
+ const frac = scale({
213
+ x: {
214
+ type: inferScaleType(s.type),
215
+ domain: s.domain,
216
+ reverse: s.reverse,
217
+ range: [0, 1],
218
+ clamp: s.clamp,
219
+ base: s.base,
220
+ exponent: s.exponent,
221
+ constant: s.constant
222
+ }
223
+ });
224
+ return colorScheme(1024, s, frac.apply);
118
225
  }
226
+ }
119
227
 
120
- return palette(steps, opacityMap(color));
228
+ function inferScaleType(type) {
229
+ if (type.endsWith('symlog')) return 'symlog';
230
+ if (type.endsWith('log')) return 'log';
231
+ if (type.endsWith('pow')) return 'pow';
232
+ if (type.endsWith('sqrt')) return 'sqrt';
233
+ if (type === 'diverging') return 'linear';
234
+ return type;
235
+ }
236
+
237
+ export function imageData(mark, w, h) {
238
+ if (!mark.image || mark.image.w !== w || mark.image.h !== h) {
239
+ const canvas = createCanvas(w, h);
240
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
241
+ const img = ctx.getImageData(0, 0, w, h);
242
+ mark.image = { canvas, ctx, img, w, h };
243
+ }
244
+ return mark.image;
121
245
  }
@@ -1,11 +1,10 @@
1
1
  import { coordinator } from '@uwdata/mosaic-core';
2
2
  import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
3
- import { scale } from '@observablehq/plot';
3
+ import { binExpr } from './util/bin-expr.js';
4
4
  import { extentX, extentY } from './util/extent.js';
5
- import { isColor } from './util/is-color.js';
6
- import { createCanvas, raster, opacityMap, palette } from './util/raster.js';
5
+ import { createCanvas } from './util/raster.js';
7
6
  import { Grid2DMark } from './Grid2DMark.js';
8
- import { binField } from './util/bin-field.js';
7
+ import { rasterEncoding } from './RasterMark.js';
9
8
 
10
9
  export class RasterTileMark extends Grid2DMark {
11
10
  constructor(source, options) {
@@ -35,46 +34,66 @@ export class RasterTileMark extends Grid2DMark {
35
34
  }
36
35
 
37
36
  tileQuery(extent) {
38
- const { plot, binType, binPad, channels, densityMap, source } = this;
37
+ const { binType, binPad, channels, densityMap, source } = this;
39
38
  const [[x0, x1], [y0, y1]] = extent;
40
39
  const [nx, ny] = this.bins;
41
- const bx = binField(this, 'x');
42
- const by = binField(this, 'y');
43
- const rx = !!plot.getAttribute('xReverse');
44
- const ry = !!plot.getAttribute('yReverse');
45
- const x = bin1d(bx, x0, x1, nx, rx, binPad);
46
- const y = bin1d(by, y0, y1, ny, ry, binPad);
40
+ const [x, bx] = binExpr(this, 'x', nx, [x0, x1], binPad);
41
+ const [y, by] = binExpr(this, 'y', ny, [y0, y1], binPad);
47
42
 
48
43
  // with padded bins, include the entire domain extent
49
44
  // if the bins are flush, exclude the extent max
50
45
  const bounds = binPad
51
- ? [isBetween(bx, [x0, x1]), isBetween(by, [y0, y1])]
52
- : [lte(x0, bx), lt(bx, x1), lte(y0, by), lt(by, y1)];
46
+ ? [isBetween(bx, [+x0, +x1]), isBetween(by, [+y0, +y1])]
47
+ : [lte(+x0, bx), lt(bx, +x1), lte(+y0, by), lt(by, +y1)];
53
48
 
54
49
  const q = Query
55
50
  .from(source.table)
56
51
  .where(bounds);
57
52
 
58
53
  const groupby = this.groupby = [];
59
- let agg = count();
54
+ const aggrMap = {};
60
55
  for (const c of channels) {
61
56
  if (Object.hasOwn(c, 'field')) {
62
- const { channel, field } = c;
57
+ const { as, channel, field } = c;
63
58
  if (field.aggregate) {
64
- agg = field;
59
+ // include custom aggregate
60
+ aggrMap[channel] = field;
65
61
  densityMap[channel] = true;
66
62
  } else if (channel === 'weight') {
67
- agg = sum(field);
63
+ // compute weighted density
64
+ aggrMap.density = sum(field);
68
65
  } else if (channel !== 'x' && channel !== 'y') {
69
- q.select({ [channel]: field });
70
- groupby.push(channel);
66
+ // add groupby field
67
+ q.select({ [as]: field });
68
+ groupby.push(as);
71
69
  }
72
70
  }
73
71
  }
72
+ const aggr = this.aggr = Object.keys(aggrMap);
74
73
 
75
- return binType === 'linear'
76
- ? binLinear2d(q, x, y, agg, nx, groupby)
77
- : bin2d(q, x, y, agg, nx, groupby);
74
+ // check for incompatible encodings
75
+ if (aggrMap.density && aggr.length > 1) {
76
+ throw new Error('Weight option can not be used with custom aggregates.');
77
+ }
78
+
79
+ // if no aggregates, default to count density
80
+ if (!aggr.length) {
81
+ aggr.push('density');
82
+ aggrMap.density = count();
83
+ }
84
+
85
+ // generate grid binning query
86
+ if (binType === 'linear') {
87
+ if (aggr.length > 1) {
88
+ throw new Error('Linear binning not applicable to multiple aggregates.');
89
+ }
90
+ if (!aggrMap.density) {
91
+ throw new Error('Linear binning not applicable to custom aggregates.');
92
+ }
93
+ return binLinear2d(q, x, y, aggrMap.density, nx, groupby);
94
+ } else {
95
+ return bin2d(q, x, y, aggrMap, nx, groupby);
96
+ }
78
97
  }
79
98
 
80
99
  async requestTiles() {
@@ -136,7 +155,7 @@ export class RasterTileMark extends Grid2DMark {
136
155
 
137
156
  // wait for tile queries to complete, then update
138
157
  const tiles = await Promise.all(queries);
139
- this.grids = [{ grid: processTiles(m, n, xx, yy, coords, tiles) }];
158
+ this.grids = [{ density: processTiles(m, n, xx, yy, coords, tiles) }];
140
159
  this.convolve().update();
141
160
  }
142
161
 
@@ -145,23 +164,19 @@ export class RasterTileMark extends Grid2DMark {
145
164
  }
146
165
 
147
166
  rasterize() {
148
- const { bins, kde, groupby } = this;
167
+ const { bins, kde } = this;
149
168
  const [ w, h ] = bins;
150
169
 
151
170
  // raster data
152
171
  const { canvas, ctx, img } = imageData(this, w, h);
153
172
 
154
- // scale function to map densities to [0, 1]
155
- const s = imageScale(this);
156
-
157
- // gather color domain as needed
158
- const idx = groupby.indexOf(this.channelField('fill')?.as);
159
- const domain = idx < 0 ? [] : kde.map(({ key }) => key[idx]);
173
+ // color + opacity encodings
174
+ const { alpha, alphaProp, color, colorProp } = rasterEncoding(this);
160
175
 
161
- // generate raster images
162
- this.data = kde.map(grid => {
163
- const palette = imagePalette(this, domain, grid.key?.[idx]);
164
- raster(grid, img.data, w, h, s, palette);
176
+ // generate rasters
177
+ this.data = kde.map(cell => {
178
+ color?.(img.data, w, h, cell[colorProp]);
179
+ alpha?.(img.data, w, h, cell[alphaProp]);
165
180
  ctx.putImageData(img, 0, 0);
166
181
  return { src: canvas.toDataURL() };
167
182
  });
@@ -199,7 +214,7 @@ function copy(m, n, grid, values, tx, ty) {
199
214
  const num = values.numRows;
200
215
  if (num === 0) return;
201
216
  const index = values.getChild('index').toArray();
202
- const value = values.getChild('value').toArray();
217
+ const value = values.getChild('density').toArray();
203
218
  for (let row = 0; row < num; ++row) {
204
219
  const idx = index[row];
205
220
  const i = tx + (idx % m);
@@ -220,72 +235,11 @@ function imageData(mark, w, h) {
220
235
  return mark.image;
221
236
  }
222
237
 
223
- function imageScale(mark) {
224
- const { densityMap, kde, plot } = mark;
225
- let domain = densityMap.fill && plot.getAttribute('colorDomain');
226
-
227
- // compute kde grid extents if no explicit domain
228
- if (!domain) {
229
- let lo = 0, hi = 0;
230
- kde.forEach(grid => {
231
- for (const v of grid) {
232
- if (v < lo) lo = v;
233
- if (v > hi) hi = v;
234
- }
235
- });
236
- domain = (lo === 0 && hi === 0) ? [0, 1] : [lo, hi];
237
- }
238
-
239
- const type = plot.getAttribute('colorScale');
240
- return scale({ x: { type, domain, range: [0, 1] } }).apply;
241
- }
242
-
243
- function imagePalette(mark, domain, value, steps = 1024) {
244
- const { densityMap, plot } = mark;
245
- const scheme = plot.getAttribute('colorScheme');
246
-
247
- // initialize color to constant fill, if specified
248
- const fill = mark.channel('fill');
249
- let color = isColor(fill?.value) ? fill.value : undefined;
250
-
251
- if (densityMap.fill || (scheme && !color)) {
252
- if (scheme) {
253
- try {
254
- return palette(
255
- steps,
256
- scale({color: { scheme, domain: [0, 1] }}).interpolate
257
- );
258
- } catch (err) {
259
- console.warn(err);
260
- }
261
- }
262
- } else if (domain.length) {
263
- // fill is based on data values
264
- const range = plot.getAttribute('colorRange');
265
- const spec = {
266
- domain,
267
- range,
268
- scheme: scheme || (range ? undefined : 'tableau10')
269
- };
270
- color = scale({ color: spec }).apply(value);
271
- }
272
-
273
- return palette(steps, opacityMap(color));
274
- }
275
-
276
- function bin1d(x, x0, x1, n, reverse, pad) {
277
- const d = (n - pad) / (x1 - x0);
278
- const f = d !== 1 ? ` * ${d}::DOUBLE` : '';
279
- return reverse
280
- ? sql`(${x1} - ${x}::DOUBLE)${f}`
281
- : sql`(${x}::DOUBLE - ${x0})${f}`;
282
- }
283
-
284
- function bin2d(q, xp, yp, value, xn, groupby) {
238
+ function bin2d(q, xp, yp, aggs, xn, groupby) {
285
239
  return q
286
240
  .select({
287
241
  index: sql`FLOOR(${xp})::INTEGER + FLOOR(${yp})::INTEGER * ${xn}`,
288
- value
242
+ ...aggs
289
243
  })
290
244
  .groupby('index', groupby);
291
245
  }
@@ -320,9 +274,9 @@ function binLinear2d(q, xp, yp, value, xn, groupby) {
320
274
 
321
275
  return Query
322
276
  .from(Query.unionAll(a, b, c, d))
323
- .select({ index: 'i', value: sum('w') }, groupby)
277
+ .select({ index: 'i', density: sum('w') }, groupby)
324
278
  .groupby('index', groupby)
325
- .having(neq('value', 0));
279
+ .having(neq('density', 0));
326
280
  }
327
281
 
328
282
  function tileFloor(value) {
@@ -0,0 +1,55 @@
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
+ }
@@ -0,0 +1,30 @@
1
+ import { sql } from '@uwdata/mosaic-sql';
2
+ import { channelScale } from './channel-scale.js';
3
+
4
+ /**
5
+ * Generates a SQL expression for 1D pixel-level binning.
6
+ * Adjusts for scale transformations (log, sqrt, ...).
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.
10
+ */
11
+ export function binExpr(mark, channel, n, extent, pad = 1, expr) {
12
+ // get base expression, the channel field unless otherwise given
13
+ const { field } = mark.channelField(channel);
14
+ expr = expr ?? field;
15
+
16
+ // extract scale information
17
+ const { type, apply, sqlApply } = channelScale(mark, channel);
18
+ const reverse = !!mark.plot.getAttribute(`${channel}Reverse`);
19
+
20
+ // return expressions for (unrounded) bin index and field
21
+ const [lo, hi] = extent.map(v => apply(v));
22
+ const v = sqlApply(expr);
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];
30
+ }
@@ -0,0 +1,27 @@
1
+ import { scaleTransform } from '@uwdata/mosaic-sql';
2
+
3
+ export function channelScale(mark, channel) {
4
+ const { plot } = mark;
5
+
6
+ let scaleType = plot.getAttribute(`${channel}Scale`);
7
+ if (!scaleType) {
8
+ const { field } = mark.channelField(channel, `${channel}1`, `${channel}2`);
9
+ const { type } = mark.stats[field.column];
10
+ scaleType = type === 'date' ? 'time' : 'linear';
11
+ }
12
+
13
+ const options = { type: scaleType };
14
+ switch (scaleType) {
15
+ case 'log':
16
+ options.base = plot.getAttribute(`${channel}Base`) ?? 10;
17
+ break;
18
+ case 'pow':
19
+ options.exponent = plot.getAttribute(`${channel}Exponent`) ?? 1;
20
+ break;
21
+ case 'symlog':
22
+ options.constant = plot.getAttribute(`${channel}Constant`) ?? 1;
23
+ break;
24
+ }
25
+
26
+ return scaleTransform(options);
27
+ }