├── .gitignore
├── CHANGES.md
├── README.md
├── package.json
├── public
├── index.html
└── manifest.json
├── src
├── App.css
├── App.js
├── App.test.js
├── Calendar.js
├── Demo.css
├── Demo.js
├── Tree.js
├── datagen.js
├── index.css
├── index.js
├── logo.svg
└── scaleTimeNano.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/CHANGES.md:
--------------------------------------------------------------------------------
1 | ## v2018.04.30
2 |
3 | [demo permalink][demo-2018-04-30]
4 |
5 | * Fix nanosecond time scale ticks
6 |
7 | [demo-2018-04-30]: http://btrdb-viz-2018-04-30.surge.sh
8 |
9 | ## v2018.03.19
10 |
11 | [demo permalink][demo-2018-03-19]
12 |
13 | * Replace line plots with bounding box plots, to clarify mid-res cell relationships (See [#12](https://github.com/PingThingsIO/btrdb-explained/pull/12))
14 |
15 | [demo-2018-03-19]: http://btrdb-viz-2018-03-19.surge.sh
16 |
17 | ## v2018.02.08
18 |
19 | [screenshot][screenshot-2018-02-08] | [demo permalink][demo-2018-02-08]
20 |
21 | * Populate tree with Simplex Noise and plot (See [#7](https://github.com/PingThingsIO/btrdb-explained/pull/7))
22 |
23 | [screenshot-2018-02-08]: https://user-images.githubusercontent.com/116838/35985837-2b30fb54-0cbd-11e8-86f7-705ced580456.gif
24 | [demo-2018-02-08]: http://btrdb-viz-2018-02-08.surge.sh
25 |
26 | ## v2018.01.28
27 |
28 | [screenshot][screenshot-2018-01-28] | [demo permalink][demo-2018-01-28]
29 |
30 | * Show mid-resolution cells (See [#5](https://github.com/PingThingsIO/btrdb-explained/pull/5))
31 |
32 | [screenshot-2018-01-28]: https://user-images.githubusercontent.com/116838/35482932-55640df4-0401-11e8-932a-da14fa03b7ae.gif
33 | [demo-2018-01-28]: http://btrdb-viz-2018-01-28.surge.sh
34 |
35 | ## v2018.01.22
36 |
37 | [screenshot][screenshot-2018-01-22] | [demo permalink][demo-2018-01-22]
38 |
39 | * Add mouse controls for exploring the tree (See [#4](https://github.com/PingThingsIO/btrdb-explained/pull/4))
40 |
41 | [screenshot-2018-01-22]: https://user-images.githubusercontent.com/116838/35245661-a2e4ad26-ff89-11e7-83a2-4db239a4ed4a.gif
42 | [demo-2018-01-22]: http://btrdb-viz-2018-01-22.surge.sh
43 |
44 | ## v2018.01.11
45 |
46 | [screenshot][screenshot-2018-01-11] | [demo permalink][demo-2018-01-11]
47 |
48 | * Add calendar metaphor for a zoomable 2D representation (See [#3](https://github.com/PingThingsIO/btrdb-explained/pull/3))
49 |
50 | [screenshot-2018-01-11]: https://user-images.githubusercontent.com/116838/34836478-9ed6755e-f6bd-11e7-8895-353dfdfbc2cc.gif
51 | [demo-2018-01-11]: http://btrdb-viz-2018-01-11.surge.sh
52 |
53 | ## v2018.01.08
54 |
55 | [screenshot][screenshot-2018-01-08] | [demo permalink][demo-2018-01-08]
56 |
57 | * Add date and time annotations (See [#2](https://github.com/PingThingsIO/btrdb-explained/pull/2))
58 |
59 | [screenshot-2018-01-08]: https://user-images.githubusercontent.com/116838/34710929-d4f974e2-f4e2-11e7-8ccf-87fda093b2a5.gif
60 | [demo-2018-01-08]: http://btrdb-viz-2018-01-08.surge.sh/
61 |
62 | ## v2018.01.07
63 |
64 | [screenshot][screenshot-2018-01-07] | [demo permalink][demo-2018-01-07]
65 |
66 | * Mouse up and down to show zooming into random nodes of the tree (See [#1](https://github.com/PingThingsIO/btrdb-explained/pull/1))
67 |
68 | [screenshot-2018-01-07]: https://user-images.githubusercontent.com/116838/34665780-2b68bb38-f427-11e7-96c1-95c80ed3f39c.gif
69 | [demo-2018-01-07]: http://btrdb-viz-2018-01-07.surge.sh/
70 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | _The Berkeley Tree DataBase (BTrDB) is pronounced "**Better DB**"._
2 |
3 | # BTrDB
4 |
5 | __A next-gen timeseries database for high-precision, high-sample-rate telemetry.__
6 |
7 | __Problem:__ Existing timeseries databases are poorly equipped for a new
8 | generation of ultra-fast sensor telemetry. Specifically, millions of
9 | high-precision power meters are to be deployed throughout the power grid to help
10 | analyze and prevent blackouts. Thus, new software must be built to facilitate
11 | the storage and analysis of its data.
12 |
13 | __Baseline:__ We need 1.4M inserts/s and 5x that in reads if we are to support
14 | 1000 [micro-synchrophasors] per server node. No timeseries database can do
15 | this.
16 |
17 | [micro-synchrophasors]:https://arxiv.org/abs/1605.02813
18 |
19 | ## Summary
20 |
21 | __Goals:__ Develop a multi-resolution storage and query engine for many 100+ Hz
22 | streams at nanosecond precision—and operate at the full line rate of
23 | underlying network or storage infrastructure for affordable cluster sizes (less
24 | than six).
25 |
26 | Developed at Berkeley, BTrDB offers new ways to support the aforementioned high
27 | throughput demands and allows efficient querying over large ranges.
28 |
29 | **Fast writes/reads**
30 |
31 | Measured on a 4-node cluster (large EC2 nodes):
32 |
33 | - 53 million inserted values per second
34 | - 119 million queried values per second
35 |
36 | **Fast analysis**
37 |
38 | In under _200ms_, it can query a year of data at nanosecond-precision (2.1
39 | trillion points) at any desired window—returning statistical summary points at any
40 | desired resolution (containing a min/max/mean per point).
41 |
42 | 
43 |
44 | **High compression**
45 |
46 | Data is compressed by 2.93x—a significant improvement for high-precision
47 | nanosecond streams. To achieve this, a modified version of _run-length encoding_
48 | was created to encode the _jitter_ of delta values rather than the delta values
49 | themselves. Incidentally, this outperforms the popular audio codec [FLAC]
50 | which was the original inspiration for this technique.
51 |
52 | [FLAC]:https://xiph.org/flac/
53 |
54 | **Efficient Versioning**
55 |
56 | Data is version-annotated to allow queries of data as it existed at a certain
57 | time. This allows reproducible query results that might otherwise change due
58 | to newer realtime data coming in. Structural sharing of data between versions
59 | is done to make this process as efficient as possible.
60 |
61 | ## The Tree Structure
62 |
63 | BTrDB stores its data in a time-partitioned tree.
64 |
65 | All nodes represent a given time slot. A node can describe all points within
66 | its time slot at a resolution corresponding to its depth in the tree.
67 |
68 | The root node covers ~146 years. With a branching factor of 64, bottom nodes at
69 | ten levels down cover 4ns each.
70 |
71 | | level | node width |
72 | |:------|:---------------------------------|
73 | | 1 | 262 ns (~146 years) |
74 | | 2 | 256 ns (~2.28 years) |
75 | | 3 | 250 ns (~13.03 days) |
76 | | 4 | 244 ns (~4.88 hours) |
77 | | 5 | 238 ns (~4.58 min) |
78 | | 6 | 232 ns (~4.29 s) |
79 | | 7 | 226 ns (~67.11 ms) |
80 | | 8 | 220 ns (~1.05 ms) |
81 | | 9 | 214 ns (~16.38 µs) |
82 | | 10 | 28 ns (256 ns) |
83 | | 11 | 22 ns (4 ns) |
84 |
85 | A node starts as a __vector node__, storing raw points in a vector of size 1024.
86 | This is considered a leaf node, since it does not point to any child nodes.
87 |
88 | ```
89 | ┌─────────────────────────────────────────────────────────────────┐
90 | │ │
91 | │ VECTOR NODE │
92 | │ (holds 1024 raw points) │
93 | │ │
94 | ├─────────────────────────────────────────────────────────────────┤
95 | │ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . │ <- raw points
96 | └─────────────────────────────────────────────────────────────────┘
97 | ```
98 |
99 | Once this vector is full and more points need to be inserted into its time slot,
100 | the node is converted to a __core node__ by time-partitioning itself into 64
101 | "statistical" points.
102 |
103 | ```
104 | ┌─────────────────────────────────────────────────────────────────┐
105 | │ │
106 | │ CORE NODE │
107 | │ (holds 64 statistical points) │
108 | │ │
109 | ├─────────────────────────────────────────────────────────────────┤
110 | │ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ │ <- stat points
111 | └─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┼─┘
112 | ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ <- child node pointers
113 | ```
114 |
115 | A __statistical point__ represents a 1/64 slice of its parent's time slot. It
116 | holds the min/max/mean/count of all points inside its time slot, and points to a
117 | new node holding extra details. When a vector node is first converted to a core
118 | node, the raw points are pushed into new vector nodes pointed to by the new
119 | statistical points.
120 |
121 | | level | node width | stat point width | total nodes | total stat points |
122 | |-------|----------------------------------|----------------------------------|----------------------|-----------------------|
123 | | 1 | 262 ns (~146 years) | 256 ns (~2.28 years) | 20 nodes | 26 points |
124 | | 2 | 256 ns (~2.28 years) | 250 ns (~13.03 days) | 26 nodes | 212 points |
125 | | 3 | 250 ns (~13.03 days) | 244 ns (~4.88 hours) | 212 nodes | 218 points |
126 | | 4 | 244 ns (~4.88 hours) | 238 ns (~4.58 min) | 218 nodes | 224 points |
127 | | 5 | 238 ns (~4.58 min) | 232 ns (~4.29 s) | 224 nodes | 230 points |
128 | | 6 | 232 ns (~4.29 s) | 226 ns (~67.11 ms) | 230 nodes | 236 points |
129 | | 7 | 226 ns (~67.11 ms) | 220 ns (~1.05 ms) | 236 nodes | 242 points |
130 | | 8 | 220 ns (~1.05 ms) | 214 ns (~16.38 µs) | 242 nodes | 248 points |
131 | | 9 | 214 ns (~16.38 µs) | 28 ns (256 ns) | 248 nodes | 254 points |
132 | | 10 | 28 ns (256 ns) | 22 ns (4 ns) | 254 nodes | 260 points |
133 | | 11 | 22 ns (4 ns) | | 260 nodes | |
134 |
135 | The sampling rate of the data at different moments will determine how deep the
136 | tree will be during those slices of time. Regardless of the depth of the actual
137 | data, the time spent querying at some higher level (lower resolution) will
138 | remain fixed (quick) due to summaries provided by parent nodes.
139 |
140 | ...
141 |
142 | ## Appendix
143 |
144 | This page is written based on the following sources:
145 |
146 | - [Homepage](http://btrdb.io/)
147 | - [Whitepaper](https://www.usenix.org/system/files/conference/fast16/fast16-papers-andersen.pdf)
148 | - [Code](https://github.com/BTrDB/btrdb-server)
149 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "btrdb-explained",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "bignumber.js": "^7.0.1",
7 | "d3-array": "^1.2.1",
8 | "d3-color": "^1.0.3",
9 | "d3-ease": "^1.0.3",
10 | "d3-interpolate": "^1.1.6",
11 | "d3-scale": "^1.0.7",
12 | "d3-shape": "^1.2.0",
13 | "d3-transition": "^1.1.1",
14 | "fast-simplex-noise": "^3.2.0",
15 | "ramda": "^0.25.0",
16 | "react": "^16.2.0",
17 | "react-dom": "^16.2.0",
18 | "react-scripts": "1.0.17",
19 | "seedrandom": "^2.4.3"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test --env=jsdom",
25 | "eject": "react-scripts eject"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | BTrDB Viz
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "BTrDB",
3 | "name": "BTrDB explained",
4 | "start_url": "./index.html",
5 | "display": "standalone",
6 | "theme_color": "#000000",
7 | "background_color": "#ffffff"
8 | }
9 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import Demo from "./Demo.js";
3 | import "./App.css";
4 |
5 | class App extends Component {
6 | render() {
7 | return ;
8 | }
9 | }
10 |
11 | export default App;
12 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import App from "./App";
4 |
5 | it("renders without crashing", () => {
6 | const div = document.createElement("div");
7 | ReactDOM.render(, div);
8 | });
9 |
--------------------------------------------------------------------------------
/src/Calendar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import * as d3scale from "d3-scale";
3 | import * as d3interpolate from "d3-interpolate";
4 |
5 | class Calendar extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.state = {
9 | // Calendar placement and sizing
10 | calCellSize: 38,
11 | calX: 700,
12 | calY: 40
13 | };
14 | }
15 | drawCalendarCell = (ctx, level, cell) => {
16 | const s = this.state.calCellSize;
17 | const len = 4;
18 | ctx.save();
19 | ctx.beginPath();
20 | ctx.moveTo(0, 0);
21 | ctx.lineTo(s, 0);
22 | ctx.stroke();
23 | ctx.globalAlpha *= 0.5;
24 | ctx.beginPath();
25 | ctx.moveTo(0, 0);
26 | ctx.lineTo(0, len);
27 | ctx.stroke();
28 | ctx.restore();
29 | };
30 | drawCalendarNode = (ctx, level) => {
31 | const n = this.state.numSquareCells;
32 | const s = this.state.calCellSize;
33 | let cell = 0;
34 | ctx.save();
35 | for (let cy = 0; cy < n; cy++) {
36 | ctx.save();
37 | for (let cx = 0; cx < n; cx++) {
38 | this.drawCalendarCell(ctx, level, cell++);
39 | ctx.translate(s, 0);
40 | }
41 | ctx.restore();
42 | ctx.translate(0, s);
43 | }
44 | ctx.restore();
45 | };
46 | drawCalendarNodeTicks = (ctx, level) => {
47 | ctx.save();
48 | ctx.lineWidth *= 2;
49 | ctx.font = "10px sans-serif";
50 | ctx.textBaseline = "top";
51 | ctx.textAlign = "left";
52 | const pad = { left: 5, top: 4 };
53 | const { calTimeK, calKX, calKRow, calRowY } = this.ds;
54 | if (level === 0) {
55 | const drawTick = (t, color, title) => {
56 | const k = calTimeK[level](t);
57 | const x = calKX(k);
58 | const row = calKRow(k);
59 | ctx.fillStyle = ctx.strokeStyle = color;
60 | ctx.beginPath();
61 | ctx.moveTo(x, calRowY(row));
62 | ctx.lineTo(x, calRowY(row + 1));
63 | ctx.stroke();
64 | let y = calRowY(row);
65 | for (let text of title.split("\n")) {
66 | ctx.fillText(text, x + pad.left, y + pad.top);
67 | y += 12;
68 | }
69 | };
70 | drawTick(0, colors.unixEpoch, "unix\nepoch");
71 | drawTick(+new Date() * 1e6, colors.now, "now");
72 | }
73 |
74 | ctx.lineWidth /= 2;
75 | ctx.strokeStyle = ctx.fillStyle = colors.dateTick;
76 | const drawTick = (t, title) => {
77 | const k = calTimeK[level](t);
78 | const x = calKX(k);
79 | const row = calKRow(k);
80 | ctx.beginPath();
81 | ctx.moveTo(x, calRowY(row));
82 | ctx.lineTo(x, calRowY(row + 1));
83 | ctx.stroke();
84 | let y = calRowY(row);
85 | for (let text of title.split("\n")) {
86 | ctx.fillText(text, x + pad.left, y + pad.top);
87 | y += 12;
88 | }
89 | };
90 | const count = 32;
91 | const ticks = calTimeK[level].ticks(count);
92 | const tickFormat = calTimeK[level].tickFormat(count);
93 | for (let i = 0; i < ticks.length; i++) {
94 | const tickTime = ticks[i];
95 | if (ticks[i] === ticks[i - 1]) continue; // nanosecond ticks sometimes duplicate
96 | if (level === 0 && i === 7) continue; // we already drew this as "unix epoch"
97 | const text = tickFormat(tickTime)
98 | .split(" ")
99 | .join("\n");
100 | drawTick(tickTime, text);
101 | }
102 | ctx.restore();
103 | };
104 | drawCalendar = ctx => {
105 | const {
106 | path,
107 | pathAnim,
108 | numSquareCells,
109 | calCellSize,
110 | calX,
111 | calY
112 | } = this.state;
113 | const { dipTime, calW, calTimeK } = this.ds;
114 |
115 | const s = calCellSize;
116 | const n = numSquareCells;
117 |
118 | const t = pathAnim % 1;
119 |
120 | // time used to control the camera
121 | let camT = d3scale
122 | .scaleLinear()
123 | .domain([dipTime, 1])
124 | .range([0, 1])
125 | .clamp(true)(t);
126 |
127 | // time used to control the child highlight border
128 | let highlightT = d3scale
129 | .scaleLinear()
130 | .domain([0, dipTime / 2])
131 | .range([0, 1])
132 | .clamp(true)(t);
133 |
134 | let index = Math.floor(pathAnim);
135 | let contextLabelIndex = index - 1;
136 |
137 | // edge case for last path
138 | if (index > path.length - 1) {
139 | index = path.length - 1;
140 | contextLabelIndex = index;
141 | camT = 1;
142 | highlightT = 1;
143 | }
144 |
145 | const child = path[index];
146 | const level = index - 1;
147 |
148 | const treeColX = child % n;
149 | const treeRowY = Math.floor(child / n);
150 |
151 | const [x, y, scale] = d3scale
152 | .scaleLinear()
153 | .domain([0, 1])
154 | .range([[treeColX * s, treeRowY * s, 1 / n], [0, 0, 1]])(camT);
155 |
156 | ctx.save();
157 | ctx.translate(calX, calY);
158 |
159 | // draw date context
160 | ctx.save();
161 | ctx.translate(calW, 0);
162 | ctx.textBaseline = "bottom";
163 | ctx.textAlign = "right";
164 | const contextDate = calTimeK[level].contextFormat();
165 | if (contextDate) {
166 | ctx.fillStyle = "rgba(90,110,100, 0.3)";
167 | ctx.fillText(contextDate, 0, -10);
168 | }
169 | ctx.restore();
170 |
171 | // draw node time length
172 | ctx.save();
173 | ctx.translate(calW, calW);
174 | ctx.textBaseline = "top";
175 | ctx.textAlign = "right";
176 | ctx.fillStyle = "rgba(90,110,100, 0.5)";
177 | ctx.fillText(nodeLengthLabels[contextLabelIndex], -5, 10);
178 | ctx.restore();
179 |
180 | // clip window
181 | ctx.beginPath();
182 | ctx.rect(-1, -1, calW + 2, calW + 2);
183 | ctx.clip();
184 |
185 | const transformToChild = () => {
186 | ctx.translate(x, y);
187 | ctx.scale(scale, scale);
188 | ctx.lineWidth /= scale;
189 | };
190 | const transformToParent = () => {
191 | transformToChild();
192 | ctx.scale(n, n);
193 | ctx.translate(-treeColX * s, -treeRowY * s);
194 | ctx.lineWidth /= n;
195 | };
196 |
197 | // draw parent
198 | const gridColor = d3interpolate.interpolate("#fff", colors.cellWall)(0.3);
199 | ctx.strokeStyle = gridColor;
200 | ctx.save();
201 | transformToParent();
202 | this.drawCalendarNode(ctx, level);
203 | ctx.restore();
204 |
205 | // draw parent ticks
206 | ctx.save();
207 | transformToParent();
208 | this.drawCalendarNodeTicks(ctx, level);
209 | ctx.strokeRect(0, 0, calW, calW);
210 | ctx.restore();
211 |
212 | // draw child
213 | const childAlpha = d3interpolate.interpolate(0, 1)(Math.pow(camT, 2));
214 | ctx.save();
215 | transformToChild();
216 | ctx.beginPath();
217 | ctx.rect(-1, -1, calW + 2, calW + 2);
218 | ctx.clip();
219 | ctx.globalAlpha *= childAlpha;
220 | ctx.fillStyle = "#fff";
221 | ctx.fillRect(0, 0, calW, calW);
222 | this.drawCalendarNode(ctx, level + 1);
223 | this.drawCalendarNodeTicks(ctx, level + 1);
224 | ctx.restore();
225 |
226 | // outline child
227 | ctx.save();
228 | transformToChild();
229 | ctx.strokeStyle = d3interpolate.interpolate(
230 | "rgba(0,0,0,0)",
231 | colors.cellWall
232 | )(highlightT);
233 | ctx.strokeRect(0, 0, calW, calW);
234 | ctx.restore();
235 |
236 | // outline window
237 | ctx.strokeStyle = colors.cellWall;
238 | ctx.strokeRect(0, 0, calW, calW);
239 |
240 | ctx.restore();
241 | };
242 | }
243 |
--------------------------------------------------------------------------------
/src/Demo.css:
--------------------------------------------------------------------------------
1 | .Demo {
2 | display: flex;
3 | flex-direction: row;
4 | width: 100%;
5 | min-height: 100%;
6 | }
7 |
8 | .Demo-sidebar {
9 | background: #e7e8e9;
10 | width: 288px;
11 | }
12 |
13 | .Demo-sidebar header {
14 | padding: 32px;
15 | text-align: center;
16 | }
17 |
18 | .Demo-logo {
19 | width: 178px;
20 | }
21 |
22 | .Demo-version {
23 | background: #1eb7aa;
24 | color: #fff;
25 | text-align: center;
26 | padding: 24px;
27 | font-family: "Fira Code", monospace;
28 | font-size: 14px;
29 | }
30 |
31 | .Demo-notes {
32 | font-family: "Roboto", sans-serif;
33 | padding-left: 38px;
34 | font-size: 14px;
35 | color: #6c6e6e;
36 | }
37 |
38 | .Demo-notes p {
39 | margin-top: 2em;
40 | margin-right: 2em;
41 | margin-bottom: 2em;
42 | line-height: 1.6em;
43 | }
44 |
45 | .Demo-notes h3 {
46 | font-size: 18px;
47 | font-weight: normal;
48 | color: #454545;
49 | }
50 |
51 | .Demo-notes li {
52 | margin-top: 0.2em;
53 | line-height: 1.6em;
54 | }
55 |
56 | .Demo-body {
57 | padding: 0;
58 | }
59 |
--------------------------------------------------------------------------------
/src/Demo.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Tree from "./Tree";
3 | import logo from "./logo.svg";
4 | import "./Demo.css";
5 |
6 | export default function() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
{"BTrDB Viz v2018.04.30"}
14 |
15 |
16 | Zooming into a BTrDB tree is achieved by descending its branches, so
17 | we show each descent as magnifiying a node.
18 |
19 |
Controls
20 |
21 | - Click nodes
22 | - Enter to animate
23 | - Shift+Mouse ↕ to scrub
24 |
25 |
Changes
26 |
27 | - Fix nanosecond time scale
28 |
29 |
Next
30 |
31 | - Explanatory Annotations
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/Tree.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import * as d3scale from "d3-scale";
3 | import { scaleTimeNano } from "./scaleTimeNano";
4 | import * as d3interpolate from "d3-interpolate";
5 | import * as d3ease from "d3-ease";
6 | import * as d3transition from "d3-transition";
7 | import * as d3array from "d3-array";
8 | import * as d3color from "d3-color";
9 | import * as d3shape from "d3-shape";
10 | import { getStatPoint } from "./datagen";
11 | import * as R from "ramda";
12 | import { BigNumber } from "bignumber.js";
13 |
14 | const nodeLengthLabels = [
15 | "146 years",
16 | "2.28 years",
17 | "13.03 days",
18 | "4.88 hours",
19 | "4.58 min",
20 | "4.29 s",
21 | "67.11 ms",
22 | "1.05 ms",
23 | "16.38 µs",
24 | "256 ns",
25 | "4 ns"
26 | ];
27 |
28 | function rgba(color, opacity) {
29 | const c = d3color.color(color);
30 | c.opacity = opacity;
31 | return c + "";
32 | }
33 |
34 | function rgbaSolid(color, bgColor, opacity) {
35 | return d3interpolate.interpolate(bgColor, color)(opacity);
36 | }
37 |
38 | const theme = {
39 | green: "#1eb7aa",
40 | orange: "#db7b35"
41 | };
42 |
43 | const colors = {
44 | cellFillExpanded: "rgba(80,100,120, 0.15)",
45 | cellFillHighlight: rgba(theme.green, 0.8),
46 | cellWall: rgba("#555", 0.1),
47 | cellWallExpanded: "#555",
48 | cellWallHighlight: theme.green,
49 |
50 | plotGridLine: rgbaSolid("#555", "#fff", 0.2),
51 |
52 | nodeFill: "#fff",
53 | nodeStroke: "#000",
54 | midNodeFill: rgba("#fff", 0.5),
55 | midNodeStroke: rgba("#000", 0.5),
56 |
57 | unixEpoch: theme.green,
58 | now: theme.orange,
59 | dateTick: "rgba(90,110,100, 0.5)",
60 | scrub: "#e7e8e9",
61 |
62 | zoomCone: "rgba(80,100,120, 0.15)",
63 | shadowCone: rgba(theme.green, 0.4),
64 |
65 | plotShadow: "rgba(80,100,120, 0.15)",
66 | plotLine: rgba("#555", 0.5),
67 | plotBorder: "#555",
68 | plotConeLine: rgba("#555", 0.4),
69 | plotHighlight: theme.green,
70 | clear: "rgba(0,0,0,0)"
71 | };
72 |
73 | const leftOfPath = path => {
74 | if (!path || !path.length) return;
75 |
76 | const i = path[path.length - 1];
77 | if (i > 0) {
78 | const sibling = path.slice(0);
79 | sibling.pop();
80 | sibling.push(i - 1);
81 | return sibling;
82 | }
83 |
84 | const parent = path.slice(0, path.length - 1);
85 | const newParent = leftOfPath(parent);
86 | if (newParent) {
87 | const cousin = newParent.slice(0);
88 | cousin.push(63);
89 | return cousin;
90 | }
91 | };
92 |
93 | const rightOfPath = path => {
94 | if (!path || !path.length) return;
95 |
96 | const i = path[path.length - 1];
97 | if (i < 63) {
98 | const sibling = path.slice(0);
99 | sibling.pop();
100 | sibling.push(i + 1);
101 | return sibling;
102 | }
103 |
104 | const parent = path.slice(0, path.length - 1);
105 | const newParent = rightOfPath(parent);
106 | if (newParent) {
107 | const cousin = newParent.slice(0);
108 | cousin.push(0);
109 | return cousin;
110 | }
111 | };
112 |
113 | const padded = array => {
114 | return array[-1] ? R.prepend(array[-1], array) : array;
115 | };
116 |
117 | class Tree extends Component {
118 | constructor(props) {
119 | super(props);
120 | this.state = {
121 | // canvas
122 | width: 1280,
123 | height: 800,
124 |
125 | numCells: 64, // cells in a tree row
126 | numSquareCells: 8, // cells in a calendar row
127 |
128 | path: [0], // first of path is ignored for generality
129 | pathAnim: 1, // float representing what index of the path we are showing
130 |
131 | // Tree placement and sizing
132 | treeCellW: 8,
133 | treeCellH: 10,
134 | treeX: 88,
135 | treeY: 82,
136 | levelOffset: 7,
137 | cellHighlight: null,
138 |
139 | // Plot placement and sizing
140 | plotX: 620,
141 | plotW: 320,
142 | plotH: 40,
143 |
144 | // Calendar placement and sizing
145 | calCellSize: 38,
146 | calX: 700,
147 | calY: 40,
148 |
149 | rootStart: BigNumber("-1152921504606846976"),
150 | rootResolution: 56
151 |
152 | // TODO:
153 | // selectionStart (path)
154 | // selectionEnd (path)
155 | };
156 | for (let i = 1; i < 10; i++) {
157 | this.state.path.push(Math.floor(Math.random() * this.state.numCells));
158 | }
159 | this.state.pathAnim = this.state.path.length;
160 | this.createD3Objects();
161 | }
162 | componentWillMount() {
163 | this.computeDerivedState(this.props, this.state);
164 | }
165 | componentWillUpdate(nextProps, nextState) {
166 | this.computeDerivedState(nextProps, nextState);
167 | }
168 | componentDidMount() {
169 | window.addEventListener("keydown", this.onKeyDown);
170 | window.addEventListener("keyup", this.onKeyUp);
171 | }
172 | componentWillUnmount() {
173 | window.removeEventListener("keydown", this.onKeyDown);
174 | window.removeEventListener("keyup", this.onKeyUp);
175 | }
176 | createD3Objects = () => {
177 | this.d3 = {};
178 | };
179 | computeDerivedState = (props, state) => {
180 | const {
181 | treeCellW,
182 | treeCellH,
183 | calCellSize,
184 | path,
185 | rootStart,
186 | rootResolution,
187 | numCells,
188 | numSquareCells,
189 | levelOffset,
190 | plotH
191 | } = state;
192 |
193 | // `dip` is how long `t` will spend dropping the node before expanding it.
194 | const dipTime = 1 / levelOffset;
195 |
196 | const pixelRatio = window.devicePixelRatio || 1;
197 |
198 | // maps tree cell units to pixels
199 | const treeColX = d3scale
200 | .scaleLinear()
201 | .domain([0, 1])
202 | .range([0, treeCellW]);
203 | const treeRowY = d3scale
204 | .scaleLinear()
205 | .domain([0, 1])
206 | .range([0, treeCellH]);
207 |
208 | // maps calendar cell units to pixels
209 | const calColX = d3scale
210 | .scaleLinear()
211 | .domain([0, 1])
212 | .range([0, calCellSize]);
213 | const calRowY = d3scale
214 | .scaleLinear()
215 | .domain([0, 1])
216 | .range([0, calCellSize]);
217 |
218 | const treeW = treeCellW * numCells;
219 | const calW = calCellSize * numSquareCells;
220 |
221 | // compute scale for each level
222 | let start = rootStart;
223 | let res = rootResolution;
224 | const treeTimeX = [];
225 | const calTimeK = [];
226 | for (let i = 0; i < path.length; i++) {
227 | const end = start.plus(2 ** res * numCells);
228 | const domain = [start, end];
229 | treeTimeX.push(
230 | scaleTimeNano(BigNumber)
231 | .domain(domain)
232 | .range([0, treeW])
233 | );
234 | calTimeK.push(
235 | scaleTimeNano(BigNumber)
236 | .domain(domain)
237 | .range([0, calW * numSquareCells])
238 | );
239 | if (i + 1 < path.length) {
240 | start = start.plus(2 ** res * path[i + 1]);
241 | }
242 | res -= 6;
243 | }
244 |
245 | // calendar time to x and row
246 | const calKX = k => k % calW;
247 | const calKRow = k => Math.floor(k / calW);
248 |
249 | const numMidRows = levelOffset - 1;
250 |
251 | // pre-populate tree data by retrieving bottom point
252 | const bottomPath = path.slice(1); // remove the stand-in root node
253 | bottomPath.push(0); // retrieve any node on last path
254 | getStatPoint(bottomPath);
255 |
256 | // get node for each level (cell data inside `.children` property)
257 | const levelPaths = d3array
258 | .range(path.length)
259 | .map(i => path.slice(1, i + 1));
260 | const levelData = levelPaths.map(path => {
261 | const data = getStatPoint(path);
262 |
263 | // get adjacent points on either side of this path's node
264 | const leftPath = leftOfPath(path);
265 | const rightPath = rightOfPath(path);
266 | getStatPoint(R.append(0, leftPath));
267 | getStatPoint(R.append(0, rightPath));
268 | const leftData = getStatPoint(leftPath);
269 | const rightData = getStatPoint(rightPath);
270 |
271 | // pad the given points with adjacent left/right points
272 | const pad = (points, midRes) => {
273 | const newPoints = points.slice(0);
274 | if (leftData) {
275 | newPoints[-1] =
276 | midRes == null
277 | ? R.last(leftData.children)
278 | : R.last(leftData.midResChildren[midRes]);
279 | }
280 | if (rightData) {
281 | newPoints.push(
282 | midRes == null
283 | ? rightData.children[0]
284 | : rightData.midResChildren[midRes][0]
285 | );
286 | }
287 | return newPoints;
288 | };
289 |
290 | // add index key to each point
291 | const addIndexes = points => {
292 | const addIndex = (v, i) => R.assoc("i", i, v);
293 | const newPoints = points.map(addIndex);
294 | if (points[-1]) {
295 | newPoints[-1] = addIndex(points[-1], -1);
296 | }
297 | return newPoints;
298 | };
299 |
300 | const newData = R.merge(data, {
301 | children: addIndexes(pad(data.children)),
302 | midResChildren: data.midResChildren.map(
303 | (points, midRes) => points && addIndexes(pad(points))
304 | )
305 | });
306 | return newData;
307 | });
308 | const levelScaleY = levelData.map(({ min, max }) =>
309 | d3scale
310 | .scaleLinear()
311 | .domain([min, max])
312 | .range([plotH, 0])
313 | );
314 |
315 | this.ds = {
316 | pixelRatio,
317 | treeW,
318 | calW,
319 | treeColX,
320 | treeRowY,
321 | treeTimeX,
322 | calColX,
323 | calRowY,
324 | calTimeK,
325 | calKX,
326 | calKRow,
327 | dipTime,
328 | numMidRows,
329 | levelData,
330 | levelScaleY
331 | };
332 | };
333 | cone = (parent, row) => {
334 | const { treeCellW, treeCellH, levelOffset } = this.state;
335 | const x = this.midResStart(parent, Math.max(0, row)) * treeCellW;
336 | const numCells = Math.pow(2, Math.max(0, row));
337 | const w = numCells * treeCellW;
338 | const y = (-levelOffset + 1 + row) * treeCellH;
339 | return { x, y, w, numCells };
340 | };
341 | getParent = level => {
342 | // get the index of the level's parent
343 | // (for example, the level below descends from index 3 of previous level)
344 | //
345 | // 0 1 2 3 4 5 6 7 8 9
346 | // |
347 | // /|\
348 | // -------------------
349 | // | |
350 | // -------------------
351 | return this.state.path[level];
352 | };
353 | getLevelAnimTime = level => {
354 | return d3scale
355 | .scaleLinear()
356 | .domain([level, level + 1])
357 | .range([0, 1])
358 | .clamp(true)(this.state.pathAnim);
359 | };
360 | getConeAnimRow = level => {
361 | const { dipTime, numMidRows } = this.ds;
362 | const t = this.getLevelAnimTime(level);
363 | return d3scale
364 | .scaleLinear()
365 | .domain([dipTime, 1])
366 | .range([0, numMidRows])(t);
367 | };
368 | drawCell = (ctx, level, cell) => {
369 | const { treeCellH, treeCellW } = this.state;
370 | const expanded = this.isCellExpanded(level, cell);
371 | const highlighted = this.isCellHighlighted(level, cell);
372 | if (highlighted) {
373 | ctx.strokeStyle = colors.cellWallHighlight;
374 | ctx.fillStyle = colors.cellFillHighlight;
375 | } else if (expanded) {
376 | ctx.strokeStyle = colors.cellWallExpanded;
377 | ctx.fillStyle = colors.cellFillExpanded;
378 | } else {
379 | ctx.strokeStyle = colors.cellWall;
380 | ctx.fillStyle = colors.clear;
381 | }
382 | ctx.fillRect(0, 0, treeCellW, treeCellH);
383 | ctx.strokeRect(0, 0, treeCellW, treeCellH);
384 | };
385 | drawMidResCell = (ctx, level, cell, midRes) => {
386 | const { treeCellH, treeCellW, cellHighlight } = this.state;
387 | const highlight =
388 | cellHighlight &&
389 | cellHighlight.level === level &&
390 | cellHighlight.midRes === midRes &&
391 | cellHighlight.cell === cell;
392 | if (highlight) {
393 | ctx.strokeStyle = colors.cellWallHighlight;
394 | ctx.fillStyle = colors.cellFillHighlight;
395 | } else {
396 | ctx.strokeStyle = colors.cellWall;
397 | ctx.fillStyle = colors.clear;
398 | }
399 | ctx.fillRect(0, 0, treeCellW, treeCellH);
400 | ctx.strokeRect(0, 0, treeCellW, treeCellH);
401 | };
402 | drawZoomCone = (ctx, level) => {
403 | const row = this.getConeAnimRow(level);
404 | const { treeCellH } = this.state;
405 | const parent = this.getParent(level);
406 |
407 | if (level > 0 && row > 0) {
408 | ctx.beginPath();
409 | const dr = 1 / treeCellH;
410 | for (let r = 0; r <= row; r += dr) {
411 | const { x, y } = this.cone(parent, r);
412 | ctx.lineTo(x, y);
413 | }
414 | for (let r = row; r >= 0; r -= dr) {
415 | const { x, y, w } = this.cone(parent, r);
416 | ctx.lineTo(x + w, y);
417 | }
418 | ctx.fillStyle = colors.zoomCone;
419 | ctx.fill();
420 | }
421 | };
422 | drawHighlightCone = (ctx, level) => {
423 | const { cellHighlight, treeCellH } = this.state;
424 | const t = this.getLevelAnimTime(level);
425 | const row = this.getConeAnimRow(level);
426 |
427 | const highlight =
428 | t === 1 &&
429 | cellHighlight &&
430 | cellHighlight.cell != null &&
431 | cellHighlight.midRes != null &&
432 | cellHighlight.level === level;
433 |
434 | if (highlight) {
435 | ctx.beginPath();
436 | const dr = 1 / treeCellH;
437 | const startR = cellHighlight.midRes;
438 | const startCell = cellHighlight.cell;
439 | const parent = this.getParent(level);
440 | const top = this.cone(parent, startR);
441 | for (let r = startR; r <= row; r += dr) {
442 | const { x, y, w } = this.cone(parent, r);
443 | ctx.lineTo(x + w * startCell / top.numCells, y);
444 | }
445 | for (let r = row; r >= startR; r -= dr) {
446 | const { x, y, w } = this.cone(parent, r);
447 | ctx.lineTo(x + w * (startCell + 1) / top.numCells, y);
448 | }
449 | ctx.fillStyle = colors.shadowCone;
450 | ctx.fill();
451 | }
452 | };
453 | drawMidLevelBox = (ctx, level) => {
454 | const { cellHighlight, treeCellW, treeCellH } = this.state;
455 | const { numMidRows } = this.ds;
456 |
457 | const parent = this.getParent(level);
458 | const t = this.getLevelAnimTime(level);
459 |
460 | if (level > 0 && t === 1) {
461 | for (let r = 1; r < numMidRows; r++) {
462 | ctx.save();
463 | const { x, y, w, numCells } = this.cone(parent, r);
464 | ctx.translate(Math.floor(x), y - treeCellH);
465 | const midRes = r;
466 | const show =
467 | cellHighlight &&
468 | cellHighlight.midRes === midRes &&
469 | cellHighlight.level === level;
470 | if (show) {
471 | ctx.fillStyle = colors.midNodeFill;
472 | ctx.fillRect(0, 0, w, treeCellH);
473 | ctx.save();
474 | for (let cell = 0; cell < numCells; cell++) {
475 | this.drawMidResCell(ctx, level, cell, midRes);
476 | ctx.translate(treeCellW, 0);
477 | }
478 | ctx.restore();
479 | ctx.strokeStyle = colors.midNodeStroke;
480 | ctx.strokeRect(0, 0, w, treeCellH);
481 | }
482 | ctx.restore();
483 | }
484 | }
485 | };
486 | drawLevelBox = (ctx, level) => {
487 | const { treeCellW, treeCellH, numCells, cellHighlight } = this.state;
488 | const { treeColX, treeRowY, treeTimeX } = this.ds;
489 |
490 | const t = this.getLevelAnimTime(level);
491 | const row = this.getConeAnimRow(level);
492 | const parent = this.getParent(level);
493 | const { x, y, w } = this.cone(parent, row);
494 | const x1 = x + w;
495 |
496 | // Translate to the topleft corner of box
497 | ctx.save();
498 | ctx.translate(x, y);
499 |
500 | // Make opaque
501 | ctx.fillStyle = colors.nodeFill;
502 | ctx.fillRect(0, 0, w, treeCellH);
503 |
504 | // Draw inner cells
505 | if (t === 1) {
506 | ctx.save();
507 | for (let cell = 0; cell < numCells; cell++) {
508 | this.drawCell(ctx, level, cell);
509 | ctx.translate(treeCellW, 0);
510 | }
511 | ctx.restore();
512 | }
513 |
514 | // draw mid-resolution highlight and ticks
515 | if (
516 | t === 1 &&
517 | cellHighlight &&
518 | cellHighlight.midRes != null &&
519 | cellHighlight.level === level &&
520 | cellHighlight.cell != null
521 | ) {
522 | const blockSize = numCells / Math.pow(2, cellHighlight.midRes);
523 | ctx.save();
524 | for (let cell = 0; cell < numCells; cell += blockSize) {
525 | if (cell === cellHighlight.cell * blockSize) {
526 | ctx.fillStyle = colors.shadowCone;
527 | ctx.fillRect(0, 0, treeCellW * blockSize, treeCellH);
528 | }
529 | ctx.strokeStyle = rgba("#555", 0.5);
530 | ctx.strokeRect(0, 0, treeCellW * blockSize, treeCellH);
531 | ctx.translate(blockSize * treeCellW, 0);
532 | }
533 | ctx.restore();
534 | }
535 |
536 | // Draw outer border
537 | ctx.strokeStyle = colors.nodeStroke;
538 | ctx.strokeRect(0, 0, w, treeCellH);
539 |
540 | // Tick font
541 | ctx.font = "10px sans-serif";
542 | ctx.textBaseline = "bottom";
543 | ctx.textAlign = "center";
544 |
545 | // Draw the major _unix epoch_ and _now_ ticks
546 | if (level === 0) {
547 | const drawTick = (t, color, title) => {
548 | const x = treeTimeX[level](t);
549 | if (x < 0 || x > treeColX(numCells)) return;
550 | ctx.fillStyle = ctx.strokeStyle = color;
551 | ctx.beginPath();
552 | ctx.moveTo(x, treeRowY(-0.7));
553 | ctx.lineTo(x, treeRowY(1));
554 | ctx.stroke();
555 | ctx.fillText(title, x, treeRowY(-2));
556 | };
557 | drawTick(0, colors.unixEpoch, "unix epoch");
558 | drawTick(+new Date() * 1e6, colors.now, "now");
559 | }
560 |
561 | // Draw the date ticks
562 | if (t === 1) {
563 | ctx.strokeStyle = ctx.fillStyle = colors.dateTick;
564 | const drawTick = (t, title) => {
565 | const x = treeTimeX[level](t);
566 | ctx.beginPath();
567 | ctx.moveTo(x, treeRowY(0));
568 | ctx.lineTo(x, treeRowY(-0.5));
569 | ctx.stroke();
570 | ctx.fillText(title, x, treeRowY(-0.75));
571 | };
572 | const count = 8;
573 | const ticks = treeTimeX[level].ticks(count);
574 | const tickFormat = treeTimeX[level].tickFormat();
575 | for (let i = 0; i < ticks.length; i++) {
576 | const tickTime = ticks[i];
577 | drawTick(tickTime, tickFormat(tickTime));
578 | }
579 | }
580 |
581 | // Draw the node length
582 | if (t === 1) {
583 | ctx.textAlign = "left";
584 | ctx.textBaseline = "middle";
585 | ctx.fillText(nodeLengthLabels[level], x1 + 28, treeRowY(0.5));
586 | }
587 |
588 | ctx.restore();
589 | };
590 | drawPlot = (ctx, level) => {
591 | const t = this.getLevelAnimTime(level);
592 | if (t < 1) return;
593 |
594 | const {
595 | plotX,
596 | plotW,
597 | plotH,
598 | treeCellH,
599 | path,
600 | levelOffset,
601 | cellHighlight
602 | } = this.state;
603 | const { levelData, levelScaleY } = this.ds;
604 |
605 | // TODO: use midResChildren if highlighting midRes level
606 | const data = levelData[level];
607 | const midRes =
608 | cellHighlight &&
609 | cellHighlight.level === level &&
610 | cellHighlight.midRes != null
611 | ? cellHighlight.midRes
612 | : null;
613 |
614 | const points = data.midResChildren[midRes] || data.children;
615 | const numPoints = midRes == null ? 64 : 2 ** midRes;
616 |
617 | if (!points) return;
618 |
619 | if (midRes != null) {
620 | ctx.save();
621 | ctx.globalAlpha *= 0.5;
622 | drawPoints(data.children, 64, true);
623 | ctx.restore();
624 | }
625 | drawPoints(points, numPoints);
626 |
627 | // draw border
628 | ctx.save();
629 | ctx.translate(plotX, treeCellH / 2 - plotH / 2);
630 | ctx.strokeStyle = colors.plotBorder;
631 | ctx.strokeRect(0, 0, plotW, plotH);
632 | ctx.restore();
633 |
634 | // DRAW POINTS
635 | function drawPoints(points, numPoints, hideCellHighlight) {
636 | // scales
637 | const xScale = d3scale
638 | .scaleLinear()
639 | .domain([-0.5, numPoints - 0.5])
640 | .range([0, plotW]);
641 | const yScale = levelScaleY[level];
642 |
643 | // shapes
644 | const line = d3shape
645 | .line()
646 | .context(ctx)
647 | .x(({ i }) => xScale(i))
648 | .y(({ mean }) => yScale(mean));
649 | const shadow = d3shape
650 | .area()
651 | .context(ctx)
652 | .x(({ i }) => xScale(i))
653 | .y0(({ min }) => yScale(min))
654 | .y1(({ max }) => yScale(max));
655 |
656 | ctx.save();
657 | ctx.translate(plotX, treeCellH / 2 - plotH / 2);
658 |
659 | // clip the plot to this rectangle
660 | ctx.save();
661 | ctx.beginPath();
662 | ctx.rect(0, 0, plotW, plotH);
663 | ctx.clip();
664 |
665 | // draw min/max shadow
666 | ctx.beginPath();
667 | shadow(padded(points));
668 | ctx.fillStyle = colors.plotShadow;
669 | // ctx.fill();
670 |
671 | // draw mean line
672 | // ctx.beginPath();
673 | // line(padded(points));
674 | // ctx.strokeStyle = colors.plotLine;
675 | // ctx.stroke();
676 |
677 | // draw mean dots
678 | for (let i = 0; i < numPoints; i++) {
679 | const { mean } = points[i];
680 | const r = 1;
681 | ctx.beginPath();
682 | ctx.ellipse(xScale(i), yScale(mean), r, r, 0, 0, 2 * Math.PI);
683 | ctx.fillStyle = rgba("#000", 0.4);
684 | // ctx.fill();
685 | }
686 |
687 | // undo clip
688 | ctx.restore();
689 |
690 | const xResScale = res => {
691 | return res == null
692 | ? xScale
693 | : d3scale
694 | .scaleLinear()
695 | .domain([-0.5, 2 ** res - 0.5])
696 | .range([0, plotW]);
697 | };
698 |
699 | const cellRect = ({ i, min, max }, res) => {
700 | // custom scale if we use lower resolution
701 | const xs = xResScale(res);
702 | const ys = yScale;
703 | ctx.rect(xs(i - 0.5), ys(min), xs(1) - xs(0), ys(max) - ys(min));
704 | };
705 |
706 | // draw vertical grid lines
707 | // ctx.beginPath();
708 | // for (let i = 1; i < numPoints; i++) {
709 | // const x = xScale(i - 0.5);
710 | // ctx.moveTo(x, 0);
711 | // ctx.lineTo(x, plotH);
712 | // }
713 | // ctx.strokeStyle = colors.plotGridLine;
714 | // ctx.stroke();
715 | for (let i = 0; i < numPoints; i++) {
716 | ctx.beginPath();
717 | cellRect(points[i]);
718 | ctx.strokeStyle = colors.plotGridLine;
719 | ctx.stroke();
720 | }
721 |
722 | // draw expanded cell
723 | const expandedCell = path[level + 1];
724 | if (midRes == null && expandedCell != null) {
725 | const p = points[expandedCell];
726 | ctx.beginPath();
727 | cellRect(p);
728 | ctx.strokeStyle = colors.cellWallExpanded;
729 | ctx.stroke();
730 |
731 | const topx0 = xScale(p.i - 0.5);
732 | const topx1 = xScale(p.i + 0.5);
733 | const topy = yScale(p.min);
734 |
735 | const midy = plotH;
736 |
737 | const botx0 = 0;
738 | const botx1 = plotW;
739 | const boty = levelOffset * treeCellH;
740 |
741 | ctx.beginPath();
742 | ctx.moveTo(topx0, topy);
743 | ctx.lineTo(topx0, midy);
744 | ctx.moveTo(topx1, topy);
745 | ctx.lineTo(topx1, midy);
746 | ctx.setLineDash([3, 2]);
747 | ctx.strokeStyle = colors.plotConeLine;
748 | ctx.stroke();
749 | ctx.beginPath();
750 | ctx.lineTo(topx0, midy);
751 | ctx.lineTo(botx0, boty);
752 | ctx.lineTo(botx1, boty);
753 | ctx.lineTo(topx1, midy);
754 | ctx.stroke();
755 | ctx.setLineDash([]);
756 | // ctx.fillStyle = colors.zoomCone;
757 | // ctx.fill();
758 | }
759 |
760 | // draw highlighted cell
761 | if (
762 | !hideCellHighlight &&
763 | cellHighlight &&
764 | cellHighlight.level === level &&
765 | cellHighlight.cell != null
766 | ) {
767 | const p = points[cellHighlight.cell];
768 | ctx.beginPath();
769 | cellRect(p, cellHighlight.midRes);
770 | // ctx.strokeStyle = colors.cellWallHighlight;
771 | ctx.strokeStyle = colors.cellWallExpanded;
772 | ctx.stroke();
773 | if (midRes != null) {
774 | ctx.fillStyle = colors.shadowCone;
775 | ctx.fill();
776 | }
777 | if (midRes == null) {
778 | const xs = xResScale(midRes);
779 | const ys = yScale;
780 | const { i, min, max } = p;
781 | ctx.fillStyle = colors.shadowCone;
782 | ctx.fillRect(xs(i - 0.5), 0, xs(1) - xs(0), ys(min));
783 | ctx.fillRect(xs(i - 0.5), ys(max), xs(1) - xs(0), plotH - ys(max));
784 | }
785 | }
786 |
787 | ctx.restore();
788 | }
789 | };
790 | drawLevel = (ctx, level) => {
791 | const t = this.getLevelAnimTime(level);
792 | if (t === 0) return;
793 |
794 | ctx.save();
795 | this.drawZoomCone(ctx, level);
796 | this.drawHighlightCone(ctx, level);
797 | this.drawLevelBox(ctx, level);
798 | this.drawMidLevelBox(ctx, level);
799 | this.drawPlot(ctx, level);
800 | ctx.restore();
801 | };
802 | getTreeHeight = () => {
803 | const { path, levelOffset, treeCellH } = this.state;
804 | return (path.length - 1) * levelOffset * treeCellH + treeCellH;
805 | };
806 | drawScrubGuide = ctx => {
807 | const { scrubbingAnim, path, pathAnim } = this.state;
808 | if (!scrubbingAnim) return;
809 | ctx.save();
810 | ctx.strokeStyle = colors.scrub;
811 | ctx.translate(-30, 0);
812 | const barH = 32;
813 | const treeH = this.getTreeHeight();
814 | ctx.beginPath();
815 | ctx.moveTo(0, 0);
816 | ctx.lineTo(0, treeH);
817 | ctx.lineWidth = 1;
818 | ctx.stroke();
819 | const y = d3scale
820 | .scaleLinear()
821 | .domain([1, path.length])
822 | .range([0, treeH - barH])(pathAnim);
823 | ctx.beginPath();
824 | ctx.moveTo(0, y);
825 | ctx.lineTo(0, y + barH);
826 | ctx.lineWidth = 4;
827 | ctx.stroke();
828 | ctx.restore();
829 | };
830 | drawTree = ctx => {
831 | ctx.save();
832 | const { treeX, treeY, treeCellH, levelOffset, path } = this.state;
833 | ctx.translate(treeX, treeY);
834 | // this.drawScrubGuide(ctx);
835 | for (let level = 0; level < path.length; level++) {
836 | this.drawLevel(ctx, level);
837 | ctx.translate(0, treeCellH * levelOffset);
838 | }
839 | ctx.restore();
840 | };
841 | draw = canvas => {
842 | if (!canvas) return;
843 | const ctx = canvas.getContext("2d");
844 | const { pixelRatio } = this.ds;
845 | const { width, height } = this.state;
846 | ctx.save();
847 | ctx.scale(pixelRatio, pixelRatio);
848 | ctx.clearRect(0, 0, width, height);
849 | this.drawTree(ctx);
850 | ctx.restore();
851 | };
852 | getMousePos = e => {
853 | if (!e) return this.lastMouse || { x: 0, y: 0 };
854 | const rect = this.canvas.getBoundingClientRect();
855 | const x = e.clientX - rect.left;
856 | const y = e.clientY - rect.top;
857 | return (this.lastMouse = { x, y });
858 | };
859 | isLevelVisible = level => {
860 | return level < Math.floor(this.state.pathAnim);
861 | };
862 | isCellExpanded = (level, cell) => {
863 | return this.state.path[level + 1] === cell;
864 | };
865 | isCellHighlighted = (level, cell, midRes) => {
866 | const { cellHighlight } = this.state;
867 | return (
868 | cellHighlight &&
869 | cellHighlight.midRes === midRes &&
870 | cellHighlight.cell === cell &&
871 | cellHighlight.level === level
872 | );
873 | };
874 | midResStart = (parent, exp) => {
875 | // 0 <= parent < 64 (the child node of previous level that we are expanding)
876 | // 0 <= exp <= 6 (the resolution row => numMidCells = 2^exp)
877 | const { numCells } = this.state;
878 | const numMidCells = Math.pow(2, exp);
879 | return d3scale
880 | .scaleLinear()
881 | .domain([0, numCells - 1])
882 | .range([0, numCells - numMidCells])(parent);
883 | };
884 | mouseToTreePath = (x, y) => {
885 | const {
886 | treeX,
887 | treeY,
888 | treeCellW,
889 | treeCellH,
890 | levelOffset,
891 | path
892 | } = this.state;
893 | const { numMidRows, treeW } = this.ds;
894 |
895 | const inside = treeX <= x && x < treeX + treeW;
896 | if (!inside) return;
897 |
898 | const gridY = Math.floor((y - treeY) / treeCellH);
899 | let level = Math.floor(gridY / levelOffset);
900 |
901 | const atNode = gridY % levelOffset === 0 && this.isLevelVisible(level);
902 | const betweenNodes =
903 | gridY % levelOffset > 0 && this.isLevelVisible(level + 1);
904 |
905 | if (atNode) {
906 | const cell = Math.floor((x - treeX) / treeCellW);
907 | if (cell >= 0 && cell < 64) return { level, cell };
908 | } else if (betweenNodes) {
909 | level++;
910 | const row = Math.floor(gridY % levelOffset);
911 | if (row < numMidRows) {
912 | const parent = path[level];
913 | const cone = this.cone(parent, row);
914 | const cell = Math.floor((x - treeX - cone.x) / treeCellW);
915 | const midRes = row;
916 | if (cell >= 0 && cell < cone.numCells) {
917 | return { level, midRes, cell };
918 | } else {
919 | return { level, midRes };
920 | }
921 | }
922 | }
923 | };
924 | mouseToPlotPath = (x, y) => {
925 | const {
926 | treeX,
927 | treeY,
928 | plotX,
929 | plotW,
930 | treeCellH,
931 | plotH,
932 | levelOffset
933 | } = this.state;
934 |
935 | const leftX = treeX + plotX;
936 | const inside = leftX <= x && x < leftX + plotW;
937 | if (!inside) return;
938 |
939 | const topY = treeY + treeCellH / 2 - plotH / 2;
940 | const offsetY = levelOffset * treeCellH;
941 |
942 | const level = Math.floor((y - topY) / offsetY);
943 | const levelY = y % offsetY;
944 |
945 | if (levelY < plotH && this.isLevelVisible(level)) {
946 | const cell = Math.floor((x - leftX) / plotW * 64);
947 | return { level, cell };
948 | }
949 | // TODO: mid-resolution mouse-over
950 | };
951 | mouseToPath = (x, y) => {
952 | return this.mouseToTreePath(x, y) || this.mouseToPlotPath(x, y);
953 | };
954 | scrubAnim = (x, y) => {
955 | const { treeY, path } = this.state;
956 | const t = d3scale
957 | .scaleLinear()
958 | .domain([treeY, treeY + this.getTreeHeight()])
959 | .range([1, path.length])
960 | .clamp(true)(y);
961 | this.setState({ pathAnim: t });
962 | };
963 | onMouseDown = (e, { isDrag }) => {
964 | const mouse = this.getMousePos(e);
965 | if (!this.isMouseDown) this.mouseLockY = mouse.y;
966 | this.isMouseDown = true;
967 |
968 | const { x, y } = { x: mouse.x, y: this.mouseLockY };
969 | const point = this.mouseToPath(x, y);
970 |
971 | if (point && point.midRes == null) {
972 | const { level, cell } = point;
973 | if (this.isLevelVisible(level + 1) && !this.isCellExpanded(level, cell)) {
974 | const path = this.state.path.slice(0, level + 1);
975 | path.push(cell);
976 | this.setState({ path, pathAnim: level + 2 });
977 | }
978 | if (!isDrag) {
979 | this.mousedownCell = cell;
980 | this.shouldCollapseOnMouseUp = this.isCellExpanded(level, cell);
981 | } else if (cell !== this.mousedownCell) {
982 | this.shouldCollapseOnMouseUp = false;
983 | }
984 | }
985 | };
986 | cancelTransitions = () => {
987 | d3transition.interrupt("collapse-cell");
988 | d3transition.interrupt("expand-cell");
989 | d3transition.interrupt("collapse-all");
990 | d3transition.interrupt("expand-all");
991 | };
992 | onMouseUp = e => {
993 | this.isMouseDown = false;
994 | this.mouseLockY = null;
995 | const { cellHighlight, pathAnim } = this.state;
996 | if (
997 | cellHighlight &&
998 | cellHighlight.midRes == null &&
999 | this.levelClickable(cellHighlight.level)
1000 | ) {
1001 | const { level, cell } = cellHighlight;
1002 | const parentPath = this.state.path.slice(0, level + 1);
1003 | if (this.isLevelVisible(level + 1)) {
1004 | const interp = d3interpolate.interpolate(pathAnim, level + 1);
1005 | if (this.shouldCollapseOnMouseUp) {
1006 | this.cancelTransitions();
1007 | d3transition
1008 | .transition("collapse-cell")
1009 | .duration(500)
1010 | .tween("pathAnim", () => t =>
1011 | this.setState({ pathAnim: interp(t) })
1012 | )
1013 | .on("end", () => this.setState({ path: parentPath }));
1014 | }
1015 | } else {
1016 | parentPath.push(cell);
1017 | this.setState({ path: parentPath });
1018 | this.cancelTransitions();
1019 | d3transition
1020 | .transition("expand-cell")
1021 | .duration(500)
1022 | .tween("pathAnim", () => t =>
1023 | this.setState({ pathAnim: level + 1 + t })
1024 | );
1025 | }
1026 | }
1027 | this.onMouseMove();
1028 | };
1029 | levelClickable = level => {
1030 | return level < 9;
1031 | };
1032 | cellClickable = obj => {
1033 | if (!obj) return false;
1034 | const { cell, level, midRes } = obj;
1035 | return cell != null && midRes == null && this.levelClickable(level);
1036 | };
1037 | onMouseMove = e => {
1038 | const mouse = this.getMousePos(e);
1039 | if (this.state.scrubbingAnim) {
1040 | this.scrubAnim(mouse.x, mouse.y);
1041 | } else {
1042 | const x = mouse.x;
1043 | const y = this.isMouseDown ? this.mouseLockY : mouse.y;
1044 | const curr = this.mouseToPath(x, y);
1045 | const prev = this.state.cellHighlight;
1046 | const clickable = this.cellClickable(curr);
1047 | const cursor = clickable ? "pointer" : "default";
1048 | const cursorChange = this.state.cursor !== cursor;
1049 | const highlightChange = JSON.stringify(curr) !== JSON.stringify(prev);
1050 | if (cursorChange) this.setState({ cursor });
1051 | if (highlightChange) this.setState({ cellHighlight: curr });
1052 | if (this.isMouseDown) this.onMouseDown(e, { isDrag: true });
1053 | }
1054 | };
1055 | onKeyDown = e => {
1056 | if (e.key === "Shift") {
1057 | this.setState({ scrubbingAnim: true });
1058 | this.setState({ cursor: "grabbing" });
1059 | // this.onMouseMove();
1060 | }
1061 | };
1062 | onKeyUp = e => {
1063 | if (e.key === "Shift") {
1064 | this.setState({ scrubbingAnim: false });
1065 | this.setState({ cursor: "default" });
1066 | } else if (e.key === "Enter") {
1067 | this.cancelTransitions();
1068 | const { pathAnim, path } = this.state;
1069 | const durationPerLevel = 125;
1070 | if (pathAnim > 1) {
1071 | const interp = d3interpolate.interpolate(pathAnim, 1);
1072 | const dist = pathAnim - 1;
1073 | const duration = dist * durationPerLevel;
1074 | d3transition
1075 | .transition("collapse-all")
1076 | .ease(d3ease.easeLinear)
1077 | .duration(duration)
1078 | .tween("pathAnim", () => t => this.setState({ pathAnim: interp(t) }));
1079 | } else {
1080 | const interp = d3interpolate.interpolate(pathAnim, path.length);
1081 | const dist = path.length - pathAnim;
1082 | const duration = dist * durationPerLevel;
1083 | d3transition
1084 | .transition("expand-all")
1085 | .ease(d3ease.easeLinear)
1086 | .duration(duration)
1087 | .tween("pathAnim", () => t => this.setState({ pathAnim: interp(t) }));
1088 | }
1089 | }
1090 | };
1091 | getCssCursor = cursor => {
1092 | if (cursor === "grabbing") {
1093 | cursor = "-webkit-grabbing";
1094 | }
1095 | return cursor;
1096 | };
1097 | render() {
1098 | const { width, height, cursor } = this.state;
1099 | const { pixelRatio } = this.ds;
1100 | return (
1101 |