├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── config-overrides.js ├── demo.gif ├── demo.png ├── design ├── logo.png └── logo.psd ├── package.json ├── public ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.js ├── App.test.js ├── data │ └── data.js ├── index.css ├── index.js ├── libroot.js ├── logo.svg ├── reportWebVitals.js ├── setupTests.js └── treemap │ ├── TreeBox.js │ ├── canvas.js │ ├── interaction.js │ ├── layout.js │ ├── paint.js │ ├── transition.js │ └── viewport.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | .eslintcache 27 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | design 4 | public -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ke Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # treebox 2 | 3 | ![](https://raw.githubusercontent.com/KevinWang15/treebox/master/design/logo.png) 4 | 5 | Treebox is an interactive TreeMap visualization 6 | 7 | - weight-aware multi-level hierarchical treemap layout 8 | - click on a block to zoom in / "esc" to zoom out 9 | - smooth transition 10 | - uses canvas & requestAnimationFrame for performance 11 | - customize text / color / weight 12 | - fires events (so you can implement tooltip, etc.) 13 | - no dependency (5kb gzipped) 14 | - MIT license 15 | 16 | # DEMO 17 | 18 | ![](https://raw.githubusercontent.com/KevinWang15/treebox/master/demo.png) 19 | 20 | ![](https://raw.githubusercontent.com/KevinWang15/treebox/master/demo.gif) 21 | 22 | # try it 23 | 24 | ```bash 25 | git clone https://github.com/KevinWang15/treebox 26 | cd treebox 27 | yarn install 28 | yarn start 29 | ``` 30 | 31 | # use it 32 | 33 | ```bash 34 | npm i @kevinwang15/treebox 35 | ``` 36 | 37 | ```javascript 38 | export function genData(layers = 4) { 39 | const result = []; 40 | 41 | for (let i = 0; i < 7; i++) { 42 | const children = layers - 1 > 0 ? genData(layers - 1) : null; 43 | result.push({ 44 | text: `${layers}-${i}`, 45 | color: ({ ctx, hovering, item, bounds }) => "red", 46 | children, 47 | weight: children ? null : Math.floor(10 * (1 + 2 * Math.random())), 48 | }); 49 | } 50 | 51 | return result; 52 | } 53 | ``` 54 | 55 | ```javascript 56 | import TreeBox from "@kevinwang15/treebox"; 57 | 58 | const pixelRatio = 2; 59 | 60 |
{ 62 | const treebox = new TreeBox({ 63 | pixelRatio, 64 | data: genData(), 65 | domElement, 66 | eventHandler: console.log, 67 | }); 68 | 69 | window.addEventListener("resize", () => { 70 | treebox.repaint(); 71 | }); 72 | 73 | document.addEventListener("keydown", (e) => { 74 | if (e.key === "Escape") { 75 | treebox.zoomOut(); 76 | } 77 | }); 78 | }} 79 | />; 80 | ``` 81 | 82 | # Roadmap 83 | 84 | - more customization options 85 | - github.io page 86 | - automated testing 87 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 2 | const path = require("path"); 3 | module.exports = function override(config, env) { 4 | // config.plugins.push(new BundleAnalyzerPlugin()); 5 | if (process.env.BUILD_TARGET === "lib") { 6 | config.entry = [ 7 | path.resolve(process.cwd(), "src", "libroot.js") 8 | ] 9 | 10 | config.output = { 11 | path: config.output.path, 12 | filename: 'treebox.js', 13 | library: "treebox", 14 | libraryTarget: "umd", 15 | } 16 | 17 | delete config.optimization["splitChunks"]; 18 | delete config.optimization["runtimeChunk"]; 19 | config.externals = ["react", "react-dom", "color"]; 20 | config.plugins = config.plugins.filter(p => ["HtmlWebpackPlugin", "GenerateSW", "ManifestPlugin"].indexOf(p.constructor.name) < 0) 21 | config.plugins.filter(x => x.options && x.options.filename === 'static/css/[name].[contenthash:8].css').forEach(plugin => plugin.options.filename = "static/css/[name].css") 22 | } 23 | return config; 24 | } -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinWang15/treebox/e26dfb9332aa5f9d38ebbef4097bd41e7fc55f3a/demo.gif -------------------------------------------------------------------------------- /demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinWang15/treebox/e26dfb9332aa5f9d38ebbef4097bd41e7fc55f3a/demo.png -------------------------------------------------------------------------------- /design/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinWang15/treebox/e26dfb9332aa5f9d38ebbef4097bd41e7fc55f3a/design/logo.png -------------------------------------------------------------------------------- /design/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinWang15/treebox/e26dfb9332aa5f9d38ebbef4097bd41e7fc55f3a/design/logo.psd -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kevinwang15/treebox", 3 | "version": "0.1.2", 4 | "main": "./build/treebox.js", 5 | "dependencies": { 6 | "lodash-es": "^4.17.20", 7 | "react": "^17.0.1", 8 | "react-dom": "^17.0.1", 9 | "sass": "^1.49.7" 10 | }, 11 | "description": "Treebox is an interactive TreeMap visualization", 12 | "keywords": [ 13 | "treemap", 14 | "visualization", 15 | "canvas", 16 | "data" 17 | ], 18 | "scripts": { 19 | "build-lib": "BUILD_TARGET=lib react-app-rewired build", 20 | "start": "react-app-rewired start", 21 | "build": "react-app-rewired build", 22 | "test": "react-app-rewired test", 23 | "eject": "react-app-rewired eject", 24 | "prepublish": "npm run build-lib" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "@testing-library/jest-dom": "^5.11.4", 34 | "@testing-library/react": "^12.1.0", 35 | "@testing-library/user-event": "^13.2.1", 36 | "prettier": "^2.2.1", 37 | "pretty-quick": "^3.1.0", 38 | "react-app-rewired": "^2.1.11", 39 | "react-scripts": "^4.0.3", 40 | "web-vitals": "^2.1.0", 41 | "webpack-bundle-analyzer": "^4.3.0" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | }, 55 | "license": "MIT", 56 | "repository": { 57 | "url": "https://github.com/KevinWang15/treebox" 58 | }, 59 | "husky": { 60 | "hooks": { 61 | "pre-commit": "pretty-quick --staged" 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 15 | 16 | 25 | treebox 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import "./App.css"; 2 | import React from "react"; 3 | import TreeBox from "./treemap/TreeBox"; 4 | import { genData } from "./data/data"; 5 | 6 | class App extends React.Component { 7 | componentDidMount() {} 8 | 9 | render() { 10 | const pixelRatio = 2; 11 | 12 | return ( 13 |
25 |
{ 31 | if (!domElement) { 32 | return; 33 | } 34 | const treebox = new TreeBox({ 35 | pixelRatio, 36 | data: genData(), 37 | domElement, 38 | eventHandler: console.log, 39 | }); 40 | 41 | window.treebox = treebox; 42 | 43 | window.addEventListener("resize", () => { 44 | treebox.repaint(); 45 | }); 46 | document.addEventListener("keydown", (e) => { 47 | if (e.key === "Escape") { 48 | treebox.zoomOut(); 49 | } 50 | }); 51 | }} 52 | /> 53 |
54 | ); 55 | } 56 | } 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import App from "./App"; 3 | 4 | test("renders learn react link", () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/data/data.js: -------------------------------------------------------------------------------- 1 | function genColor() { 2 | const colors = [ 3 | "#00B3A4", 4 | "#3185FC", 5 | "#DB1374", 6 | "#490092", 7 | "#FEB6DB", 8 | "#F98510", 9 | "#E6C220", 10 | "#BFA180", 11 | "#920000", 12 | "#461A0A", 13 | ]; 14 | return colors[Math.floor(Math.random() * colors.length)]; 15 | } 16 | 17 | export function genData(layers = 4) { 18 | const result = []; 19 | 20 | for (let i = 0; i < 7; i++) { 21 | const children = layers - 1 > 0 ? genData(layers - 1) : null; 22 | const c = genColor(); 23 | result.push({ 24 | text: `${layers}-${i}`, 25 | color: ({ ctx, item, bounds }) => { 26 | const gradient = ctx.createLinearGradient(0, bounds.y1, 0, bounds.y0); 27 | gradient.addColorStop(0, c + "FF"); 28 | gradient.addColorStop(1, c + "AA"); 29 | return gradient; 30 | }, 31 | children, 32 | weight: children ? null : Math.floor(10 * (1 + 2 * Math.random())), 33 | }); 34 | } 35 | 36 | return result; 37 | } 38 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /src/libroot.js: -------------------------------------------------------------------------------- 1 | import TreeBox from "./treemap/TreeBox"; 2 | 3 | export default TreeBox; 4 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = (onPerfEntry) => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /src/treemap/TreeBox.js: -------------------------------------------------------------------------------- 1 | import { reverseViewportTransform, viewportTransform } from "./viewport"; 2 | import { clearAll, clearRect, fillRect, fillText } from "./canvas"; 3 | import { 4 | onClickEventListener, 5 | onMouseDownEventListener, 6 | onMouseMove, 7 | onMouseUpEventListener, 8 | onMouseWheelEventListener, 9 | } from "./interaction"; 10 | import { layoutLayer } from "./layout"; 11 | import { transitionTo, undoZoomOut, zoomIn, zoomOut } from "./transition"; 12 | import { clearRectAndPaintLayer, paintLayer, repaint } from "./paint"; 13 | import { throttle } from "lodash-es"; 14 | 15 | export default class TreeBox { 16 | // members 17 | pixelRatio = 1; 18 | 19 | domElement; 20 | canvasElement; 21 | selectionAreaElement; 22 | canvas2dContext; 23 | 24 | viewport = { x0: 0, x1: 0, y0: 0, y1: 0 }; 25 | viewportHistory = []; 26 | viewportHistoryUndoStack = []; 27 | 28 | // root node of user input 29 | rootNode; 30 | 31 | // the node that is zoomed-in on 32 | activeNode; 33 | 34 | // if in a transition, which node are we transitioning to 35 | transitionTargetNode = null; 36 | viewportTransitionInProgress = false; 37 | 38 | // painting the nodes 39 | paintLayer = paintLayer.bind(this); 40 | clearRectAndPaintLayer = clearRectAndPaintLayer.bind(this); 41 | repaint = repaint.bind(this); 42 | 43 | // interactions 44 | onMouseMove = onMouseMove.bind(this); 45 | onClickEventListener = onClickEventListener.bind(this); 46 | onMouseDownEventListener = onMouseDownEventListener.bind(this); 47 | onMouseUpEventListener = onMouseUpEventListener.bind(this); 48 | onMouseWheelEventListener = onMouseWheelEventListener.bind(this); 49 | 50 | // transitions 51 | transitionTo = transitionTo.bind(this); 52 | zoomIn = zoomIn.bind(this); 53 | zoomOut = zoomOut.bind(this); 54 | zoomOutThrottled = throttle(this.zoomOut, 350, { 55 | leading: true, 56 | trailing: false, 57 | }); 58 | undoZoomOut = undoZoomOut.bind(this); 59 | undoZoomOutThrottled = throttle(this.undoZoomOut, 350, { 60 | leading: true, 61 | trailing: false, 62 | }); 63 | 64 | // canvas utils 65 | canvasUtils = { 66 | fillText: fillText.bind(this), 67 | fillRect: fillRect.bind(this), 68 | clearAll: clearAll.bind(this), 69 | clearRect: clearRect.bind(this), 70 | }; 71 | 72 | // viewport utils 73 | viewportUtils = { 74 | transform: viewportTransform.bind(this), 75 | reverseTransform: reverseViewportTransform.bind(this), 76 | }; 77 | 78 | // pixels between boxes 79 | BOX_MARGIN = 1; 80 | 81 | // how many pixels moved before drawing a selection area 82 | SELECTION_AREA_TRIGGER_THRESHOLD = 20; 83 | 84 | constructor({ data, domElement, eventHandler, pixelRatio = 1 }) { 85 | this.pixelRatio = pixelRatio; 86 | this.eventHandler = eventHandler; 87 | this.domElement = domElement; 88 | this.canvasElement = this.createCanvasElement(domElement); 89 | this.selectionAreaElement = this.createSelectionAreaElement(); 90 | 91 | this.canvasElement.style.zoom = 1 / this.pixelRatio; 92 | this.rootNode = { 93 | children: data, 94 | x0: 0, 95 | y0: 0, 96 | x1: this.domElement.clientWidth, 97 | y1: this.domElement.clientHeight, 98 | }; 99 | this.activeNode = this.rootNode; 100 | this.canvas2dContext = this.canvasElement.getContext("2d"); 101 | 102 | Object.assign(this.viewport, { 103 | x0: 0, 104 | y0: 0, 105 | x1: this.domElement.clientWidth, 106 | y1: this.domElement.clientHeight, 107 | }); 108 | layoutLayer(this.activeNode.children, { 109 | ...this.viewport, 110 | depth: 0, 111 | }); 112 | 113 | this.paintLayer(this.activeNode.children, { hovering: false, depth: 0 }); 114 | this.addEventListeners(); 115 | } 116 | 117 | destroy() { 118 | this.removeEventListeners(); 119 | this.canvasUtils.clearAll(); 120 | this.selectionAreaElement.parentElement.removeChild( 121 | this.selectionAreaElement 122 | ); 123 | this.canvasElement.parentElement.removeChild(this.canvasElement); 124 | this.domElement = null; 125 | this.canvasElement = null; 126 | this.canvas2dContext = null; 127 | this.viewportHistory = null; 128 | this.viewport = null; 129 | } 130 | 131 | onMouseMoveEventListener = (e) => { 132 | let x = e.pageX - this.domElementRect.left; 133 | let y = e.pageY - this.domElementRect.top; 134 | this.onMouseMove({ x, y }); 135 | this.lastMousePos = { 136 | x, 137 | y, 138 | }; 139 | }; 140 | 141 | addEventListeners() { 142 | document.addEventListener("mousemove", this.onMouseMoveEventListener); 143 | document.addEventListener("mousedown", this.onMouseDownEventListener); 144 | document.addEventListener("mouseup", this.onMouseUpEventListener); 145 | document.addEventListener("wheel", this.onMouseWheelEventListener); 146 | this.canvasElement.addEventListener("click", this.onClickEventListener); 147 | } 148 | 149 | removeEventListeners() { 150 | document.removeEventListener("mousemove", this.onMouseMoveEventListener); 151 | document.removeEventListener("mousedown", this.onMouseDownEventListener); 152 | document.removeEventListener("mouseup", this.onMouseUpEventListener); 153 | document.removeEventListener("wheel", this.onMouseWheelEventListener); 154 | this.canvasElement.removeEventListener("click", this.onClickEventListener); 155 | } 156 | 157 | emitEvent(type, args) { 158 | if (!this.eventHandler) { 159 | return; 160 | } 161 | 162 | this.eventHandler(type, args); 163 | } 164 | 165 | createCanvasElement(domElement) { 166 | this.domElementRect = domElement.getBoundingClientRect(); 167 | const canvas = document.createElement("CANVAS"); 168 | canvas.width = this.domElementRect.width * this.pixelRatio; 169 | canvas.height = this.domElementRect.height * this.pixelRatio; 170 | domElement.appendChild(canvas); 171 | return canvas; 172 | } 173 | 174 | createSelectionAreaElement() { 175 | const element = document.createElement("div"); 176 | Object.assign(element.style, { 177 | pointerEvents: "none", 178 | border: "1px solid rgba(98, 155, 255, 0.81)", 179 | borderRadius: "5px", 180 | boxSizing: "border-box", 181 | background: "rgba(46, 115, 252, 0.11)", 182 | backdropFilter: "sepia(70%)", 183 | position: "fixed", 184 | }); 185 | document.body.appendChild(element); 186 | return element; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/treemap/canvas.js: -------------------------------------------------------------------------------- 1 | import { viewportTransform } from "./viewport"; 2 | 3 | export function fillText(text, bounds, fontSize, fillStyle = "white") { 4 | this.canvas2dContext.save(); 5 | this.canvas2dContext.beginPath(); 6 | this.canvas2dContext.rect( 7 | bounds.x0 + this.BOX_MARGIN, 8 | bounds.y0 + this.BOX_MARGIN, 9 | bounds.x1 - bounds.x0 - this.BOX_MARGIN * 2, 10 | bounds.y1 - bounds.y0 - this.BOX_MARGIN * 2 11 | ); 12 | this.canvas2dContext.clip(); 13 | 14 | this.canvas2dContext.font = fontSize + "px sans-serif"; 15 | this.canvas2dContext.fillStyle = fillStyle; 16 | this.canvas2dContext.textAlign = "center"; 17 | this.canvas2dContext.textBaseline = "middle"; 18 | 19 | const maxWidth = bounds.x1 - bounds.x0 - this.BOX_MARGIN * 2; 20 | const centerX = (bounds.x0 + bounds.x1) / 2; 21 | const centerY = (bounds.y0 + bounds.y1) / 2; 22 | const lineHeight = fontSize * 1.2; 23 | 24 | const words = text.split(' '); 25 | let line = ''; 26 | let lines = []; 27 | 28 | for (let n = 0; n < words.length; n++) { 29 | const testLine = line + words[n] + ' '; 30 | const metrics = this.canvas2dContext.measureText(testLine); 31 | const testWidth = metrics.width; 32 | 33 | if (testWidth > maxWidth && n > 0) { 34 | lines.push(line.trim()); 35 | line = words[n] + ' '; 36 | } else { 37 | line = testLine; 38 | } 39 | } 40 | lines.push(line.trim()); 41 | 42 | const totalHeight = lines.length * lineHeight; 43 | let startY = centerY - (totalHeight / 2) + (lineHeight / 2); 44 | 45 | lines.forEach((line, index) => { 46 | this.canvas2dContext.fillText( 47 | line, 48 | centerX, 49 | startY + (index * lineHeight) 50 | ); 51 | }); 52 | 53 | this.canvas2dContext.restore(); 54 | } 55 | 56 | export function clearRect(x0, y0, w, h) { 57 | const x1 = x0 + w; 58 | const y1 = y0 + h; 59 | let transformed = viewportTransform.call(this, { x0, y0, x1, y1 }); 60 | this.canvas2dContext.clearRect( 61 | transformed.x0, 62 | transformed.y0, 63 | transformed.x1 - transformed.x0, 64 | transformed.y1 - transformed.y0 65 | ); 66 | } 67 | 68 | export function clearAll() { 69 | this.canvasUtils.clearRect( 70 | 0, 71 | 0, 72 | this.domElement.clientWidth, 73 | this.domElement.clientHeight 74 | ); 75 | } 76 | 77 | export function fillRect(x0, y0, w, h, { color }) { 78 | const x1 = x0 + w; 79 | const y1 = y0 + h; 80 | let transformed = viewportTransform.call(this, { x0, y0, x1, y1 }); 81 | this.canvas2dContext.fillStyle = color; 82 | this.canvas2dContext.fillRect( 83 | transformed.x0 + this.BOX_MARGIN, 84 | transformed.y0 + this.BOX_MARGIN, 85 | transformed.x1 - transformed.x0 - this.BOX_MARGIN * 2, 86 | transformed.y1 - transformed.y0 - this.BOX_MARGIN * 2 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/treemap/interaction.js: -------------------------------------------------------------------------------- 1 | import { normalizeViewport } from "./viewport"; 2 | 3 | function limitTo(value, min, max) { 4 | if (value < min) { 5 | return min; 6 | } 7 | if (value > max) { 8 | return max; 9 | } 10 | return value; 11 | } 12 | 13 | export function onMouseMove({ x, y }) { 14 | const transformed = this.viewportUtils.reverseTransform({ 15 | x0: x, 16 | y0: y, 17 | x1: x, 18 | y1: y, 19 | }); 20 | 21 | const tx = transformed.x0; 22 | const ty = transformed.y0; 23 | for (const e of this.activeNode.children || []) { 24 | if (e.x0 < tx && e.x1 > tx && e.y0 < ty && e.y1 > ty) { 25 | if (!(this.lastHoveringItem && this.lastHoveringItem === e)) { 26 | if (this.lastHoveringItem) { 27 | this.clearRectAndPaintLayer(this.lastHoveringItem, { 28 | hovering: false, 29 | depth: 0, 30 | }); 31 | } 32 | this.lastHoveringItem = e; 33 | this.clearRectAndPaintLayer(e, { hovering: true, depth: 0 }); 34 | this.emitEvent("hover", e); 35 | break; 36 | } 37 | } 38 | } 39 | 40 | if ( 41 | this.isMouseDown && 42 | this.lastMouseDownPos && 43 | selectionAreaTriggered.call(this, { x, y }) 44 | ) { 45 | let x0 = limitTo(x, 0, this.domElementRect.width); 46 | let x1 = limitTo(this.lastMouseDownPos.x, 0, this.domElementRect.width); 47 | let y0 = limitTo(y, 0, this.domElementRect.height); 48 | let y1 = limitTo(this.lastMouseDownPos.y, 0, this.domElementRect.height); 49 | this.selectionAreaElement.style.display = "block"; 50 | this.selectionAreaElement.style.top = 51 | Math.min(y0, y1) + this.domElementRect.top + "px"; 52 | this.selectionAreaElement.style.left = 53 | Math.min(x0, x1) + this.domElementRect.left + "px"; 54 | this.selectionAreaElement.style.width = Math.abs(x0 - x1) + "px"; 55 | this.selectionAreaElement.style.height = Math.abs(y0 - y1) + "px"; 56 | 57 | if (Math.abs(x0 - x1) * Math.abs(y0 - y1) < 400) { 58 | // ignore small selections 59 | this.selectionAreaViewPort = null; 60 | } else { 61 | this.selectionAreaViewPort = this.viewportUtils.reverseTransform( 62 | normalizeViewport({ 63 | x0, 64 | y0, 65 | x1, 66 | y1, 67 | }) 68 | ); 69 | } 70 | } 71 | } 72 | 73 | export function onClickEventListener(e) { 74 | let x = e.pageX - this.domElementRect.left; 75 | let y = e.pageY - this.domElementRect.top; 76 | 77 | if (this.lastMouseDownPos && selectionAreaTriggered.call(this, { x, y })) { 78 | return; 79 | } 80 | if (this.viewportTransitionInProgress) { 81 | return; 82 | } 83 | if (this.transitionTargetNode) { 84 | return; 85 | } 86 | if (!this.lastHoveringItem.children || !this.lastHoveringItem.children.length) { 87 | return; 88 | } 89 | if (this.lastHoveringItem) { 90 | this.zoomIn(this.lastHoveringItem); 91 | } 92 | } 93 | 94 | export function onMouseDownEventListener(e) { 95 | this.isMouseDown = true; 96 | this.lastMouseDownPos = { 97 | x: e.pageX - this.domElementRect.left, 98 | y: e.pageY - this.domElementRect.top, 99 | }; 100 | } 101 | 102 | export function onMouseUpEventListener(e) { 103 | this.isMouseDown = false; 104 | this.selectionAreaElement.style.display = "none"; 105 | if (this.selectionAreaViewPort) { 106 | this.viewportHistory.push({ 107 | node: this.activeNode, 108 | viewport: this.selectionAreaViewPort, 109 | }); 110 | this.transitionTo(this.selectionAreaViewPort).then(() => { 111 | this.repaint(); 112 | }); 113 | this.selectionAreaViewPort = null; 114 | } 115 | } 116 | 117 | export function onMouseWheelEventListener(e) { 118 | if (e.deltaY < -20) { 119 | this.zoomOutThrottled(); 120 | } else if (e.deltaY > 20) { 121 | this.undoZoomOutThrottled(); 122 | } 123 | } 124 | 125 | export function selectionAreaTriggered({ x, y }) { 126 | return ( 127 | Math.abs(x - this.lastMouseDownPos.x) + 128 | Math.abs(y - this.lastMouseDownPos.y) > 129 | this.SELECTION_AREA_TRIGGER_THRESHOLD 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/treemap/layout.js: -------------------------------------------------------------------------------- 1 | export function layoutLayer(data, { x0, x1, y0, y1, depth }) { 2 | for (let item of data) { 3 | if (!item.weight) { 4 | item.weight = calculateWeight(item); 5 | } 6 | } 7 | 8 | if (data.length === 1) { 9 | const item = data[0]; 10 | item.x0 = x0; 11 | item.x1 = x1; 12 | item.y0 = y0; 13 | item.y1 = y1; 14 | item.w = x1 - x0; 15 | item.layoutOk = true; 16 | 17 | if (item.children && item.children.length) { 18 | layoutLayer(item.children, { 19 | x0: x0, 20 | x1: x1, 21 | y0: y0, 22 | y1: y1, 23 | depth: depth + 1, 24 | }); 25 | } 26 | return; 27 | } 28 | const [group1, group2] = divideIntoTwoGroups(data); 29 | 30 | const width = x1 - x0; 31 | const height = y1 - y0; 32 | 33 | if (width > height) { 34 | //left-right 35 | const width = x1 - x0; 36 | const g1width = Math.round( 37 | (width * calcTotalWeight(group1)) / calcTotalWeight(data) 38 | ); 39 | layoutLayer(group1, { x0, x1: x0 + g1width, y0, y1, depth }); 40 | layoutLayer(group2, { x0: x0 + g1width, x1, y0, y1, depth }); 41 | } else { 42 | //top-bottom 43 | const height = y1 - y0; 44 | const g1height = Math.round( 45 | (height * calcTotalWeight(group1)) / calcTotalWeight(data) 46 | ); 47 | layoutLayer(group1, { x0, x1, y0, y1: y0 + g1height, depth }); 48 | layoutLayer(group2, { x0, x1, y0: y0 + g1height, y1, depth }); 49 | } 50 | } 51 | 52 | function divideIntoTwoGroups(data) { 53 | const targetWeightForGroup1 = calcTotalWeight(data) / 2; 54 | const group1 = []; 55 | const group2 = []; 56 | let currentWright = 0; 57 | const array = data.sort((x, y) => { 58 | return y.weight - x.weight; 59 | }); 60 | for (let item of array) { 61 | if (currentWright < targetWeightForGroup1) { 62 | group1.push(item); 63 | } else { 64 | group2.push(item); 65 | } 66 | currentWright += item.weight; 67 | } 68 | if (group1.length === 0) { 69 | group1.push(group2.shift()); 70 | } else if (group2.length === 0) { 71 | group2.push(group1.shift()); 72 | } 73 | return [group1, group2]; 74 | } 75 | 76 | function calcTotalWeight(data) { 77 | let result = 0; 78 | for (let item of data) { 79 | result += item.weight; 80 | } 81 | return result; 82 | } 83 | 84 | function calculateWeight(item) { 85 | if (item.weight) { 86 | return item.weight; 87 | } 88 | 89 | let w = 0; 90 | for (let child of item.children) { 91 | w += calculateWeight(child); 92 | } 93 | return w; 94 | } 95 | -------------------------------------------------------------------------------- /src/treemap/paint.js: -------------------------------------------------------------------------------- 1 | export function clearRectAndPaintLayer(e, p) { 2 | this.canvasUtils.clearRect(e.x0, e.y0, e.x1 - e.x0, e.y1 - e.y0); 3 | this.paintLayer([e], p); 4 | } 5 | 6 | const autoGeneratedColorCache = {}; 7 | 8 | function genColor() { 9 | const colors = [ 10 | "#00B3A4", 11 | "#3185FC", 12 | "#DB1374", 13 | "#490092", 14 | "#FEB6DB", 15 | "#F98510", 16 | "#E6C220", 17 | "#BFA180", 18 | "#920000", 19 | "#461A0A", 20 | ]; 21 | return colors[Math.floor(Math.random() * colors.length)]; 22 | } 23 | 24 | function autoGenerateColor(text) { 25 | const c = genColor(); 26 | 27 | if (!autoGeneratedColorCache[text]) { 28 | autoGeneratedColorCache[text] = ({ ctx, item, bounds }) => { 29 | if ((bounds.y0 || bounds.y0 === 0) && (bounds.y1 || bounds.y1 === 0)) { 30 | const gradient = ctx.createLinearGradient(0, bounds.y1, 0, bounds.y0); 31 | gradient.addColorStop(0, c + "FF"); 32 | gradient.addColorStop(1, c + "AA"); 33 | return gradient; 34 | } else { 35 | return c; 36 | } 37 | }; 38 | } 39 | return autoGeneratedColorCache[text]; 40 | } 41 | 42 | /** 43 | * low-level api to actually draw to the canvas. 44 | * will be called multiple times during a transition 45 | */ 46 | export function paintLayer(data, { hovering, transitionProgress = 0, depth }) { 47 | if (!data || depth > 2) { 48 | return; 49 | } 50 | 51 | for (let item of data) { 52 | let bounds = this.viewportUtils.transform({ 53 | x0: item.x0, 54 | y0: item.y0, 55 | x1: item.x1, 56 | y1: item.y1, 57 | }); 58 | 59 | const itemColor = (item.color || autoGenerateColor(item.text))({ 60 | hovering, 61 | ctx: this.canvas2dContext, 62 | bounds, 63 | item, 64 | }); 65 | 66 | let fontSize = Math.min(Math.round((bounds.x1 - bounds.x0) / 10), 160); 67 | 68 | const doPaintNode = () => { 69 | try { 70 | if (hovering) { 71 | this.canvas2dContext.save(); 72 | this.canvas2dContext.filter = "brightness(110%) saturate(120%)"; 73 | this.canvas2dContext.globalAlpha = 0.6; 74 | } 75 | this.canvasUtils.fillRect( 76 | item.x0, 77 | item.y0, 78 | item.x1 - item.x0, 79 | item.y1 - item.y0, 80 | { 81 | color: itemColor, 82 | } 83 | ); 84 | 85 | if (hovering) { 86 | this.canvas2dContext.restore(); 87 | } 88 | } catch (e) { 89 | console.warn(e); 90 | } 91 | 92 | if (depth <= 2) { 93 | this.canvasUtils.fillText(item.text, bounds, fontSize); 94 | } 95 | }; 96 | 97 | if (item.children) { 98 | this.paintLayer(item.children, { 99 | hovering, 100 | transitionProgress, 101 | depth: depth + 1, 102 | }); 103 | if (this.transitionTargetNode === item) { 104 | this.canvas2dContext.save(); 105 | this.canvas2dContext.globalAlpha = 1 - transitionProgress; 106 | this.canvasUtils.fillRect( 107 | item.x0, 108 | item.y0, 109 | item.x1 - item.x0, 110 | item.y1 - item.y0, 111 | { 112 | color: itemColor, 113 | } 114 | ); 115 | if (depth <= 2) { 116 | this.canvasUtils.fillText(item.text, bounds, fontSize, "#FFFFFF"); 117 | } 118 | this.canvas2dContext.restore(); 119 | } else { 120 | if (this.activeNode !== item) { 121 | doPaintNode(); 122 | } 123 | } 124 | } else { 125 | this.canvasUtils.clearRect( 126 | item.x0, 127 | item.y0, 128 | item.x1 - item.x0, 129 | item.y1 - item.y0 130 | ); 131 | doPaintNode(); 132 | } 133 | } 134 | } 135 | 136 | export function repaint() { 137 | this.domElementRect = this.domElement.getBoundingClientRect(); 138 | this.canvasElement.width = this.domElementRect.width * this.pixelRatio; 139 | this.canvasElement.height = this.domElementRect.height * this.pixelRatio; 140 | 141 | this.clearRectAndPaintLayer(this.activeNode, { hovering: false, depth: -1 }); 142 | } 143 | -------------------------------------------------------------------------------- /src/treemap/transition.js: -------------------------------------------------------------------------------- 1 | import { calcTransitioningViewport } from "./viewport"; 2 | 3 | export function transitionTo(viewport) { 4 | if (this.viewportTransitionInProgress) { 5 | return Promise.reject("viewportTransition in progress"); 6 | } 7 | this.viewportTransitionInProgress = true; 8 | return new Promise((resolve) => { 9 | const transitionStart = +new Date(); 10 | const transitionLength = 200; 11 | const pristineViewport = { ...this.viewport }; 12 | 13 | let onAnimationFrame = () => { 14 | let progress = (+new Date() - transitionStart) / transitionLength; 15 | if (progress > 1) { 16 | progress = 1; 17 | } 18 | Object.assign( 19 | this.viewport, 20 | calcTransitioningViewport(pristineViewport, viewport, progress) 21 | ); 22 | this.canvasUtils.clearAll(); 23 | this.paintLayer(this.activeNode.children, { 24 | hovering: false, 25 | transitionProgress: progress, 26 | depth: 0, 27 | }); 28 | 29 | if (progress < 1) { 30 | requestAnimationFrame(onAnimationFrame); 31 | } else { 32 | resolve(); 33 | } 34 | }; 35 | requestAnimationFrame(onAnimationFrame); 36 | }).finally(() => { 37 | this.viewportTransitionInProgress = false; 38 | }); 39 | } 40 | 41 | export function zoomIn(targetNode) { 42 | targetNode.parent = this.activeNode; 43 | this.transitionTargetNode = targetNode; 44 | let nodeAndViewport = { 45 | node: targetNode, 46 | viewport: targetNode, 47 | }; 48 | this.viewportHistory.push(nodeAndViewport); 49 | this.viewportHistoryUndoStack.splice(0); 50 | 51 | this.transitionTo(targetNode, {}).then(() => { 52 | this.activeNode = targetNode; 53 | this.transitionTargetNode = null; 54 | this.repaint(); 55 | setTimeout(() => { 56 | this.onMouseMove({ x: this.lastMousePos.x, y: this.lastMousePos.y }); 57 | }); 58 | }); 59 | } 60 | 61 | export function zoomOut() { 62 | if (this.viewportTransitionInProgress) { 63 | return; 64 | } 65 | let popped = this.viewportHistory.pop(); 66 | if (popped) { 67 | this.viewportHistoryUndoStack.push(popped); 68 | } 69 | let lastNodeAndViewport = this.viewportHistory[ 70 | this.viewportHistory.length - 1 71 | ]; 72 | if (!lastNodeAndViewport) { 73 | lastNodeAndViewport = { node: this.rootNode, viewport: this.rootNode }; 74 | } 75 | this.activeNode = lastNodeAndViewport.node; 76 | 77 | this.transitionTargetNode = this.activeNode; 78 | this.transitionTo(lastNodeAndViewport.viewport).then(() => { 79 | this.transitionTargetNode = null; 80 | this.lastHoveringItem = null; 81 | this.repaint(); 82 | setTimeout(() => { 83 | this.onMouseMove({ x: this.lastMousePos.x, y: this.lastMousePos.y }); 84 | }); 85 | }); 86 | } 87 | 88 | export function undoZoomOut() { 89 | if (this.viewportTransitionInProgress) { 90 | return; 91 | } 92 | if (!this.viewportHistoryUndoStack.length) { 93 | return; 94 | } 95 | 96 | let lastNodeAndViewport = this.viewportHistoryUndoStack.pop(); 97 | if (!lastNodeAndViewport) { 98 | return; 99 | } 100 | this.viewportHistory.push(lastNodeAndViewport); 101 | 102 | this.transitionTargetNode = lastNodeAndViewport.node; 103 | 104 | this.transitionTo(lastNodeAndViewport.viewport).then(() => { 105 | this.activeNode = lastNodeAndViewport.node; 106 | this.transitionTargetNode = null; 107 | this.repaint(); 108 | setTimeout(() => { 109 | this.onMouseMove({ x: this.lastMousePos.x, y: this.lastMousePos.y }); 110 | }); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /src/treemap/viewport.js: -------------------------------------------------------------------------------- 1 | export function calcTransitioningViewport( 2 | currentViewport, 3 | targetViewport, 4 | prog 5 | ) { 6 | const baseX0 = currentViewport.x0; 7 | const diffX0 = targetViewport.x0 - baseX0; 8 | const baseX1 = currentViewport.x1; 9 | const diffX1 = targetViewport.x1 - baseX1; 10 | const baseY0 = currentViewport.y0; 11 | const diffY0 = targetViewport.y0 - baseY0; 12 | const baseY1 = currentViewport.y1; 13 | const diffY1 = targetViewport.y1 - baseY1; 14 | return { 15 | x0: baseX0 + diffX0 * prog, 16 | x1: baseX1 + diffX1 * prog, 17 | y0: baseY0 + diffY0 * prog, 18 | y1: baseY1 + diffY1 * prog, 19 | }; 20 | } 21 | 22 | export function viewportTransform({ x0, y0, x1, y1 }) { 23 | const vpw = this.viewport.x1 - this.viewport.x0; 24 | const vph = this.viewport.y1 - this.viewport.y0; 25 | return { 26 | x0: ((x0 - this.viewport.x0) / vpw) * this.canvasElement.clientWidth, 27 | x1: ((x1 - this.viewport.x0) / vpw) * this.canvasElement.clientWidth, 28 | y0: ((y0 - this.viewport.y0) / vph) * this.canvasElement.clientHeight, 29 | y1: ((y1 - this.viewport.y0) / vph) * this.canvasElement.clientHeight, 30 | }; 31 | } 32 | 33 | export function reverseViewportTransform({ x0, y0, x1, y1 }) { 34 | const vpw = this.viewport.x1 - this.viewport.x0; 35 | const vph = this.viewport.y1 - this.viewport.y0; 36 | return { 37 | x0: 38 | (x0 * this.pixelRatio * vpw) / this.canvasElement.clientWidth + 39 | this.viewport.x0, 40 | x1: 41 | (x1 * this.pixelRatio * vpw) / this.canvasElement.clientWidth + 42 | this.viewport.x0, 43 | y0: 44 | (y0 * this.pixelRatio * vph) / this.canvasElement.clientHeight + 45 | this.viewport.y0, 46 | y1: 47 | (y1 * this.pixelRatio * vph) / this.canvasElement.clientHeight + 48 | this.viewport.y0, 49 | }; 50 | } 51 | 52 | export function normalizeViewport({ x0, x1, y0, y1 }) { 53 | const result = {}; 54 | if (x0 < x1) { 55 | result["x0"] = x0; 56 | result["x1"] = x1; 57 | } else { 58 | result["x0"] = x1; 59 | result["x1"] = x0; 60 | } 61 | 62 | if (y0 < y1) { 63 | result["y0"] = y0; 64 | result["y1"] = y1; 65 | } else { 66 | result["y0"] = y1; 67 | result["y1"] = y0; 68 | } 69 | return result; 70 | } 71 | --------------------------------------------------------------------------------