@uwdata/mosaic-plot 0.9.0 → 0.11.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 +10556 -19300
- package/dist/mosaic-plot.min.js +19 -28
- package/package.json +7 -7
- package/src/interactors/Highlight.js +1 -1
- package/src/interactors/Interval1D.js +2 -2
- package/src/interactors/Interval2D.js +2 -2
- package/src/interactors/Nearest.js +11 -7
- package/src/interactors/PanZoom.js +2 -2
- package/src/interactors/Toggle.js +2 -2
- package/src/legend.js +7 -2
- package/src/marks/ConnectedMark.js +1 -1
- 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/marks/util/stats.js +1 -1
- package/src/plot-attributes.js +1 -0
- package/src/plot.js +15 -3
- 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.11.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",
|
|
@@ -24,15 +24,15 @@
|
|
|
24
24
|
"prebuild": "rimraf dist && mkdir dist",
|
|
25
25
|
"build": "node ../../esbuild.js mosaic-plot",
|
|
26
26
|
"lint": "eslint src test",
|
|
27
|
-
"test": "
|
|
27
|
+
"test": "vitest run",
|
|
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.16",
|
|
32
|
+
"@uwdata/mosaic-core": "^0.11.0",
|
|
33
|
+
"@uwdata/mosaic-sql": "^0.11.0",
|
|
34
34
|
"d3": "^7.9.0",
|
|
35
35
|
"isoformat": "^0.2.1"
|
|
36
36
|
},
|
|
37
|
-
"gitHead": "
|
|
37
|
+
"gitHead": "861d616f39926a1d2aee83b59dbdd70b0b3caf12"
|
|
38
38
|
}
|
|
@@ -99,6 +99,6 @@ async function predicateFunction(mark, selection) {
|
|
|
99
99
|
const data = await mark.coordinator.query(q);
|
|
100
100
|
const v = data.getChild?.('__');
|
|
101
101
|
return !(data.numRows || data.length) ? (() => false)
|
|
102
|
-
: v ? (i => v.
|
|
102
|
+
: v ? (i => v.at(i))
|
|
103
103
|
: (i => data[i].__);
|
|
104
104
|
}
|
|
@@ -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 { clausePoint, 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,12 @@ export class Nearest {
|
|
|
24
24
|
|
|
25
25
|
clause(value) {
|
|
26
26
|
const { clients, fields } = this;
|
|
27
|
-
|
|
27
|
+
const opt = { source: this, clients };
|
|
28
|
+
// if only one field, use a simpler clause that passes the value
|
|
29
|
+
// this allows a single field selection value to act like a param
|
|
30
|
+
return fields.length > 1
|
|
31
|
+
? clausePoints(fields, value ? [value] : value, opt)
|
|
32
|
+
: clausePoint(fields[0], value?.[0], opt);
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
init(svg) {
|
|
@@ -53,11 +58,10 @@ export class Nearest {
|
|
|
53
58
|
if (i !== this.valueIndex) {
|
|
54
59
|
this.valueIndex = i;
|
|
55
60
|
const v = i < 0 ? undefined : keys.map(k => columns[k][i]);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
+
selection.update(
|
|
62
|
+
// provide value for param, clause for selection
|
|
63
|
+
param ? (!v || v.length > 1 ? v : v[0]) : that.clause(v)
|
|
64
|
+
);
|
|
61
65
|
}
|
|
62
66
|
});
|
|
63
67
|
|
|
@@ -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
|
|
@@ -45,7 +45,7 @@ export class ConnectedMark extends Mark {
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* M4 is an optimization for value-preserving time-series aggregation
|
|
48
|
-
* (
|
|
48
|
+
* (https://www.vldb.org/pvldb/vol7/p797-jugel.pdf). This implementation uses
|
|
49
49
|
* an efficient version with a single scan and the aggregate function
|
|
50
50
|
* argmin and argmax, following https://arxiv.org/pdf/2306.03714.pdf.
|
|
51
51
|
*/
|
|
@@ -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/marks/util/stats.js
CHANGED
|
@@ -172,7 +172,7 @@ export function qt(p, dof) {
|
|
|
172
172
|
export function erfinv(x) {
|
|
173
173
|
// Implementation from "Approximating the erfinv function" by Mike Giles,
|
|
174
174
|
// GPU Computing Gems, volume 2, 2010.
|
|
175
|
-
// Ported from Apache Commons Math,
|
|
175
|
+
// Ported from Apache Commons Math, https://www.apache.org/licenses/LICENSE-2.0
|
|
176
176
|
|
|
177
177
|
// beware that the logarithm argument must be
|
|
178
178
|
// computed as (1.0 - x) * (1.0 + x),
|
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;
|
|
@@ -131,7 +132,7 @@ export class Plot {
|
|
|
131
132
|
params.set(param, [mark]);
|
|
132
133
|
param.addEventListener('value', () => {
|
|
133
134
|
return Promise.allSettled(
|
|
134
|
-
params.get(param).map(mark => mark.
|
|
135
|
+
params.get(param).map(mark => mark.initialize())
|
|
135
136
|
);
|
|
136
137
|
});
|
|
137
138
|
}
|
|
@@ -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 / durationYear, 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
|
+
}
|