@uwdata/mosaic-plot 0.9.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.
- package/dist/mosaic-plot.js +2717 -2435
- package/dist/mosaic-plot.min.js +11 -11
- package/package.json +6 -6
- package/src/interactors/Interval1D.js +2 -2
- package/src/interactors/Interval2D.js +2 -2
- package/src/interactors/Nearest.js +5 -2
- package/src/interactors/PanZoom.js +2 -2
- package/src/interactors/Toggle.js +2 -2
- package/src/legend.js +7 -2
- package/src/marks/Density1DMark.js +1 -1
- package/src/marks/ErrorBarMark.js +1 -1
- package/src/marks/Grid2DMark.js +1 -1
- package/src/marks/HexbinMark.js +39 -49
- package/src/marks/Mark.js +1 -2
- package/src/marks/RegressionMark.js +2 -2
- package/src/plot-attributes.js +1 -0
- package/src/plot.js +14 -2
- package/src/transforms/bin-step.js +43 -0
- package/src/transforms/bin.js +37 -47
- package/src/transforms/time-interval.js +53 -0
- package/src/marks/util/to-data-columns.js +0 -71
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uwdata/mosaic-plot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "A Mosaic-powered plotting framework based on Observable Plot.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"data",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"mosaic"
|
|
11
11
|
],
|
|
12
12
|
"license": "BSD-3-Clause",
|
|
13
|
-
"author": "Jeffrey Heer (
|
|
13
|
+
"author": "Jeffrey Heer (https://idl.uw.edu)",
|
|
14
14
|
"type": "module",
|
|
15
15
|
"main": "src/index.js",
|
|
16
16
|
"module": "src/index.js",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"prepublishOnly": "npm run test && npm run lint && npm run build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@observablehq/plot": "^0.6.
|
|
32
|
-
"@uwdata/mosaic-core": "^0.
|
|
33
|
-
"@uwdata/mosaic-sql": "^0.
|
|
31
|
+
"@observablehq/plot": "^0.6.15",
|
|
32
|
+
"@uwdata/mosaic-core": "^0.10.0",
|
|
33
|
+
"@uwdata/mosaic-sql": "^0.10.0",
|
|
34
34
|
"d3": "^7.9.0",
|
|
35
35
|
"isoformat": "^0.2.1"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "94fc4f0d4efc622001f6afd6714d1e9dda745be2"
|
|
38
38
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clauseInterval } from '@uwdata/mosaic-core';
|
|
2
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';
|
|
@@ -52,7 +52,7 @@ export class Interval1D {
|
|
|
52
52
|
|
|
53
53
|
clause(value) {
|
|
54
54
|
const { mark, pixelSize, field, scale } = this;
|
|
55
|
-
return
|
|
55
|
+
return clauseInterval(field, value, {
|
|
56
56
|
source: this,
|
|
57
57
|
clients: this.peers ? mark.plot.markSet : new Set().add(mark),
|
|
58
58
|
scale,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clauseIntervals } from '@uwdata/mosaic-core';
|
|
2
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';
|
|
@@ -55,7 +55,7 @@ export class Interval2D {
|
|
|
55
55
|
|
|
56
56
|
clause(value) {
|
|
57
57
|
const { mark, pixelSize, xfield, yfield, xscale, yscale } = this;
|
|
58
|
-
return
|
|
58
|
+
return clauseIntervals([xfield, yfield], value, {
|
|
59
59
|
source: this,
|
|
60
60
|
clients: this.peers ? mark.plot.markSet : new Set().add(mark),
|
|
61
61
|
scales: [xscale, yscale],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clausePoints, isSelection } from '@uwdata/mosaic-core';
|
|
2
2
|
import { select, pointer } from 'd3';
|
|
3
3
|
import { getField } from './util/get-field.js';
|
|
4
4
|
|
|
@@ -24,7 +24,10 @@ export class Nearest {
|
|
|
24
24
|
|
|
25
25
|
clause(value) {
|
|
26
26
|
const { clients, fields } = this;
|
|
27
|
-
return
|
|
27
|
+
return clausePoints(fields, value ? [value] : value, {
|
|
28
|
+
source: this,
|
|
29
|
+
clients
|
|
30
|
+
});
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
init(svg) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Selection, clauseInterval } from '@uwdata/mosaic-core';
|
|
2
2
|
import { select, zoom, ZoomTransform } from 'd3';
|
|
3
3
|
import { getField } from './util/get-field.js';
|
|
4
4
|
|
|
@@ -48,7 +48,7 @@ export class PanZoom {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
clause(value, field, scale) {
|
|
51
|
-
return
|
|
51
|
+
return clauseInterval(field, value, {
|
|
52
52
|
source: this,
|
|
53
53
|
clients: this.mark.plot.markSet,
|
|
54
54
|
scale
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { clausePoints } from '@uwdata/mosaic-core';
|
|
2
2
|
|
|
3
3
|
export class Toggle {
|
|
4
4
|
/**
|
|
@@ -35,7 +35,7 @@ export class Toggle {
|
|
|
35
35
|
|
|
36
36
|
clause(value) {
|
|
37
37
|
const { fields, mark } = this;
|
|
38
|
-
return
|
|
38
|
+
return clausePoints(fields, value, {
|
|
39
39
|
source: this,
|
|
40
40
|
clients: this.peers ? mark.plot.markSet : new Set().add(mark)
|
|
41
41
|
});
|
package/src/legend.js
CHANGED
|
@@ -10,7 +10,7 @@ export class Legend {
|
|
|
10
10
|
constructor(channel, options) {
|
|
11
11
|
const { as, field, ...rest } = options;
|
|
12
12
|
this.channel = channel;
|
|
13
|
-
this.options =
|
|
13
|
+
this.options = rest;
|
|
14
14
|
this.type = null;
|
|
15
15
|
this.handler = null;
|
|
16
16
|
this.selection = as;
|
|
@@ -51,10 +51,15 @@ export class Legend {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function createLegend(legend, svg) {
|
|
54
|
-
const { channel,
|
|
54
|
+
const { channel, plot, selection } = legend;
|
|
55
55
|
const scale = svg.scale(channel);
|
|
56
56
|
const type = scale.type === 'ordinal' ? SWATCH : RAMP;
|
|
57
57
|
|
|
58
|
+
const options = {
|
|
59
|
+
label: plot.getAttribute(`${channel}Label`) ?? null,
|
|
60
|
+
...legend.options
|
|
61
|
+
};
|
|
62
|
+
|
|
58
63
|
// labels for swatch legends are not yet supported by Plot
|
|
59
64
|
// track here: https://github.com/observablehq/plot/issues/834
|
|
60
65
|
// for consistent layout, adjust sizing when there is no label
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
1
2
|
import { Query, gt, isBetween, sql, sum } from '@uwdata/mosaic-sql';
|
|
2
3
|
import { Transient } from '../symbols.js';
|
|
3
4
|
import { binExpr } from './util/bin-expr.js';
|
|
@@ -6,7 +7,6 @@ import { extentX, extentY, xext, yext } from './util/extent.js';
|
|
|
6
7
|
import { grid1d } from './util/grid.js';
|
|
7
8
|
import { handleParam } from './util/handle-param.js';
|
|
8
9
|
import { Mark, channelOption, markQuery } from './Mark.js';
|
|
9
|
-
import { toDataColumns } from './util/to-data-columns.js';
|
|
10
10
|
|
|
11
11
|
export class Density1DMark extends Mark {
|
|
12
12
|
constructor(type, source, options) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
1
2
|
import { avg, count, stddev } from '@uwdata/mosaic-sql';
|
|
2
3
|
import { erfinv } from './util/stats.js';
|
|
3
4
|
import { Mark, markPlotSpec, markQuery } from './Mark.js';
|
|
4
5
|
import { handleParam } from './util/handle-param.js';
|
|
5
|
-
import { toDataColumns } from './util/to-data-columns.js';
|
|
6
6
|
|
|
7
7
|
export class ErrorBarMark extends Mark {
|
|
8
8
|
constructor(type, source, options) {
|
package/src/marks/Grid2DMark.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { interpolatorBarycentric, interpolateNearest, interpolatorRandomWalk } from '@observablehq/plot';
|
|
2
|
+
import { toDataColumns } from '@uwdata/mosaic-core';
|
|
2
3
|
import { Query, count, isBetween, lt, lte, neq, sql, sum } from '@uwdata/mosaic-sql';
|
|
3
4
|
import { Transient } from '../symbols.js';
|
|
4
5
|
import { binExpr } from './util/bin-expr.js';
|
|
@@ -6,7 +7,6 @@ import { dericheConfig, dericheConv2d } from './util/density.js';
|
|
|
6
7
|
import { extentX, extentY, xyext } from './util/extent.js';
|
|
7
8
|
import { grid2d } from './util/grid.js';
|
|
8
9
|
import { handleParam } from './util/handle-param.js';
|
|
9
|
-
import { toDataColumns } from './util/to-data-columns.js';
|
|
10
10
|
import { Mark } from './Mark.js';
|
|
11
11
|
|
|
12
12
|
export const DENSITY = 'density';
|
package/src/marks/HexbinMark.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
const
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
.
|
|
91
|
-
.from(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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';
|
|
@@ -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));
|
package/src/plot-attributes.js
CHANGED
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
|
|
46
|
-
|
|
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
|
+
}
|
package/src/transforms/bin.js
CHANGED
|
@@ -1,13 +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
7
|
const EXTENT = new Set([
|
|
5
8
|
'rectY-x', 'rectX-y', 'rect-x', 'rect-y', 'ruleY-x', 'ruleX-y'
|
|
6
9
|
]);
|
|
7
10
|
|
|
8
|
-
export function
|
|
11
|
+
export function hasExtent(mark, channel) {
|
|
12
|
+
return EXTENT.has(`${mark.type}-${channel}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function bin(field, options = {}) {
|
|
9
16
|
const fn = (mark, channel) => {
|
|
10
|
-
if (
|
|
17
|
+
if (hasExtent(mark, channel)) {
|
|
11
18
|
return {
|
|
12
19
|
[`${channel}1`]: binField(mark, channel, field, options),
|
|
13
20
|
[`${channel}2`]: binField(mark, channel, field, { ...options, offset: 1 })
|
|
@@ -26,56 +33,39 @@ function binField(mark, channel, column, options) {
|
|
|
26
33
|
return {
|
|
27
34
|
column,
|
|
28
35
|
label: column,
|
|
29
|
-
get stats() { return { column, stats: ['min', 'max'] }; },
|
|
30
36
|
get columns() { return [column]; },
|
|
31
37
|
get basis() { return column; },
|
|
38
|
+
get stats() { return { column, stats: ['min', 'max'] }; },
|
|
32
39
|
toString() {
|
|
33
|
-
const {
|
|
34
|
-
const {
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
42
64
|
}
|
|
43
65
|
};
|
|
44
66
|
}
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (nice !== false) {
|
|
50
|
-
// use span to determine step size
|
|
51
|
-
const span = max - min;
|
|
52
|
-
const maxb = steps;
|
|
53
|
-
const logb = Math.LN10;
|
|
54
|
-
const level = Math.ceil(Math.log(maxb) / logb);
|
|
55
|
-
let step = Math.max(
|
|
56
|
-
minstep,
|
|
57
|
-
Math.pow(10, Math.round(Math.log(span) / logb) - level)
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// increase step size if too many bins
|
|
61
|
-
while (Math.ceil(span / step) > maxb) { step *= 10; }
|
|
62
|
-
|
|
63
|
-
// decrease step size if allowed
|
|
64
|
-
const div = [5, 2];
|
|
65
|
-
let v;
|
|
66
|
-
for (let i = 0, n = div.length; i < n; ++i) {
|
|
67
|
-
v = step / div[i];
|
|
68
|
-
if (v >= minstep && span / v <= maxb) step = v;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
v = Math.log(step);
|
|
72
|
-
const precision = v >= 0 ? 0 : ~~(-v / logb) + 1;
|
|
73
|
-
const eps = Math.pow(10, -precision - 1);
|
|
74
|
-
v = Math.floor(min / step + eps) * step;
|
|
75
|
-
min = min < v ? v - step : v;
|
|
76
|
-
max = Math.ceil(max / step) * step;
|
|
77
|
-
steps = Math.round((max - min) / step);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return { min, max, steps };
|
|
68
|
+
function hasTimeScale(mark, channel) {
|
|
69
|
+
const scale = mark.plot.getAttribute(`${channel}Scale`);
|
|
70
|
+
return scale === 'utc' || scale === 'time';
|
|
81
71
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { bisector } from 'd3';
|
|
2
|
+
import { binStep } from './bin-step.js';
|
|
3
|
+
|
|
4
|
+
const YEAR = 'year';
|
|
5
|
+
const MONTH = 'month';
|
|
6
|
+
const DAY = 'day';
|
|
7
|
+
const HOUR = 'hour';
|
|
8
|
+
const MINUTE = 'minute';
|
|
9
|
+
const SECOND = 'second';
|
|
10
|
+
const MILLISECOND = 'millisecond';
|
|
11
|
+
|
|
12
|
+
const durationSecond = 1000;
|
|
13
|
+
const durationMinute = durationSecond * 60;
|
|
14
|
+
const durationHour = durationMinute * 60;
|
|
15
|
+
const durationDay = durationHour * 24;
|
|
16
|
+
const durationWeek = durationDay * 7;
|
|
17
|
+
const durationMonth = durationDay * 30;
|
|
18
|
+
const durationYear = durationDay * 365;
|
|
19
|
+
|
|
20
|
+
/** @type {[string, number, number][]} */
|
|
21
|
+
const intervals = [
|
|
22
|
+
[SECOND, 1, durationSecond],
|
|
23
|
+
[SECOND, 5, 5 * durationSecond],
|
|
24
|
+
[SECOND, 15, 15 * durationSecond],
|
|
25
|
+
[SECOND, 30, 30 * durationSecond],
|
|
26
|
+
[MINUTE, 1, durationMinute],
|
|
27
|
+
[MINUTE, 5, 5 * durationMinute],
|
|
28
|
+
[MINUTE, 15, 15 * durationMinute],
|
|
29
|
+
[MINUTE, 30, 30 * durationMinute],
|
|
30
|
+
[ HOUR, 1, durationHour ],
|
|
31
|
+
[ HOUR, 3, 3 * durationHour ],
|
|
32
|
+
[ HOUR, 6, 6 * durationHour ],
|
|
33
|
+
[ HOUR, 12, 12 * durationHour ],
|
|
34
|
+
[ DAY, 1, durationDay ],
|
|
35
|
+
[ DAY, 7, durationWeek ],
|
|
36
|
+
[ MONTH, 1, durationMonth ],
|
|
37
|
+
[ MONTH, 3, 3 * durationMonth ],
|
|
38
|
+
[ YEAR, 1, durationYear ]
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export function timeInterval(min, max, steps) {
|
|
42
|
+
const span = max - min;
|
|
43
|
+
const target = span / steps;
|
|
44
|
+
let i = bisector(i => i[2]).right(intervals, target);
|
|
45
|
+
if (i === intervals.length) {
|
|
46
|
+
return { interval: YEAR, step: binStep(span, steps) };
|
|
47
|
+
} else if (i) {
|
|
48
|
+
i = intervals[target / intervals[i - 1][2] < intervals[i][2] / target ? i - 1 : i];
|
|
49
|
+
return { interval: i[0], step: i[1] };
|
|
50
|
+
} else {
|
|
51
|
+
return { interval: MILLISECOND, step: binStep(span, steps, 1) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { convertArrowColumn, isArrowTable } from '@uwdata/mosaic-core';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* @typedef {Array | Int8Array | Uint8Array | Uint8ClampedArray
|
|
5
|
-
* | Int16Array | Uint16Array | Int32Array | Uint32Array
|
|
6
|
-
* | Float32Array | Float64Array
|
|
7
|
-
* } Arrayish - an Array or TypedArray
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* @typedef {
|
|
12
|
-
* | { numRows: number, columns: Record<string,Arrayish> }
|
|
13
|
-
* | { numRows: number, values: Arrayish; }
|
|
14
|
-
* } DataColumns
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Convert input data to a set of column arrays.
|
|
19
|
-
* @param {any} data The input data.
|
|
20
|
-
* @returns {DataColumns} An object with named column arrays.
|
|
21
|
-
*/
|
|
22
|
-
export function toDataColumns(data) {
|
|
23
|
-
return isArrowTable(data)
|
|
24
|
-
? arrowToColumns(data)
|
|
25
|
-
: arrayToColumns(data);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Convert an Arrow table to a set of column arrays.
|
|
30
|
-
* @param {import('apache-arrow').Table} data An Apache Arrow Table.
|
|
31
|
-
* @returns {DataColumns} An object with named column arrays.
|
|
32
|
-
*/
|
|
33
|
-
function arrowToColumns(data) {
|
|
34
|
-
const { numRows, numCols, schema: { fields } } = data;
|
|
35
|
-
const columns = {};
|
|
36
|
-
|
|
37
|
-
for (let col = 0; col < numCols; ++col) {
|
|
38
|
-
const name = fields[col].name;
|
|
39
|
-
if (columns[name]) {
|
|
40
|
-
console.warn(`Redundant column name "${name}". Skipping...`);
|
|
41
|
-
} else {
|
|
42
|
-
columns[name] = convertArrowColumn(data.getChildAt(col));
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return { numRows, columns };
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Convert an array of values to a set of column arrays.
|
|
51
|
-
* If the array values are objects, build out named columns.
|
|
52
|
-
* We use the keys of the first object as the column names.
|
|
53
|
-
* Otherwise, use a special "values" array.
|
|
54
|
-
* @param {object[]} data An array of data objects.
|
|
55
|
-
* @returns {DataColumns} An object with named column arrays.
|
|
56
|
-
*/
|
|
57
|
-
function arrayToColumns(data) {
|
|
58
|
-
const numRows = data.length;
|
|
59
|
-
if (typeof data[0] === 'object') {
|
|
60
|
-
const names = numRows ? Object.keys(data[0]) : [];
|
|
61
|
-
const columns = {};
|
|
62
|
-
if (names.length > 0) {
|
|
63
|
-
names.forEach(name => {
|
|
64
|
-
columns[name] = data.map(d => d[name]);
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
return { numRows, columns };
|
|
68
|
-
} else {
|
|
69
|
-
return { numRows, values: data };
|
|
70
|
-
}
|
|
71
|
-
}
|