├── .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 | ![zoom](https://user-images.githubusercontent.com/116838/34450616-5090c8a2-ecd3-11e7-8722-f5d7f131e909.gif) 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 | PingThings 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 | { 1103 | this.canvas = node; 1104 | this.draw(node); 1105 | }} 1106 | width={width * pixelRatio} 1107 | height={height * pixelRatio} 1108 | style={{ 1109 | width: `${width}px`, 1110 | height: `${height}px`, 1111 | userSelect: "none", 1112 | cursor: this.getCssCursor(cursor) 1113 | }} 1114 | onMouseMove={this.onMouseMove} 1115 | onMouseDown={e => this.onMouseDown(e, { isDrag: false })} 1116 | onMouseUp={this.onMouseUp} 1117 | onDragStart={() => false} 1118 | /> 1119 | ); 1120 | } 1121 | } 1122 | 1123 | export default Tree; 1124 | -------------------------------------------------------------------------------- /src/datagen.js: -------------------------------------------------------------------------------- 1 | import FastSimplexNoise from "fast-simplex-noise"; 2 | import seedrandom from "seedrandom"; 3 | import * as d3array from "d3-array"; 4 | 5 | const globalCache = {}; 6 | 7 | // 10 levels of noise 8 | // TODO: create 9 | // reference: https://beta.observablehq.com/@shaunlebron/btrdb-mock-data-generator/2 10 | 11 | // level noise knobs 12 | // prettier-ignore 13 | const levelNoiseKnobs = [ 14 | {meanFrequency: 0.03, shadowFrequency: 0.05, octaves: 5, persistence: 0.5, meanHeight: 120, shadowHeight: 40}, 15 | {meanFrequency: 0.111, shadowFrequency: 0.05, octaves: 2, persistence: 0.5, meanHeight: 43, shadowHeight: 57}, 16 | {meanFrequency: 0.24, shadowFrequency: 0.049, octaves: 4, persistence: 0.5, meanHeight: 28, shadowHeight: 19}, 17 | {meanFrequency: 0.078, shadowFrequency: 0.049, octaves: 5, persistence: 0.5, meanHeight: 72, shadowHeight: 39}, 18 | {meanFrequency: 0.363, shadowFrequency: 0.042, octaves: 1, persistence: 0.5, meanHeight: 72, shadowHeight: 74}, 19 | {meanFrequency: 0.095, shadowFrequency: 0.05, octaves: 3, persistence: 0.5, meanHeight: 140, shadowHeight: 74}, 20 | {meanFrequency: 0.053, shadowFrequency: 0.026, octaves: 2, persistence: 0.5, meanHeight: 140, shadowHeight: 87}, 21 | {meanFrequency: 0.029, shadowFrequency: 0.021, octaves: 3, persistence: 0.5, meanHeight: 90, shadowHeight: 46}, 22 | {meanFrequency: 0.021, shadowFrequency: 0.016, octaves: 2, persistence: 0.5, meanHeight: 70, shadowHeight: 46}, 23 | 24 | {meanFrequency: 0.03, shadowFrequency: 0.05, octaves: 5, persistence: 0.5, meanHeight: 120, shadowHeight: 40}, 25 | ]; 26 | 27 | const meanNoise = []; 28 | const shadowNoise = []; 29 | for (let { 30 | meanFrequency, 31 | shadowFrequency, 32 | octaves, 33 | persistence, 34 | meanHeight, 35 | shadowHeight 36 | } of levelNoiseKnobs) { 37 | const min = 0; 38 | meanNoise.push( 39 | new FastSimplexNoise({ 40 | random: seedrandom("hello"), 41 | frequency: meanFrequency, 42 | max: meanHeight, 43 | persistence, 44 | octaves, 45 | min 46 | }) 47 | ); 48 | shadowNoise.push( 49 | new FastSimplexNoise({ 50 | random: seedrandom("hello"), 51 | frequency: shadowFrequency, 52 | max: shadowHeight, 53 | persistence, 54 | octaves, 55 | min 56 | }) 57 | ); 58 | } 59 | 60 | // global noise knobs 61 | const meanNoiseTime = 32; 62 | const minNoiseTime = 50; 63 | const maxNoiseTime = 80; 64 | 65 | function getNoiseXFromPath(path) { 66 | let exp = 0; 67 | let x = 0; 68 | 69 | // We can't go back further than 8 levels to compute x since floating points 70 | // can't support numbers that high. 71 | const rootI = Math.max(0, path.length - 8); 72 | 73 | for (let i = path.length - 1; i >= rootI; i--) { 74 | x += path[i] * 2 ** exp; 75 | exp += 6; 76 | } 77 | return x; 78 | } 79 | 80 | function getNoise(path) { 81 | const level = path.length - 1; 82 | const x = path.length === 1 ? path[0] : getNoiseXFromPath(path); 83 | const mean = meanNoise[level].scaled([x, meanNoiseTime]); 84 | const min = mean - shadowNoise[level].scaled([x, minNoiseTime]); 85 | const max = mean + shadowNoise[level].scaled([x, maxNoiseTime]); 86 | 87 | // assume uniform distribution of points 88 | const count = 2 ** (6 * (9 - level)); 89 | return count === 1 90 | ? { count, mean, min: mean, max: mean } 91 | : { count, mean, min, max }; 92 | } 93 | 94 | function cacheLookup(cache, path) { 95 | if (!path || !path.length) return; 96 | let curr = cache; 97 | for (let i of path) { 98 | if (!curr || !curr.children) return; 99 | curr = curr.children[i]; 100 | } 101 | return curr; 102 | } 103 | 104 | function copyStat({ min, mean, max }) { 105 | return { min, mean, max }; 106 | } 107 | 108 | function midResChildren(children, res) { 109 | if (!res) return; 110 | const numPoints = 2 ** res; 111 | const width = 64 / numPoints; 112 | const midResPoint = i => { 113 | const points = children.slice(i * width, (i + 1) * width); 114 | return { 115 | min: d3array.min(points, p => p.min), 116 | max: d3array.max(points, p => p.max), 117 | mean: d3array.sum(points, p => p.mean) / points.length 118 | }; 119 | }; 120 | return d3array.range(numPoints).map(midResPoint); 121 | } 122 | 123 | function cacheWrite(cache, path, children) { 124 | let curr = cache; 125 | for (let i of path) curr = curr.children[i]; 126 | curr.children = children.map(copyStat); 127 | curr.midResChildren = d3array 128 | .range(6) 129 | .map(res => midResChildren(children, res)); 130 | 131 | // special case: store stats in top-level node 132 | if (path.length === 0) { 133 | curr.mean = d3array.sum(children, p => p.mean) / children.length; 134 | curr.min = d3array.min(children, p => p.min); 135 | curr.max = d3array.max(children, p => p.max); 136 | } 137 | } 138 | 139 | function fitChildren(points, parent) { 140 | // Get mean-centered points. 141 | // NOTE: disregarding count currently 142 | const { count } = points[0]; 143 | 144 | const localMean = d3array.sum(points, p => p.mean) / points.length; 145 | const center = localMean; 146 | const relative = points.map(({ min, mean, max }) => ({ 147 | mean: mean - center, 148 | min: min - center, 149 | max: max - center 150 | })); 151 | 152 | // Get local relative extremes. 153 | const localRelMin = d3array.min(relative, p => p.min); 154 | const localRelMax = d3array.max(relative, p => p.max); 155 | const indexOfMin = relative.findIndex(p => p.min === localRelMin); 156 | const indexOfMax = relative.findIndex(p => p.max === localRelMax); 157 | 158 | // Get global relative extremes that we must target. 159 | const globalRelMin = parent.min - parent.mean; 160 | const globalRelMax = parent.max - parent.mean; 161 | 162 | // Get the minimum scale that we must stretch the local points such that 163 | // they are contained inside the global bounds. 164 | const minStretch = globalRelMin / localRelMin; 165 | const maxStretch = globalRelMax / localRelMax; 166 | const minFitDelta = globalRelMax - localRelMax * minStretch; 167 | const maxFitDelta = localRelMin * maxStretch - globalRelMin; 168 | const stretch = 169 | minFitDelta >= 0 170 | ? minStretch 171 | : maxFitDelta >= 0 172 | ? maxStretch 173 | : // floating point precision errors can cause slight overshoots, so choose 174 | // the one with the smallest delta. 175 | Math.abs(minFitDelta) < Math.abs(maxFitDelta) 176 | ? minStretch 177 | : maxStretch; 178 | 179 | // Fit points to target bounds as best we can w/ recentering and uniform scaling. 180 | const fitPoint = (rel, k) => ({ 181 | mean: parent.mean + rel.mean * k, 182 | min: parent.mean + rel.min * k, 183 | max: parent.mean + rel.max * k, 184 | count 185 | }); 186 | const fitPoints = relative.map(rel => fitPoint(rel, stretch)); 187 | 188 | // Since we are only guaranteed _one_ local bound is flushed against the 189 | // global bounds, we stretch the two min and max points to the bounds manually 190 | // to ensure to ensure both bounds are equal. 191 | const minPoint = fitPoints[indexOfMin]; 192 | const maxPoint = fitPoints[indexOfMax]; 193 | 194 | if (count === 1) { 195 | const setAll = (p, v) => (p.mean = p.min = p.max = v); 196 | setAll(minPoint, parent.min); 197 | setAll(maxPoint, parent.max); 198 | // TODO: we have to adjust the mean of other point(s) to correct the balance 199 | // such that all points still average to the correct amount. 200 | } else { 201 | minPoint.min = parent.min; 202 | maxPoint.max = parent.max; 203 | } 204 | 205 | return fitPoints; 206 | } 207 | 208 | // path = tree node path from root 209 | function getStatPoint(path, cache) { 210 | if (!cache) cache = globalCache; 211 | if (!path) return; 212 | if (path.length === 0) return cache; 213 | 214 | let point = cacheLookup(cache, path); 215 | if (!point) { 216 | const parentPath = path.slice(0, -1); 217 | const points = d3array.range(64).map(i => getNoise([...parentPath, i])); 218 | const parent = getStatPoint(parentPath, cache); 219 | const fitPoints = parent === cache ? points : fitChildren(points, parent); 220 | cacheWrite(cache, parentPath, fitPoints); 221 | point = cacheLookup(cache, path); 222 | } 223 | return point; 224 | } 225 | 226 | // initialize cache 227 | function initCache(cache) { 228 | getStatPoint([0], cache); 229 | } 230 | 231 | initCache(globalCache); 232 | 233 | export { getStatPoint, initCache }; 234 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | /* Allow all elements to fill page */ 2 | html, 3 | body, 4 | #root { 5 | width: 100%; 6 | min-height: 100%; 7 | height: 100%; 8 | } 9 | 10 | body { 11 | margin: 0; 12 | padding: 0; 13 | font-family: sans-serif; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | 6 | ReactDOM.render(, document.getElementById("root")); 7 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 12 | 13 | 15 | 24 | 25 | 26 | 28 | 29 | 31 | 39 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/scaleTimeNano.js: -------------------------------------------------------------------------------- 1 | import { scaleLinear, scaleTime } from "d3-scale"; 2 | import { bisector, tickStep, ticks } from "d3-array"; 3 | import { timeFormat, utcFormat } from "d3-time-format"; 4 | import { 5 | timeYear, 6 | timeMonth, 7 | timeWeek, 8 | timeDay, 9 | timeHour, 10 | timeMinute, 11 | timeSecond, 12 | timeMillisecond, 13 | utcYear, 14 | utcMonth, 15 | utcWeek, 16 | utcDay, 17 | utcHour, 18 | utcMinute, 19 | utcSecond, 20 | utcMillisecond 21 | } from "d3-time"; 22 | 23 | //---------------------------------------------------------------------------- 24 | // Date to nanosecond timestamp conversion 25 | //---------------------------------------------------------------------------- 26 | function nsToDate(ns) { 27 | return new Date(+ns / 1e6); 28 | } 29 | function dateToNs(date) { 30 | return +date * 1e6; 31 | } 32 | 33 | function calendar(BigNum, timeFuncs) { 34 | const { 35 | year, 36 | month, 37 | week, 38 | day, 39 | hour, 40 | minute, 41 | second, 42 | milli, 43 | format 44 | } = timeFuncs; 45 | 46 | //------------------------------------------------------------ 47 | // State 48 | //------------------------------------------------------------ 49 | let bigDomain; // BigNum domain 50 | let highOffset; // BigNum offset of domain for high precision numbers 51 | let mode; // precision mode (low or high) 52 | 53 | //------------------------------------------------------------ 54 | // Precision Mode 55 | //------------------------------------------------------------ 56 | const MODE_LO = "LO"; // Low precision (safe to convert BigNums to floats directly) 57 | const MODE_HI = "HI"; // High precision (use distance from left domain point for high precision) 58 | 59 | const updateMode = () => { 60 | const d = bigDomain; 61 | const [a, b] = [d[0], d[d.length - 1]]; 62 | mode = Math.abs(+a - +b) > 1e7 ? MODE_LO : MODE_HI; 63 | }; 64 | 65 | const b2f = { 66 | // BigNum -> Float 67 | [MODE_LO]: b => +b, 68 | [MODE_HI]: b => +b.minus(highOffset) 69 | }; 70 | const f2b = { 71 | // Float -> BigNum 72 | [MODE_LO]: f => BigNum(String(f)), 73 | [MODE_HI]: f => highOffset.plus(String(f)) 74 | }; 75 | 76 | //------------------------------------------------------------ 77 | // Float scale 78 | //------------------------------------------------------------ 79 | const floatScale = scaleLinear(); 80 | 81 | //------------------------------------------------------------ 82 | // BigNum scale 83 | //------------------------------------------------------------ 84 | const bigScale = x => { 85 | const bigX = BigNum(x); 86 | const floatX = b2f[mode](bigX); 87 | const y = floatScale(floatX); 88 | return y; 89 | }; 90 | bigScale.invert = y => { 91 | const floatX = floatScale.invert(y); 92 | const bigX = f2b[mode](floatX); 93 | return bigX; 94 | }; 95 | 96 | bigScale.domain = domain => { 97 | if (!domain) return bigDomain.slice(); 98 | if (domain[0] instanceof Date) { 99 | domain = domain.map(dateToNs); 100 | } 101 | bigDomain = domain.map(t => BigNum(t)); 102 | highOffset = BigNum(dateToNs(nsToDate(bigDomain[0]))); 103 | updateMode(); 104 | floatScale.domain(bigDomain.map(b2f[mode])); 105 | return bigScale; 106 | }; 107 | 108 | // inherit floatScale functions as they are 109 | const inheritFns = ["range", "rangeRound", "clamp", "interpolate"]; 110 | for (let fn of inheritFns) { 111 | bigScale[fn] = (...args) => { 112 | const result = floatScale[fn](...args); 113 | // make sure we return bigScale for chainable methods 114 | return result === floatScale ? bigScale : result; 115 | }; 116 | } 117 | 118 | //------------------------------------------------------------ 119 | // Copy 120 | //------------------------------------------------------------ 121 | bigScale.copy = function() { 122 | return calendar(BigNum, timeFuncs) 123 | .domain(bigScale.domain()) 124 | .range(bigScale.range()) 125 | .interpolate(bigScale.interpolate()) 126 | .clamp(bigScale.clamp()); 127 | }; 128 | 129 | //------------------------------------------------------------ 130 | // Tick formatter 131 | //------------------------------------------------------------ 132 | function tickFormat(ns) { 133 | // NOTE: assuming ns is an integer 134 | 135 | const sign = Math.sign(+ns); 136 | let nano = +(ns + "").slice(-3) * sign; 137 | let micro = +(ns + "").slice(-6, -3) * sign; 138 | 139 | const posMod = (t, n) => (t % n + n) % n; 140 | if (nano !== 0) return posMod(nano, 1000) + "n"; 141 | if (micro !== 0) return posMod(micro, 1000) + "u"; 142 | 143 | return scaleTime().tickFormat()(nsToDate(ns)); 144 | } 145 | 146 | bigScale.tickFormat = function(count, specifier) { 147 | // NOTE: count and specifier are currently ignored 148 | return tickFormat; 149 | }; 150 | 151 | //------------------------------------------------------------ 152 | // Ticks 153 | //------------------------------------------------------------ 154 | bigScale.ticks = function(interval, step) { 155 | if (mode === MODE_LO) { 156 | return scaleTime() 157 | .domain(bigDomain.map(nsToDate)) 158 | .ticks(interval, step) 159 | .map(dateToNs); 160 | } else if (mode === MODE_HI) { 161 | // order 162 | const d = bigScale.domain(); 163 | let [t0, t1] = [d[0], d[d.length - 1]]; 164 | const r = t1.lt(t0); 165 | if (r) [t0, t1] = [t1, t0]; 166 | 167 | // ticks 168 | let t; 169 | if (interval == null) interval = 10; 170 | if (typeof interval === "number") { 171 | t = ticks(b2f[mode](t0), b2f[mode](t1), interval).map(f2b[mode]); 172 | } else { 173 | const i = step == null ? interval : interval.every(step); 174 | const a = i.ceil(nsToDate(t0)); 175 | const b = i.floor(nsToDate(t1)); 176 | t = i.range(a, b + 1, step).map(dateToNs); 177 | } 178 | return r ? t.reverse() : t; 179 | } else { 180 | throw new Error("unknown mode"); 181 | } 182 | }; 183 | 184 | //------------------------------------------------------------ 185 | // Rounding the domain to nice values 186 | //------------------------------------------------------------ 187 | bigScale.nice = function(interval, step) { 188 | if (mode === MODE_LO) { 189 | bigScale.domain( 190 | scaleTime() 191 | .domain(bigScale.domain().map(nsToDate)) 192 | .nice(interval, step) 193 | .domain() 194 | .map(dateToNs) 195 | ); 196 | } else if (mode === MODE_HI) { 197 | if (interval == null) interval = 10; 198 | if (typeof interval === "number") { 199 | bigScale.domain( 200 | floatScale 201 | .nice(interval) 202 | .domain() 203 | .map(f2b[mode]) 204 | ); 205 | } else { 206 | const d = bigScale.domain(); 207 | let [t0, t1] = [d[0], d[d.length - 1]]; 208 | const r = t1.lt(t0); 209 | if (r) [t0, t1] = [t1, t0]; 210 | 211 | const i = step == null ? interval : interval.every(step); 212 | const a = i.floor(nsToDate(t0)); 213 | const b = i.ceil(nsToDate(t1)); 214 | bigScale.domain([a, b].map(dateToNs)); 215 | } 216 | } else { 217 | throw new Error("unknown mode"); 218 | } 219 | return bigScale; 220 | }; 221 | 222 | return bigScale; 223 | } 224 | 225 | //---------------------------------------------------------------------------- 226 | // Local or UTC time scales 227 | //---------------------------------------------------------------------------- 228 | const timeFuncs = { 229 | year: timeYear, 230 | month: timeMonth, 231 | week: timeWeek, 232 | day: timeDay, 233 | hour: timeHour, 234 | minute: timeMinute, 235 | second: timeSecond, 236 | milli: timeMillisecond, 237 | format: timeFormat 238 | }; 239 | const utcFuncs = { 240 | year: utcYear, 241 | month: utcMonth, 242 | week: utcWeek, 243 | day: utcDay, 244 | hour: utcHour, 245 | minute: utcMinute, 246 | second: utcSecond, 247 | milli: utcMillisecond, 248 | format: utcFormat 249 | }; 250 | function scaleTimeNano(BigNum) { 251 | if (!BigNum) throw new Error("please specify a BigNum"); 252 | const domain = [new Date(2000, 0, 1), new Date(2000, 0, 2)]; 253 | return calendar(BigNum, timeFuncs).domain(domain); 254 | } 255 | function scaleUtcNano(BigNum) { 256 | if (!BigNum) throw new Error("please specify a BigNum"); 257 | const domain = [Date.UTC(2000, 0, 1), Date.UTC(2000, 0, 2)]; 258 | return calendar(BigNum, utcFuncs).domain(domain); 259 | } 260 | 261 | export { scaleTimeNano, scaleUtcNano }; 262 | --------------------------------------------------------------------------------