├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── .DS_Store ├── Gaegu-Bold.ttf ├── Gaegu-Light.ttf ├── Gaegu-Regular.ttf ├── gaegu-v8-latin-regular.ttf └── indie-flower-v11-latin-regular.ttf ├── dist ├── roughviz.es.js └── roughviz.umd.js ├── jest.config.js ├── package.json ├── src ├── Bar.js ├── BarH.js ├── Chart.js ├── Donut.js ├── Force.js ├── Line.js ├── Network.js ├── Pie.js ├── Scatter.js ├── StackedBar.js ├── index.html ├── index.js └── utils │ ├── addFonts.js │ ├── addLegend.js │ ├── colors.js │ ├── roughCeiling.js │ └── saveToPng.js ├── tests ├── Bar.test.js ├── BarH.test.js ├── Donut.test.js ├── Pie.test.js ├── Scatter.test.js └── utils │ └── roughCeiling.test.js ├── vite.config.js └── website ├── index.html ├── logo.png ├── main.js ├── package.json ├── public └── vite.svg ├── roughDemo.js ├── style.css └── vite.config.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "strongloop", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true, 6 | "jest": true, 7 | "node": true 8 | }, 9 | "rules": { 10 | "max-len": ["error", { 11 | "code": 100 12 | }], 13 | "no-new": 0, 14 | "prefer-const": "error", 15 | // "quotes": ["error", "double"] 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules 3 | jest.config.js 4 | examples/ 5 | .cache 6 | .eslintrc 7 | .vscode/ 8 | package-lock.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Jared Wilber 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | roughViz.js
2 | 3 | **roughViz.js** is a reusable JavaScript library for creating sketchy/hand-drawn styled charts in the browser, based on D3v5, roughjs, and handy. 4 | 5 | 6 | roughViz.js 7 | 8 | 9 | ### Why? 10 | Use these charts where the communication goal is to show intent or generality, and not absolute precision. Or just because they're fun and look weird. 11 | 12 | 13 | ### Chart Types 14 | 15 | | Chart Type | API | 16 | | -------------- | ----------------------------------------------------- | 17 | | Bar | roughViz.Bar | 18 | | Horizontal Bar | roughViz.BarH | 19 | | Donut | roughViz.Donut | 20 | | Line | roughViz.Line | 21 | | Pie | roughViz.Pie | 22 | | Scatter | roughViz.Scatter | 23 | | Stacked Bar | roughViz.StackedBar | 24 | 25 | Visit [this link](https://observablehq.com/d/6d3209e2f7f114de) for interactive examples of each chart. 26 | 27 | ### Features 28 | 29 | Apply the features of `roughjs` to each chart: 30 | 31 | **roughness**: 32 | 33 | roughness examples 34 | 35 | fillStyle 36 | fillStyle examples 37 | 38 | 39 | **fillWeight** 40 | fillStyle examples 41 | 42 | 43 | As well as additional chart-specific options ([see API below](#API)) 44 | 45 | 46 | ### Installation 47 | 48 | Via CDN (expose the `roughViz` global in `html`): 49 | 50 | ```html 51 | 52 | ``` 53 | 54 | Via `npm`: 55 | 56 | ```sh 57 | npm install rough-viz 58 | ``` 59 | Want to use with `React`? [There's a wrapper!](https://github.com/Chris927/react-roughviz): 60 | 61 | ```sh 62 | npm install react-roughviz 63 | ``` 64 | 65 | Want to use with `Vue`? [There's a wrapper!](https://github.com/jolo-dev/vue-roughviz): 66 | 67 | ```sh 68 | npm install vue-roughviz 69 | ``` 70 | 71 | Want to use it with `Python`? [Go crazy](https://github.com/charlesdong1991/py-roughviz): 72 | 73 | ```sh 74 | pip install py-roughviz 75 | ``` 76 | 77 | 78 | ### How to use 79 | 80 | If you're using ESM, make sure to import the library: 81 | 82 | ``` 83 | import roughViz from "rough-viz"; 84 | ``` 85 | 86 | Create some container elements, one for each chart: 87 | 88 | ```html 89 | 90 |
91 |
92 | ``` 93 | In the javascript, just create charts, referencing the desired container: 94 | ```js 95 | // create Bar chart from csv file, using default options 96 | new roughViz.Bar({ 97 | element: '#viz0', // container selection 98 | data: 'https://raw.githubusercontent.com/jwilber/random_data/master/flavors.csv', 99 | labels: 'flavor', 100 | values: 'price' 101 | }); 102 | 103 | // create Donut chart using defined data & customize plot options 104 | new roughViz.Donut( 105 | { 106 | element: '#viz1', 107 | data: { 108 | labels: ['North', 'South', 'East', 'West'], 109 | values: [10, 5, 8, 3] 110 | }, 111 | title: "Regions", 112 | width: window.innerWidth / 4, 113 | roughness: 8, 114 | colors: ['red', 'orange', 'blue', 'skyblue'], 115 | stroke: 'black', 116 | strokeWidth: 3, 117 | fillStyle: 'cross-hatch', 118 | fillWeight: 3.5, 119 | } 120 | ); 121 | ``` 122 | 123 |

API

124 | 125 | ### `roughViz.Bar` 126 | Required 127 | - `element` [string]: Id or class of container element. 128 | - `data`: Data with which to construct chart. 129 | Can be either an object or string. 130 | 131 | - If object: must contain `labels` and `values` keys: 132 | 133 | ```js 134 | new roughViz.Bar({ 135 | element: '.viz', 136 | data: {labels: ['a', 'b'], values: [10, 20]} 137 | }) 138 | ``` 139 | 140 | - If string: must be a path/url to a `csv` or `tsv`, and you must also specify the `labels` and `values` as separate attributes that represent columns in said file: 141 | ```js 142 | new roughViz.Bar({ 143 | element: '#viz0', 144 | data: 'stringToDataUrl.csv', 145 | labels: 'nameOfLabelsColumn', 146 | values: 'nameOfValuesColumn', 147 | }) 148 | ``` 149 | 150 | Optional 151 | - `axisFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 152 | - `axisRoughness` [number]: Roughness for x & y axes. Default: `0.5`. 153 | - `axisStrokeWidth` [number]: Stroke-width for x & y axes. Default: `0.5`. 154 | - `bowing` [number]: Chart bowing. Default: `0`. 155 | - `color` [string]: Color for each bar. Default: `'skyblue'`. 156 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 157 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.5`. 158 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 159 | - `highlight` [string]: Color for each bar on hover. Default: `'coral'`. 160 | - `innerStrokeWidth` [number]: Stroke-width for paths inside bars. Default: `1`. 161 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 162 | - `labelFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 163 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 164 | - `padding` [number]: Padding between bars. Default: `0.1`. 165 | - `roughness` [number]: Roughness level of chart. Default: `1`. 166 | - `simplification` [number]: Chart simplification. Default `0.2`. 167 | - `stroke` [string]: Color of bars' stroke. Default: `black`. 168 | - `strokeWidth` [number]: Size of bars' stroke. Default: `1`. 169 | - `title` [string]: Chart title. Optional. 170 | - `titleFontSize` [string]: Font-size for chart title. Default: `'1rem'`. 171 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 172 | - `xLabel` [string]: Label for x-axis. 173 | - `yLabel` [string]: Label for y-axis. 174 | 175 | 176 | ### `roughViz.BarH` 177 | Required 178 | - `element` [string]: Id or class of container element. 179 | - `data`: Data with which to construct chart. 180 | Can be either an object or string. 181 | 182 | - If object: must contain `labels` and `values` keys: 183 | 184 | ```js 185 | new roughViz.BarH({ 186 | element: '.viz', 187 | data: {labels: ['a', 'b'], values: [10, 20]} 188 | }) 189 | ``` 190 | 191 | - If string: must be a path/url to a `csv` or `tsv`, and you must also specify the `labels` and `values` as separate attributes that represent columns in said file: 192 | ```js 193 | new roughViz.BarH({ 194 | element: '#viz0', 195 | data: 'stringToDataUrl.csv', 196 | labels: 'nameOfLabelsColumn', 197 | values: 'nameOfValuesColumn', 198 | }) 199 | ``` 200 | 201 | Optional 202 | - `axisFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 203 | - `axisRoughness` [number]: Roughness for x & y axes. Default: `0.5`. 204 | - `axisStrokeWidth` [number]: Stroke-width for x & y axes. Default: `0.5`. 205 | - `bowing` [number]: Chart bowing. Default: `0`. 206 | - `color` [string]: Color for each bar. Default: `'skyblue'`. 207 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 208 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.5`. 209 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 210 | - `highlight` [string]: Color for each bar on hover. Default: `'coral'`. 211 | - `innerStrokeWidth` [number]: Stroke-width for paths inside bars. Default: `1`. 212 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 213 | - `labelFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 214 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 215 | - `padding` [number]: Padding between bars. Default: `0.1`. 216 | - `roughness` [number]: Roughness level of chart. Default: `1`. 217 | - `simplification` [number]: Chart simplification. Default `0.2`. 218 | - `stroke` [string]: Color of bars' stroke. Default: `black`. 219 | - `strokeWidth` [number]: Size of bars' stroke. Default: `1`. 220 | - `title` [string]: Chart title. Optional. 221 | - `titleFontSize` [string]: Font-size for chart title. Default: `'1rem'`. 222 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 223 | - `xLabel` [string]: Label for x-axis. 224 | - `yLabel` [string]: Label for y-axis. 225 | 226 | 227 | ### `roughViz.Donut` 228 | Required 229 | - `element` [string]: Id or class of container element. 230 | - `data`: Data with which to construct chart. 231 | Can be either an object or string. 232 | 233 | - If object: must contain `labels` and `values` keys: 234 | 235 | ```js 236 | new roughViz.Donut({ 237 | element: '.viz', 238 | data: {labels: ['a', 'b'], values: [10, 20]} 239 | }) 240 | ``` 241 | 242 | - If string: must be a path/url to a `csv`, `json`, or `tsv`, and you must also specify the `labels` and `values` as separate attributes that represent columns in said file: 243 | ```js 244 | new roughViz.Donut({ 245 | element: '#viz0', 246 | data: 'stringToDataUrl.csv', 247 | labels: 'nameOfLabelsColumn', 248 | values: 'nameOfValuesColumn', 249 | }) 250 | ``` 251 | 252 | Optional 253 | - `bowing` [number]: Chart bowing. Default: `0`. 254 | - `colors` [array]: Array of colors for each arc. Default: `['coral', 'skyblue', '#66c2a5', 'tan', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f', 'tan', 'orange']`. 255 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 256 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.85`. 257 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 258 | - `highlight` [string]: Color for each arc on hover. Default: `'coral'`. 259 | - `innerStrokeWidth` [number]: Stroke-width for paths inside arcs. Default: `0.75`. 260 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 261 | - `legend` [boolean]: Whether or not to add legend. Default: `'true'`. 262 | - `legendPosition` [string]: Position of legend. Should be either `'left'` or `'right'`. Default: `'right'`. 263 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 264 | - `padding` [number]: Padding between bars. Default: `0.1`. 265 | - `roughness` [number]: Roughness level of chart. Default: `1`. 266 | - `simplification` [number]: Chart simplification. Default `0.2`. 267 | - `strokeWidth` [number]: Size of bars' stroke. Default: `1`. 268 | - `title` [string]: Chart title. Optional. 269 | - `titleFontSize` [string]: Font-size for chart title. Default: `'1rem'`. 270 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 271 | 272 | 273 | ### `roughViz.Line` 274 | Required 275 | - `element` [string]: Id or class of container element. 276 | - `data`: Must be a path/url to a `csv` or `tsv`, and you must also specify the each `y` as separate attributes that represent columns in said file. Each attribute prefaced with `y` (except `yLabel`) will receive its own line: 277 | ```js 278 | new roughViz.Line({ 279 | element: '#viz0', 280 | data: 'https://raw.githubusercontent.com/jwilber/random_data/master/profits.csv', 281 | y1: 'revenue', 282 | y2: 'cost', 283 | y3: 'profit' 284 | }) 285 | ``` 286 | 287 | Optional 288 | - `axisFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 289 | - `axisRoughness` [number]: Roughness for x & y axes. Default: `0.5`. 290 | - `axisStrokeWidth` [number]: Stroke-width for x & y axes. Default: `0.5`. 291 | - `bowing` [number]: Chart bowing. Default: `0`. 292 | - `circle` [boolean]: Whether or not to add circles to chart. Default: `true`. 293 | - `circleRadius` [number]: Radius of circles. Default: `10`. 294 | - `circleRoughness` [number]: Roughness of circles. Default: `2`. 295 | - `colors` [array or string]: Array of colors for each arc. Default: `['coral', 'skyblue', '#66c2a5', 'tan', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f', 'tan', 'orange']`. If string (e.g. `'blue'`), all circles will take that color. 296 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 297 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.5`. 298 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 299 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 300 | - `labelFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 301 | - `legend` [boolean]: Whether or not to add legend. Default: `true`. 302 | - `legendPosition` [string]: Position of legend. Should be either `'left'` or `'right'`. Default: `'right'`. 303 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 304 | - `roughness` [number]: Roughness level of chart. Default: `1`. 305 | - `simplification` [number]: Chart simplification. Default `0.2`. 306 | - `stroke` [string]: Color of lines' stroke. Default: `this.colors`. 307 | - `strokeWidth` [number]: Size of lines' stroke. Default: `1`. 308 | - `title` [string]: Chart title. Optional. 309 | - `titleFontSize` [string]: Font-size for chart title. Default: `'0.95rem'`. 310 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 311 | - `xLabel` [string]: Label for x-axis. 312 | - `yLabel` [string]: Label for y-axis. 313 | 314 | 315 | ### `roughViz.Pie` 316 | Required 317 | - `element` [string]: Id or class of container element. 318 | - `data`: Data with which to construct chart. 319 | Can be either an object or string. 320 | 321 | - If object: must contain `labels` and `values` keys: 322 | 323 | ```js 324 | new roughViz.Pie({ 325 | element: '.viz', 326 | data: {labels: ['a', 'b'], values: [10, 20]} 327 | }) 328 | ``` 329 | 330 | - If string: must be a path/url to a `csv`, `json`, or `tsv`, and you must also specify the `labels` and `values` as separate attributes that represent columns in said file: 331 | ```js 332 | new roughViz.Pie({ 333 | element: '#viz0', 334 | data: 'stringToDataUrl.csv', 335 | labels: 'nameOfLabelsColumn', 336 | values: 'nameOfValuesColumn', 337 | }) 338 | ``` 339 | 340 | Optional 341 | - `bowing` [number]: Chart bowing. Default: `0`. 342 | - `colors` [array]: Array of colors for each arc. Default: `['coral', 'skyblue', '#66c2a5', 'tan', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f', 'tan', 'orange']`. 343 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 344 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.85`. 345 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 346 | - `highlight` [string]: Color for each arc on hover. Default: `'coral'`. 347 | - `innerStrokeWidth` [number]: Stroke-width for paths inside arcs. Default: `0.75`. 348 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 349 | - `legend` [boolean]: Whether or not to add legend. Default: `true`. 350 | - `legendPosition` [string]: Position of legend. Should be either `'left'` or `'right'`. Default: `'right'`. 351 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 352 | - `padding` [number]: Padding between bars. Default: `0.1`. 353 | - `roughness` [number]: Roughness level of chart. Default: `1`. 354 | - `simplification` [number]: Chart simplification. Default `0.2`. 355 | - `strokeWidth` [number]: Size of bars' stroke. Default: `1`. 356 | - `title` [string]: Chart title. Optional. 357 | - `titleFontSize` [string]: Font-size for chart title. Default: `'1rem'`. 358 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 359 | 360 | 361 | ### `roughViz.Scatter` 362 | Required 363 | - `element` [string]: Id or class of container element. 364 | - `data`: Data with which to construct chart. 365 | Can be either an object or string. 366 | 367 | - If object: must contain `x` and `y` keys: 368 | 369 | ```js 370 | new roughViz.Scatter({ 371 | element: '.viz', 372 | data: {x: [1, 2, 35], y: [10, 20, 8]} 373 | }) 374 | ``` 375 | 376 | - If string: must be a path/url to a `csv` or `tsv`, and you must also specify the `x` and `y` as separate attributes that represent columns in said file: 377 | ```js 378 | new roughViz.Scatter({ 379 | element: '#viz0', 380 | data: 'stringToDataUrl.csv', 381 | x: 'nameOfLabelsColumn', 382 | y: 'nameOfValuesColumn', 383 | }) 384 | ``` 385 | 386 | Optional 387 | - `axisFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 388 | - `axisRoughness` [number]: Roughness for x & y axes. Default: `0.5`. 389 | - `axisStrokeWidth` [number]: Stroke-width for x & y axes. Default: `0.5`. 390 | - `bowing` [number]: Chart bowing. Default: `0`. 391 | - `colors` [array or string]: Array of colors for each arc. Default: `['coral', 'skyblue', '#66c2a5', 'tan', '#8da0cb', '#e78ac3', '#a6d854', '#ffd92f', 'tan', 'orange']`. If string (e.g. `'blue'`), all circles will take that color. 392 | - `colorVar` [string]: If input data is `csv` or `tsv`, this should be an ordinal column with which to color points by. 393 | `curbZero` [boolean]: Whether or not to force (x, y) axes to (0, 0). Default: `false`. 394 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 395 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.5`. 396 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 397 | - `highlight` [string]: Color for each bar on hover. Default: `'coral'`. 398 | - `highlightLabel` [string]: If input data is `csv` or `tsv`, this should be a column representing what value to display on hover. Otherwise, `(x, y)` values will be shown on hover. 399 | - `innerStrokeWidth` [number]: Stroke-width for paths inside circles. Default: `1`. 400 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 401 | - `labelFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 402 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 403 | - `radius` [number]: Circle radius. Default: `8`. 404 | - `roughness` [number]: Roughness level of chart. Default: `1`. 405 | - `simplification` [number]: Chart simplification. Default `0.2`. 406 | - `stroke` [string]: Color of circles' stroke. Default: `black`. 407 | - `strokeWidth` [number]: Size of circles' stroke. Default: `1`. 408 | - `title` [string]: Chart title. Optional. 409 | - `titleFontSize` [string]: Font-size for chart title. Default: `'0.95rem'`. 410 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 411 | - `xLabel` [string]: Label for x-axis. 412 | - `yLabel` [string]: Label for y-axis. 413 | 414 | 415 | ### `roughViz.StackedBar` 416 | Required 417 | - `element` [string]: Id or class of container element. 418 | - `data`: Data with which to construct chart. Should be an object. 419 | - `labels`: String name of label key in `data` object. 420 | 421 | ```js 422 | new roughViz.StackedBar({ 423 | element: '#vis0', 424 | data: [ 425 | {month:'Jan', A:20, B: 5}, 426 | {month:'Feb', A:25, B: 10}, 427 | ], 428 | labels: 'month', 429 | }) 430 | ``` 431 | 432 | Optional 433 | - `axisFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 434 | - `axisRoughness` [number]: Roughness for x & y axes. Default: `0.5`. 435 | - `axisStrokeWidth` [number]: Stroke-width for x & y axes. Default: `0.5`. 436 | - `bowing` [number]: Chart bowing. Default: `0`. 437 | - `colors` [string]: Array of colors for each bar grouping. 438 | - `fillStyle` [string]: Bar fill-style. Should be one of [fillStyles](#fillStyle) shown above. 439 | - `fillWeight` [number]: Weight of inner paths' color. Default: `0.5`. 440 | - `font`: Font-family to use. You can use `0` or `gaegu` to use `Gaegu`, or `1` or `indie flower` to use `Indie Flower`. Or feed it something else. Default: `Gaegu`. 441 | - `highlight` [string]: Color for each bar on hover. Default: `'coral'`. 442 | - `innerStrokeWidth` [number]: Stroke-width for paths inside bars. Default: `1`. 443 | - `interactive` [boolean]: Whether or not chart is interactive. Default: `true`. 444 | - `labelFontSize` [string]: Font-size for axes' labels. Default: `'1rem'`. 445 | - `margin` [object]: Margin object. Default: `{top: 50, right: 20, bottom: 70, left: 100}` 446 | - `padding` [number]: Padding between bars. Default: `0.1`. 447 | - `roughness` [number]: Roughness level of chart. Default: `1`. 448 | - `simplification` [number]: Chart simplification. Default `0.2`. 449 | - `stroke` [string]: Color of bars' stroke. Default: `black`. 450 | - `strokeWidth` [number]: Size of bars' stroke. Default: `1`. 451 | - `title` [string]: Chart title. Optional. 452 | - `titleFontSize` [string]: Font-size for chart title. Default: `'1rem'`. 453 | - `tooltipFontSize` [string]: Font-size for tooltip. Default: `'0.95rem'`. 454 | - `xLabel` [string]: Label for x-axis. 455 | - `yLabel` [string]: Label for y-axis. 456 | 457 | 458 | 459 | ### Contributors 460 | - [Jared Wilber](https://twitter.com/jdwlbr) 461 | - [Laimonas Andriejauskas](https://github.com/laimonasA) 462 | - [Dave Slutzkin](https://github.com/daveslutzkin) 463 | - [JoLo](https://github.com/jolo-dev) 464 | - [Lucas Wilber](https://github.com/lucasjwilber) 465 | 466 | ### Acknowledgements 467 | This library wouldn't be possible without the following people: 468 | 469 | - [Mike Bostock](https://twitter.com/mbostock) for [D3.js](https://d3js.org/). 470 | - [Preet Shihn](https://twitter.com/preetster) for [rough.js](https://roughjs.com/). 471 | - [Jo Wood](https://www.city.ac.uk/people/academics/jo-wood) for [handy](https://www.gicentre.net/software/#/handy/) processing lib. 472 | 473 | 474 | ### License 475 | MIT License 476 | 477 | Copyright (c) 2019 Jared Wilber 478 | 479 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 480 | 481 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 482 | 483 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 484 | -------------------------------------------------------------------------------- /assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/.DS_Store -------------------------------------------------------------------------------- /assets/Gaegu-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/Gaegu-Bold.ttf -------------------------------------------------------------------------------- /assets/Gaegu-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/Gaegu-Light.ttf -------------------------------------------------------------------------------- /assets/Gaegu-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/Gaegu-Regular.ttf -------------------------------------------------------------------------------- /assets/gaegu-v8-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/gaegu-v8-latin-regular.ttf -------------------------------------------------------------------------------- /assets/indie-flower-v11-latin-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/assets/indie-flower-v11-latin-regular.ttf -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/8p/b6pfnrfn4sz61xsdjvm4xq3w0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: null, 95 | 96 | // Run tests from one or more projects 97 | // projects: null, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: null, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | // rootDir: null, 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 132 | // snapshotSerializers: [], 133 | 134 | // The test environment that will be used for testing 135 | testEnvironment: "node", 136 | 137 | // Options that will be passed to the testEnvironment 138 | // testEnvironmentOptions: {}, 139 | 140 | // Adds a location field to test results 141 | // testLocationInResults: false, 142 | 143 | // The glob patterns Jest uses to detect test files 144 | // testMatch: [ 145 | // "**/__tests__/**/*.[jt]s?(x)", 146 | // "**/?(*.)+(spec|test).[tj]s?(x)" 147 | // ], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: null, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: null, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: null, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | }; 189 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rough-viz", 3 | "version": "2.0.5", 4 | "description": "Hand drawn, rough, sketchy data visualization in svg.", 5 | "jsdelivr": "dist/roughviz.umd.js", 6 | "main": "dist/roughviz.cjs.js", 7 | "module": "dist/roughviz.es.js", 8 | "unpkg": "dist/roughviz.umd.js", 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build", 12 | "serve": "vite preview", 13 | "test": "jest --env=jsdom" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jwilber/roughViz.git" 18 | }, 19 | "keywords": [ 20 | "chart", 21 | "graph", 22 | "rough", 23 | "hand-drawn", 24 | "sketchy", 25 | "dataviz", 26 | "data visualization" 27 | ], 28 | "author": "jwilber", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/jwilber/roughViz/issues" 32 | }, 33 | "homepage": "https://github.com/jwilber/roughViz#readme", 34 | "dependencies": { 35 | "d3-array": "^2.3.1", 36 | "d3-axis": "^1.0.12", 37 | "d3-fetch": "^1.1.2", 38 | "d3-force": "^3.0.0", 39 | "d3-format": "^1.4.1", 40 | "d3-scale": "^3.2.0", 41 | "d3-scale-chromatic": "^1.5.0", 42 | "d3-selection": "^1.4.0", 43 | "d3-shape": "^1.3.5", 44 | "roughjs": "^4.0.0" 45 | }, 46 | "devDependencies": { 47 | "eslint": "^6.3.0", 48 | "eslint-config-strongloop": "^2.1.0", 49 | "jest": "^24.9.0", 50 | "rollup-plugin-terser": "^7.0.2", 51 | "vite": "^4.4.9" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Bar.js: -------------------------------------------------------------------------------- 1 | import { max } from "d3-array"; 2 | import { axisBottom, axisLeft } from "d3-axis"; 3 | import { csv, tsv } from "d3-fetch"; 4 | import { format } from "d3-format"; 5 | import { scaleBand, scaleLinear } from "d3-scale"; 6 | import { mouse, select, selectAll } from "d3-selection"; 7 | import rough from "roughjs/bundled/rough.esm.js"; 8 | import Chart from "./Chart"; 9 | import { roughCeiling } from "./utils/roughCeiling"; 10 | 11 | /** 12 | * Bar chart class, which extends the Chart class. 13 | */ 14 | class Bar extends Chart { 15 | /** 16 | * Constructs a new Bar instance. 17 | * @param {Object} opts - Configuration object for the bar chart. 18 | */ 19 | constructor(opts) { 20 | super(opts); 21 | 22 | // load in arguments from config object 23 | this.data = opts.data; 24 | this.margin = opts.margin || { top: 20, right: 10, bottom: 20, left: 20 }; 25 | this.color = opts.color || "red"; 26 | this.highlight = opts.highlight || "coral"; 27 | this.roughness = roughCeiling({ roughness: opts.roughness }); 28 | this.stroke = opts.stroke || "black"; 29 | this.strokeWidth = opts.strokeWidth || 1; 30 | this.axisStrokeWidth = opts.axisStrokeWidth || 0.5; 31 | this.axisRoughness = opts.axisRoughness || 0.5; 32 | this.innerStrokeWidth = opts.innerStrokeWidth || 1; 33 | this.fillWeight = opts.fillWeight || 0.5; 34 | this.axisFontSize = opts.axisFontSize; 35 | this.labels = this.dataFormat === "object" ? "labels" : opts.labels; 36 | this.values = this.dataFormat === "object" ? "values" : opts.values; 37 | this.xValueFormat = opts.xValueFormat; 38 | this.yValueFormat = opts.yValueFormat; 39 | this.padding = opts.padding || 0.1; 40 | this.xLabel = opts.xLabel || ""; 41 | this.yLabel = opts.yLabel || ""; 42 | this.labelFontSize = opts.labelFontSize || "1rem"; 43 | this.responsive = true; 44 | this.boundRedraw = this.redraw.bind(this, opts); 45 | // new width 46 | this.initChartValues(opts); 47 | // resolve font 48 | this.resolveFont(); 49 | // create the chart 50 | this.drawChart = this.resolveData(opts.data); 51 | this.drawChart(); 52 | if (opts.title !== "undefined") this.setTitle(opts.title); 53 | window.addEventListener("resize", this.resizeHandler.bind(this)); 54 | } 55 | 56 | /** 57 | * Handles window resize to redraw chart if responsive. 58 | */ 59 | resizeHandler() { 60 | if (this.responsive) { 61 | this.boundRedraw(); 62 | } 63 | } 64 | 65 | /** 66 | * Removes SVG elements and tooltips associated with the chart. 67 | */ 68 | remove() { 69 | select(this.el).select("svg").remove(); 70 | select(this.el).select(".tooltip").remove(); 71 | } 72 | 73 | /** 74 | * Redraws the bar chart with updated options. 75 | * @param {Object} opts - Updated configuration object for the bar chart. 76 | */ 77 | redraw(opts) { 78 | // 1. Remove the current SVG associated with the chart. 79 | this.remove(); 80 | 81 | // 2. Recalculate the size of the container. 82 | this.initChartValues(opts); 83 | 84 | // 3. Redraw everything. 85 | this.resolveFont(); 86 | this.drawChart = this.resolveData(opts.data); 87 | this.drawChart(); 88 | 89 | if (opts.title !== "undefined") { 90 | this.setTitle(opts.title); 91 | } 92 | } 93 | 94 | /** 95 | * Initialize the chart with default attributes. 96 | * @param {Object} opts - Configuration object for the chart. 97 | */ 98 | initChartValues(opts) { 99 | this.roughness = opts.roughness || this.roughness; 100 | this.color = opts.color || this.color; 101 | this.stroke = opts.stroke || this.stroke; 102 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 103 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 104 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 105 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 106 | this.fillWeight = opts.fillWeight || this.fillWeight; 107 | this.fillStyle = opts.fillStyle || this.fillStyle; 108 | this.title = opts.title || this.title; 109 | const divDimensions = select(this.el).node().getBoundingClientRect(); 110 | const width = divDimensions.width; 111 | const height = divDimensions.height; 112 | this.width = width - this.margin.left - this.margin.right; 113 | this.height = height - this.margin.top - this.margin.bottom; 114 | this.roughId = this.el + "_svg"; 115 | this.graphClass = this.el.substring(1, this.el.length); 116 | this.interactionG = "g." + this.graphClass; 117 | this.setSvg(); 118 | } 119 | 120 | // add this to abstract base 121 | resolveData(data) { 122 | if (typeof data === "string") { 123 | if (data.includes(".csv")) { 124 | return () => { 125 | csv(data).then((d) => { 126 | this.data = d; 127 | this.drawFromFile(); 128 | }); 129 | }; 130 | } else if (data.includes(".tsv")) { 131 | return () => { 132 | tsv(data).then((d) => { 133 | this.data = d; 134 | this.drawFromFile(); 135 | }); 136 | }; 137 | } 138 | } else { 139 | return () => { 140 | this.data = data; 141 | this.drawFromObject(); 142 | }; 143 | } 144 | } 145 | 146 | /** 147 | * Created scales required for chart. 148 | */ 149 | addScales() { 150 | const that = this; 151 | 152 | this.xScale = scaleBand() 153 | .rangeRound([0, this.width]) 154 | .padding(this.padding) 155 | .domain( 156 | this.dataFormat === "file" 157 | ? this.data.map((d) => d[that.labels]) 158 | : this.data[that.labels] 159 | ); 160 | 161 | this.yScale = scaleLinear() 162 | .rangeRound([this.height, 0]) 163 | .domain( 164 | this.dataFormat === "file" 165 | ? [0, max(this.data, (d) => +d[that.values])] 166 | : [0, max(this.data[that.values])] 167 | ); 168 | } 169 | 170 | /** 171 | * Create x and y labels for chart. 172 | */ 173 | addLabels() { 174 | // xLabel 175 | if (this.xLabel !== "") { 176 | this.svg 177 | .append("text") 178 | .attr("x", this.width / 2) 179 | .attr("y", this.height + this.margin.bottom / 2) 180 | .attr("dx", "1em") 181 | .attr("class", "labelText") 182 | .style("text-anchor", "middle") 183 | .style("font-family", this.fontFamily) 184 | .style("font-size", this.labelFontSize) 185 | .text(this.xLabel); 186 | } 187 | // yLabel 188 | if (this.yLabel !== "") { 189 | this.svg 190 | .append("text") 191 | .attr("transform", "rotate(-90)") 192 | .attr("y", 0 - this.margin.left / 1.4) 193 | .attr("x", 0 - this.height / 2) 194 | .attr("dy", "1em") 195 | .attr("class", "labelText") 196 | .style("text-anchor", "middle") 197 | .style("font-family", this.fontFamily) 198 | .style("font-size", this.labelFontSize) 199 | .text(this.yLabel); 200 | } 201 | } 202 | 203 | /** 204 | * Create x and y axes for chart. 205 | */ 206 | addAxes() { 207 | const xAxis = axisBottom(this.xScale) 208 | .tickSize(0) 209 | .tickFormat((d) => { 210 | return this.xValueFormat ? format(this.xValueFormat)(d) : d; 211 | }); 212 | 213 | const yAxis = axisLeft(this.yScale) 214 | .tickSize(0) 215 | .tickFormat((d) => { 216 | return this.yValueFormat ? format(this.yValueFormat)(d) : d; 217 | }); 218 | 219 | // x-axis 220 | this.svg 221 | .append("g") 222 | .attr("transform", "translate(0," + this.height + ")") 223 | .call(xAxis) 224 | .attr("class", `xAxis${this.graphClass}`) 225 | .selectAll("text") 226 | .attr("transform", "translate(-10,0)rotate(-45)") 227 | .style("text-anchor", "end") 228 | .style("font-family", this.fontFamily) 229 | .style( 230 | "font-size", 231 | this.axisFontSize === undefined 232 | ? `${Math.min(0.8, Math.min(this.width, this.height) / 140)}rem` 233 | : this.axisFontSize 234 | ) 235 | .style("opacity", 0.9); 236 | 237 | // y-axis 238 | this.svg 239 | .append("g") 240 | .call(yAxis) 241 | .attr("class", `yAxis${this.graphClass}`) 242 | .selectAll("text") 243 | .style("font-family", this.fontFamily) 244 | .style( 245 | "font-size", 246 | this.axisFontSize === undefined 247 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 248 | : this.axisFontSize 249 | ) 250 | .style("opacity", 0.9); 251 | 252 | // hide original axes 253 | selectAll("path.domain").attr("stroke", "transparent"); 254 | } 255 | 256 | makeAxesRough(roughSvg, rcAxis) { 257 | const xAxisClass = `xAxis${this.graphClass}`; 258 | const yAxisClass = `yAxis${this.graphClass}`; 259 | const roughXAxisClass = `rough-${xAxisClass}`; 260 | const roughYAxisClass = `rough-${yAxisClass}`; 261 | 262 | select(`.${xAxisClass}`) 263 | .selectAll("path.domain") 264 | .each(function (d, i) { 265 | const pathD = select(this).node().getAttribute("d"); 266 | const roughXAxis = rcAxis.path(pathD, { 267 | fillStyle: "hachure", 268 | }); 269 | roughXAxis.setAttribute("class", roughXAxisClass); 270 | roughSvg.appendChild(roughXAxis); 271 | }); 272 | selectAll(`.${roughXAxisClass}`).attr( 273 | "transform", 274 | `translate(0, ${this.height})` 275 | ); 276 | 277 | select(`.${yAxisClass}`) 278 | .selectAll("path.domain") 279 | .each(function (d, i) { 280 | const pathD = select(this).node().getAttribute("d"); 281 | const roughYAxis = rcAxis.path(pathD, { 282 | fillStyle: "hachure", 283 | }); 284 | roughYAxis.setAttribute("class", roughYAxisClass); 285 | roughSvg.appendChild(roughYAxis); 286 | }); 287 | } 288 | 289 | /** 290 | * Set the chart title with the given title. 291 | * @param {string} title - The title for the chart. 292 | */ 293 | setTitle(title) { 294 | this.svg 295 | .append("text") 296 | .attr("x", this.width / 2) 297 | .attr("y", 0 - this.margin.top / 2) 298 | .attr("class", "title") 299 | .attr("text-anchor", "middle") 300 | .style( 301 | "font-size", 302 | this.titleFontSize === undefined 303 | ? `${Math.min(40, Math.min(this.width, this.height) / 5)}px` 304 | : this.titleFontSize 305 | ) 306 | .style("font-family", this.fontFamily) 307 | .style("opacity", 0.8) 308 | .text(title); 309 | } 310 | 311 | /** 312 | * Add interaction elements to chart. 313 | */ 314 | addInteraction() { 315 | selectAll(this.interactionG) 316 | .data(this.dataFormat === "file" ? this.data : this.data.values) 317 | .append("rect") 318 | .attr("x", (d, i) => { 319 | return this.dataFormat === "file" 320 | ? this.xScale(d[this.labels]) 321 | : this.xScale(this.data[this.labels][i]); 322 | }) 323 | .attr("y", (d, i) => { 324 | return this.dataFormat === "file" 325 | ? this.yScale(+d[this.values]) 326 | : this.yScale(this.data[this.values][i]); 327 | }) 328 | .attr("width", this.xScale.bandwidth()) 329 | .attr("height", (d, i) => { 330 | return this.dataFormat === "file" 331 | ? this.height - this.yScale(+d[this.values]) 332 | : this.height - this.yScale(this.data[this.values][i]); 333 | }) 334 | .attr("fill", "transparent"); 335 | 336 | // create tooltip 337 | const Tooltip = select(this.el) 338 | .append("div") 339 | .style("opacity", 0) 340 | .attr("class", "tooltip") 341 | .style("position", "absolute") 342 | .style("background-color", "white") 343 | .style("border", "solid") 344 | .style("border-width", "1px") 345 | .style("border-radius", "5px") 346 | .style("padding", "3px") 347 | .style("font-family", this.fontFamily) 348 | .style("font-size", this.tooltipFontSize) 349 | .style("pointer-events", "none"); 350 | 351 | // event functions 352 | let mouseover = function (d) { 353 | Tooltip.style("opacity", 1); 354 | }; 355 | const that = this; 356 | 357 | let mousemove = function (d) { 358 | const attrX = select(this).attr("attrX"); 359 | const attrY = select(this).attr("attrY"); 360 | const mousePos = mouse(this); 361 | // get size of enclosing div 362 | Tooltip.html(`${attrX}: ${attrY}`) 363 | .style("opacity", 0.95) 364 | .style( 365 | "transform", 366 | `translate(${mousePos[0] + 10 + that.margin.left}px, 367 | ${ 368 | mousePos[1] - 369 | 10 - 370 | (that.height + that.margin.top + that.margin.bottom / 2) 371 | }px)` 372 | ); 373 | }; 374 | 375 | let mouseleave = function (d) { 376 | Tooltip.style("opacity", 0); 377 | }; 378 | 379 | // d3 event handlers 380 | selectAll(this.interactionG).on("mouseover", function () { 381 | mouseover(); 382 | select(this).select("path").style("stroke", that.highlight); 383 | select(this) 384 | .selectAll("path:nth-child(2)") 385 | .style("stroke-width", that.strokeWidth + 1.2); 386 | }); 387 | 388 | selectAll(this.interactionG).on("mouseout", function () { 389 | mouseleave(); 390 | select(this).select("path").style("stroke", that.color); 391 | select(this) 392 | .selectAll("path:nth-child(2)") 393 | .style("stroke-width", that.strokeWidth); 394 | }); 395 | 396 | selectAll(this.interactionG).on("mousemove", mousemove); 397 | } 398 | 399 | /** 400 | * Draw rough SVG elements on chart. 401 | */ 402 | initRoughObjects() { 403 | this.roughSvg = document.getElementById(this.roughId); 404 | this.rcAxis = rough.svg(this.roughSvg, { 405 | options: { 406 | strokeWidth: this.axisStrokeWidth, 407 | roughness: this.axisRoughness, 408 | }, 409 | }); 410 | this.rc = rough.svg(this.roughSvg, { 411 | options: { 412 | fill: this.color, 413 | stroke: this.stroke === "none" ? undefined : this.stroke, 414 | strokeWidth: this.innerStrokeWidth, 415 | roughness: this.roughness, 416 | bowing: this.bowing, 417 | fillStyle: this.fillStyle, 418 | }, 419 | }); 420 | } 421 | 422 | /** 423 | * Draw chart from object input. 424 | */ 425 | drawFromObject() { 426 | this.initRoughObjects(); 427 | this.addScales(); 428 | this.addAxes(); 429 | this.makeAxesRough(this.roughSvg, this.rcAxis); 430 | this.addLabels(); 431 | 432 | // Add barplot 433 | this.data.values.forEach((d, i) => { 434 | const node = this.rc.rectangle( 435 | this.xScale(this.data[this.labels][i]), 436 | this.yScale(+d), 437 | this.xScale.bandwidth(), 438 | this.height - this.yScale(+d), 439 | { 440 | simplification: this.simplification, 441 | fillWeight: this.fillWeight, 442 | } 443 | ); 444 | const roughNode = this.roughSvg.appendChild(node); 445 | roughNode.setAttribute("class", this.graphClass); 446 | roughNode.setAttribute("attrX", this.data[this.labels][i]); 447 | roughNode.setAttribute("attrY", +d); 448 | }); 449 | 450 | selectAll(this.interactionG) 451 | .selectAll("path:nth-child(2)") 452 | .style("stroke-width", this.strokeWidth); 453 | // If desired, add interactivity 454 | if (this.interactive === true) { 455 | this.addInteraction(); 456 | } 457 | } // draw 458 | 459 | /** 460 | * Draw chart from file. 461 | */ 462 | drawFromFile() { 463 | this.initRoughObjects(); 464 | this.addScales(); 465 | this.addAxes(); 466 | this.makeAxesRough(this.roughSvg, this.rcAxis); 467 | this.addLabels(); 468 | 469 | // Add barplot 470 | this.data.forEach((d) => { 471 | const node = this.rc.rectangle( 472 | this.xScale(d[this.labels]), 473 | this.yScale(+d[this.values]), 474 | this.xScale.bandwidth(), 475 | this.height - this.yScale(+d[this.values]), 476 | { 477 | simplification: this.simplification, 478 | fillWeight: this.fillWeight, 479 | } 480 | ); 481 | const roughNode = this.roughSvg.appendChild(node); 482 | roughNode.setAttribute("class", this.graphClass); 483 | roughNode.setAttribute("attrX", d[this.labels]); 484 | roughNode.setAttribute("attrY", +d[this.values]); 485 | }); 486 | 487 | selectAll(this.interactionG) 488 | .selectAll("path:nth-child(2)") 489 | .style("stroke-width", this.strokeWidth); 490 | // If desired, add interactivity 491 | if (this.interactive === true) { 492 | this.addInteraction(); 493 | } 494 | } // draw 495 | } 496 | 497 | export default Bar; 498 | -------------------------------------------------------------------------------- /src/BarH.js: -------------------------------------------------------------------------------- 1 | import { max } from "d3-array"; 2 | import { axisBottom, axisLeft } from "d3-axis"; 3 | import { csv, tsv } from "d3-fetch"; 4 | import { format } from "d3-format"; 5 | import { scaleBand, scaleLinear } from "d3-scale"; 6 | import { mouse, select, selectAll } from "d3-selection"; 7 | import rough from "roughjs/bundled/rough.esm.js"; 8 | import Chart from "./Chart"; 9 | import { roughCeiling } from "./utils/roughCeiling"; 10 | 11 | /** 12 | * BarH chart class, which extends the Chart class. 13 | */ 14 | class BarH extends Chart { 15 | /** 16 | * Constructs a new BarH instance. 17 | * @param {Object} opts - Configuration object for the horizontal bar chart. 18 | */ 19 | constructor(opts) { 20 | super(opts); 21 | 22 | // load in arguments from config object 23 | // this.data = opts.data; 24 | this.margin = opts.margin || { top: 50, right: 20, bottom: 50, left: 100 }; 25 | this.color = opts.color || "red"; 26 | this.highlight = opts.highlight || "coral"; 27 | this.roughness = roughCeiling({ roughness: opts.roughness }); 28 | this.stroke = opts.stroke || "black"; 29 | this.strokeWidth = opts.strokeWidth || 1; 30 | this.axisStrokeWidth = opts.axisStrokeWidth || 0.5; 31 | this.axisRoughness = opts.axisRoughness || 0.5; 32 | this.innerStrokeWidth = opts.innerStrokeWidth || 1; 33 | this.fillWeight = opts.fillWeight || 0.5; 34 | this.axisFontSize = opts.axisFontSize; 35 | this.labels = this.dataFormat === "object" ? "labels" : opts.labels; 36 | this.values = this.dataFormat === "object" ? "values" : opts.values; 37 | this.xValueFormat = opts.xValueFormat; 38 | this.yValueFormat = opts.yValueFormat; 39 | this.padding = opts.padding || 0.1; 40 | this.xLabel = opts.xLabel || ""; 41 | this.yLabel = opts.yLabel || ""; 42 | this.labelFontSize = opts.labelFontSize || "1rem"; 43 | this.responsive = true; 44 | this.boundRedraw = this.redraw.bind(this, opts); 45 | // new width 46 | this.initChartValues(opts); 47 | // resolve font 48 | this.resolveFont(); 49 | // create the chart 50 | this.drawChart = this.resolveData(opts.data); 51 | this.drawChart(); 52 | if (opts.title !== "undefined") this.setTitle(opts.title); 53 | window.addEventListener("resize", this.resizeHandler.bind(this)); 54 | } 55 | 56 | /** 57 | * Handles window resize to redraw chart if responsive. 58 | */ 59 | resizeHandler() { 60 | if (this.responsive) { 61 | this.boundRedraw(); 62 | } 63 | } 64 | 65 | /** 66 | * Removes SVG elements and tooltips associated with the chart. 67 | */ 68 | remove() { 69 | select(this.el).select("svg").remove(); 70 | } 71 | 72 | /** 73 | * Redraws the bar chart with updated options. 74 | * @param {Object} opts - Updated configuration object for the bar chart. 75 | */ 76 | redraw(opts) { 77 | // 1. Remove the current SVG associated with the chart. 78 | this.remove(); 79 | 80 | // 2. Recalculate the size of the container. 81 | this.initChartValues(opts); 82 | 83 | // 3. Redraw everything. 84 | this.resolveFont(); 85 | this.drawChart = this.resolveData(opts.data); 86 | this.drawChart(); 87 | 88 | if (opts.title !== "undefined") { 89 | this.setTitle(opts.title); 90 | } 91 | } 92 | 93 | /** 94 | * Initialize the chart with default attributes. 95 | * @param {Object} opts - Configuration object for the chart. 96 | */ 97 | initChartValues(opts) { 98 | this.roughness = opts.roughness || this.roughness; 99 | this.stroke = opts.stroke || this.stroke; 100 | this.color = opts.color || this.color; 101 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 102 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 103 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 104 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 105 | this.fillWeight = opts.fillWeight || this.fillWeight; 106 | this.fillStyle = opts.fillStyle || this.fillStyle; 107 | const divDimensions = select(this.el).node().getBoundingClientRect(); 108 | const width = divDimensions.width; 109 | const height = divDimensions.height; 110 | this.width = width - this.margin.left - this.margin.right; 111 | this.height = height - this.margin.top - this.margin.bottom; 112 | this.roughId = this.el + "_svg"; 113 | this.graphClass = this.el.substring(1, this.el.length); 114 | this.interactionG = "g." + this.graphClass; 115 | this.setSvg(); 116 | } 117 | 118 | // add this to abstract base 119 | resolveData(data) { 120 | if (typeof data === "string") { 121 | if (data.includes(".csv")) { 122 | return () => { 123 | csv(data).then((d) => { 124 | this.data = d; 125 | this.drawFromFile(); 126 | }); 127 | }; 128 | } else if (data.includes(".tsv")) { 129 | return () => { 130 | tsv(data).then((d) => { 131 | this.data = d; 132 | this.drawFromFile(); 133 | }); 134 | }; 135 | } 136 | } else { 137 | return () => { 138 | this.data = data; 139 | this.drawFromObject(); 140 | }; 141 | } 142 | } 143 | 144 | addScales() { 145 | const that = this; 146 | this.yScale = scaleBand() 147 | .rangeRound([0, this.height]) 148 | .padding(this.padding) 149 | .domain( 150 | this.dataFormat === "file" 151 | ? this.data.map((d) => d[that.labels]) 152 | : this.data[that.labels] 153 | ); 154 | 155 | this.xScale = scaleLinear() 156 | .rangeRound([0, this.width]) 157 | .domain( 158 | this.dataFormat === "file" 159 | ? [0, max(this.data, (d) => +d[that.values])] 160 | : [0, max(this.data[that.values])] 161 | ); 162 | } 163 | 164 | /** 165 | * Create x and y labels for chart. 166 | */ 167 | addLabels() { 168 | // xLabel 169 | if (this.xLabel !== "") { 170 | this.svg 171 | .append("text") 172 | .attr("x", this.width / 2) 173 | .attr("y", this.height + this.margin.bottom / 2.4) 174 | .attr("dx", "1em") 175 | .attr("class", "labelText") 176 | .style("text-anchor", "middle") 177 | .style("font-family", this.fontFamily) 178 | .style("font-size", this.labelFontSize) 179 | .text(this.xLabel); 180 | } 181 | // yLabel 182 | if (this.yLabel !== "") { 183 | this.svg 184 | .append("text") 185 | .attr("transform", "rotate(-90)") 186 | .attr("y", 0 - this.margin.left / 1.5) 187 | .attr("x", 0 - this.height / 2) 188 | .attr("dy", "1em") 189 | .attr("class", "labelText") 190 | .style("text-anchor", "middle") 191 | .style("font-family", this.fontFamily) 192 | .style("font-size", this.labelFontSize) 193 | .text(this.yLabel); 194 | } 195 | } 196 | 197 | /** 198 | * Create x and y axes for chart. 199 | */ 200 | addAxes() { 201 | const xAxis = axisBottom(this.xScale) 202 | .tickSize(0) 203 | .tickFormat((d) => { 204 | return this.xValueFormat ? format(this.xValueFormat)(d) : d; 205 | }); 206 | 207 | const yAxis = axisLeft(this.yScale) 208 | .tickSize(0) 209 | .tickFormat((d) => { 210 | return this.yValueFormat ? format(this.yValueFormat)(d) : d; 211 | }); 212 | 213 | // x-axis 214 | this.svg 215 | .append("g") 216 | .attr("transform", `translate(0, ${this.height})`) 217 | .call(xAxis) 218 | .attr("class", `xAxis${this.graphClass}`) 219 | .selectAll("text") 220 | .attr("transform", "translate(-10,0)rotate(-45)") 221 | .style("text-anchor", "end") 222 | .style("font-family", this.fontFamily) 223 | .style( 224 | "font-size", 225 | this.axisFontSize === undefined 226 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 227 | : this.axisFontSize 228 | ) 229 | .style("opacity", 0.85); 230 | 231 | // y-axis 232 | this.svg 233 | .append("g") 234 | .call(yAxis) 235 | .attr("class", `yAxis${this.graphClass}`) 236 | .selectAll("text") 237 | .style("font-family", this.fontFamily) 238 | .style( 239 | "font-size", 240 | this.axisFontSize === undefined 241 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 242 | : this.axisFontSize 243 | ) 244 | .style("opacity", 0.85); 245 | 246 | // hide original axes 247 | selectAll("path.domain").attr("stroke", "transparent"); 248 | } 249 | 250 | makeAxesRough(roughSvg, rcAxis) { 251 | const xAxisClass = `xAxis${this.graphClass}`; 252 | const yAxisClass = `yAxis${this.graphClass}`; 253 | const roughXAxisClass = `rough-${xAxisClass}`; 254 | const roughYAxisClass = `rough-${yAxisClass}`; 255 | 256 | select(`.${xAxisClass}`) 257 | .selectAll("path.domain") 258 | .each(function (d, i) { 259 | const pathD = select(this).node().getAttribute("d"); 260 | const roughXAxis = rcAxis.path(pathD, { 261 | stroke: "black", 262 | fillStyle: "hachure", 263 | }); 264 | roughXAxis.setAttribute("class", roughXAxisClass); 265 | roughSvg.appendChild(roughXAxis); 266 | }); 267 | selectAll(`.${roughXAxisClass}`).attr( 268 | "transform", 269 | `translate(0, ${this.height})` 270 | ); 271 | 272 | select(`.${yAxisClass}`) 273 | .selectAll("path.domain") 274 | .each(function (d, i) { 275 | const pathD = select(this).node().getAttribute("d"); 276 | const roughYAxis = rcAxis.path(pathD, { 277 | stroke: "black", 278 | fillStyle: "hachure", 279 | }); 280 | roughYAxis.setAttribute("class", roughYAxisClass); 281 | roughSvg.appendChild(roughYAxis); 282 | }); 283 | } 284 | 285 | /** 286 | * Set the chart title with the given title. 287 | * @param {string} title - The title for the chart. 288 | */ 289 | setTitle(title) { 290 | this.svg 291 | .append("text") 292 | .attr("x", this.width / 2) 293 | .attr("y", 0 - this.margin.top / 2) 294 | .attr("class", "title") 295 | .attr("text-anchor", "middle") 296 | .style( 297 | "font-size", 298 | this.titleFontSize === undefined 299 | ? `${Math.min(40, Math.min(this.width, this.height) / 5)}px` 300 | : this.titleFontSize 301 | ) 302 | .style("font-family", this.fontFamily) 303 | .style("opacity", 0.8) 304 | .text(title); 305 | } 306 | 307 | /** 308 | * Add interaction elements to chart. 309 | */ 310 | addInteraction() { 311 | // add highlight helper dom nodes 312 | selectAll(this.interactionG) 313 | .data(this.dataFormat === "file" ? this.data : this.data.values) 314 | .append("rect") 315 | .attr("x", 0) 316 | .attr("y", (d, i) => { 317 | return this.dataFormat === "file" 318 | ? this.yScale(d[this.labels]) 319 | : this.yScale(this.data[this.labels][i]); 320 | }) 321 | .attr("width", (d, i) => { 322 | return this.dataFormat === "file" 323 | ? this.xScale(+d[this.values]) 324 | : this.xScale(this.data[this.values][i]); 325 | }) 326 | .attr("height", this.yScale.bandwidth()) 327 | .attr("fill", "transparent"); 328 | 329 | // create tooltip 330 | const Tooltip = select(this.el) 331 | .append("div") 332 | .style("opacity", 0) 333 | .attr("class", "tooltip") 334 | .style("position", "absolute") 335 | .style("background-color", "white") 336 | .style("border", "solid") 337 | .style("border-width", "1px") 338 | .style("border-radius", "5px") 339 | .style("padding", "3px") 340 | .style("font-family", this.fontFamily) 341 | .style("font-size", this.tooltipFontSize) 342 | .style("pointer-events", "none"); 343 | 344 | // event functions 345 | let mouseover = function (d) { 346 | Tooltip.style("opacity", 1); 347 | }; 348 | const that = this; 349 | 350 | let mousemove = function (d) { 351 | const attrX = select(this).attr("attrX"); 352 | const attrY = select(this).attr("attrY"); 353 | const mousePos = mouse(this); 354 | // get size of enclosing div 355 | Tooltip.html(`${attrX}: ${attrY}`) 356 | .style("opacity", 0.95) 357 | .style( 358 | "transform", 359 | `translate(${mousePos[0] + that.margin.left}px, 360 | ${ 361 | mousePos[1] - 362 | (that.height + that.margin.top + that.margin.bottom / 2) 363 | }px)` 364 | ); 365 | }; 366 | let mouseleave = function (d) { 367 | Tooltip.style("opacity", 0); 368 | }; 369 | 370 | // d3 event handlers 371 | selectAll(this.interactionG).on("mouseover", function () { 372 | mouseover(); 373 | select(this).select("path").style("stroke", that.highlight); 374 | select(this) 375 | .selectAll("path:nth-child(2)") 376 | .style("stroke-width", that.strokeWidth + 1.2); 377 | }); 378 | 379 | selectAll(this.interactionG).on("mouseout", function () { 380 | mouseleave(); 381 | select(this).select("path").style("stroke", that.color); 382 | select(this) 383 | .selectAll("path:nth-child(2)") 384 | .style("stroke-width", that.strokeWidth); 385 | }); 386 | 387 | selectAll(this.interactionG).on("mousemove", mousemove); 388 | } 389 | 390 | /** 391 | * Draw rough SVG elements on chart. 392 | */ 393 | initRoughObjects() { 394 | this.roughSvg = document.getElementById(this.roughId); 395 | this.rcAxis = rough.svg(this.roughSvg, { 396 | options: { 397 | strokeWidth: this.axisStrokeWidth, 398 | roughness: this.axisRoughness, 399 | }, 400 | }); 401 | this.rc = rough.svg(this.roughSvg, { 402 | options: { 403 | fill: this.color, 404 | stroke: this.stroke === "none" ? undefined : this.stroke, 405 | strokeWidth: this.innerStrokeWidth, 406 | roughness: this.roughness, 407 | bowing: this.bowing, 408 | fillStyle: this.fillStyle, 409 | }, 410 | }); 411 | } 412 | 413 | /** 414 | * Draw chart from object input. 415 | */ 416 | drawFromObject() { 417 | this.initRoughObjects(); 418 | this.addScales(); 419 | this.addAxes(); 420 | this.makeAxesRough(this.roughSvg, this.rcAxis); 421 | this.addLabels(); 422 | 423 | this.data.values.forEach((d, i) => { 424 | const node = this.rc.rectangle( 425 | 0, 426 | this.yScale(this.data[this.labels][i]), 427 | this.xScale(d), 428 | this.yScale.bandwidth(), 429 | { 430 | simplification: this.simplification, 431 | fillWeight: this.fillWeight, 432 | } 433 | ); 434 | const roughNode = this.roughSvg.appendChild(node); 435 | roughNode.setAttribute("class", this.graphClass); 436 | roughNode.setAttribute("attrX", this.data[this.labels][i]); 437 | roughNode.setAttribute("attrY", +d); 438 | }); 439 | 440 | selectAll(this.interactionG) 441 | .selectAll("path:nth-child(2)") 442 | .style("stroke-width", this.strokeWidth); 443 | // If desired, add interactivity 444 | if (this.interactive === true) { 445 | this.addInteraction(); 446 | } 447 | } // draw 448 | 449 | /** 450 | * Draw chart from file. 451 | */ 452 | drawFromFile() { 453 | this.initRoughObjects(); 454 | this.addScales(); 455 | this.addAxes(); 456 | this.makeAxesRough(this.roughSvg, this.rcAxis); 457 | this.addLabels(); 458 | 459 | // Add barplot 460 | this.data.forEach((d) => { 461 | const node = this.rc.rectangle( 462 | 0, 463 | this.yScale(d[this.labels]), 464 | this.xScale(+d[this.values]), 465 | this.yScale.bandwidth(), 466 | { 467 | simplification: this.simplification, 468 | fillWeight: this.fillWeight, 469 | } 470 | ); 471 | const roughNode = this.roughSvg.appendChild(node); 472 | roughNode.setAttribute("class", this.graphClass); 473 | roughNode.setAttribute("attrX", d[this.labels]); 474 | roughNode.setAttribute("attrY", +d[this.values]); 475 | }); 476 | 477 | selectAll(this.interactionG) 478 | .selectAll("path:nth-child(2)") 479 | .style("stroke-width", this.strokeWidth); 480 | // If desired, add interactivity 481 | if (this.interactive === true) { 482 | this.addInteraction(); 483 | } 484 | } // draw 485 | } 486 | 487 | export default BarH; 488 | -------------------------------------------------------------------------------- /src/Chart.js: -------------------------------------------------------------------------------- 1 | import { select } from "d3-selection"; 2 | import { addFontGaegu, addFontIndieFlower } from "./utils/addFonts"; 3 | 4 | /** 5 | * Chart class ABC. 6 | */ 7 | class Chart { 8 | /** 9 | * Constructs a new Chart instance. 10 | * @param {Object} opts - Configuration object for the chart. 11 | */ 12 | constructor(opts) { 13 | this.el = opts.element; 14 | this.element = opts.element; 15 | this.title = opts.title; 16 | this.titleFontSize = opts.titleFontSize || "17px"; 17 | this.font = opts.font || 0; 18 | this.fillStyle = opts.fillStyle; 19 | this.tooltipFontSize = opts.tooltipFontSize || "0.95rem"; 20 | this.bowing = opts.bowing || 0; 21 | this.simplification = opts.simplification || 0.2; 22 | this.interactive = opts.interactive !== false; 23 | this.dataFormat = typeof opts.data === "object" ? "object" : "file"; 24 | } 25 | 26 | setSvg() { 27 | this.svg = select(this.el) 28 | .append("svg") 29 | .attr( 30 | "viewBox", 31 | `0 0 ${this.width + this.margin.left + this.margin.right} 32 | ${this.height + this.margin.top + this.margin.bottom}` 33 | ) 34 | .append("g") 35 | .attr("id", this.roughId) 36 | .attr( 37 | "transform", 38 | "translate(" + this.margin.left + "," + this.margin.top + ")" 39 | ); 40 | } 41 | 42 | resolveFont() { 43 | if ( 44 | this.font === 0 || 45 | this.font === undefined || 46 | this.font.toString().toLowerCase() === "gaegu" 47 | ) { 48 | addFontGaegu(this.svg); 49 | this.fontFamily = "gaeguregular"; 50 | } else if ( 51 | this.font === 1 || 52 | this.font.toString().toLowerCase() === "indie flower" 53 | ) { 54 | addFontIndieFlower(this.svg); 55 | this.fontFamily = "indie_flowerregular"; 56 | } else { 57 | this.fontFamily = this.font; 58 | } 59 | } 60 | } 61 | 62 | export default Chart; 63 | -------------------------------------------------------------------------------- /src/Donut.js: -------------------------------------------------------------------------------- 1 | import { csv, tsv, json } from "d3-fetch"; 2 | import { mouse, select, selectAll } from "d3-selection"; 3 | import { arc, pie } from "d3-shape"; 4 | import rough from "roughjs/bundled/rough.esm.js"; 5 | import Chart from "./Chart"; 6 | import { colors } from "./utils/colors"; 7 | import { addLegend } from "./utils/addLegend"; 8 | import { roughCeiling } from "./utils/roughCeiling"; 9 | 10 | /** 11 | * Donut chart class, which extends the Chart class. 12 | */ 13 | class Donut extends Chart { 14 | /** 15 | * Constructs a new Donut instance. 16 | * @param {Object} opts - Configuration object for the donut chart. 17 | */ 18 | constructor(opts) { 19 | super(opts); 20 | 21 | // load in arguments from config object 22 | // this.data = opts.data; 23 | this.margin = opts.margin || { top: 50, right: 20, bottom: 10, left: 20 }; 24 | this.colors = opts.colors || colors; 25 | this.highlight = opts.highlight; 26 | this.roughness = roughCeiling({ roughness: opts.roughness, ceiling: 30 }); 27 | this.strokeWidth = opts.strokeWidth || 0.75; 28 | this.innerStrokeWidth = opts.innerStrokeWidth || 0.75; 29 | this.fillWeight = opts.fillWeight || 0.85; 30 | this.labels = this.dataFormat === "object" ? "labels" : opts.labels; 31 | this.values = this.dataFormat === "object" ? "values" : opts.values; 32 | if (this.labels === undefined || this.values === undefined) { 33 | console.log(`Error for ${this.el}: Must include labels and values when \ 34 | instantiating Donut chart. Skipping chart.`); 35 | return; 36 | } 37 | this.legend = opts.legend !== false; 38 | this.legendPosition = opts.legendPosition || "right"; 39 | this.responsive = true; 40 | this.boundRedraw = this.redraw.bind(this, opts); 41 | // new width 42 | this.initChartValues(opts); 43 | // resolve font 44 | this.resolveFont(); 45 | // create the chart 46 | this.drawChart = this.resolveData(opts.data); 47 | this.drawChart(); 48 | if (opts.title !== "undefined") this.setTitle(opts.title); 49 | window.addEventListener("resize", this.resizeHandler.bind(this)); 50 | } 51 | 52 | /** 53 | * Handles window resize to redraw chart if responsive. 54 | */ 55 | resizeHandler() { 56 | if (this.responsive) { 57 | this.boundRedraw(); 58 | } 59 | } 60 | /** 61 | * Removes SVG elements and tooltips associated with the chart. 62 | */ 63 | remove() { 64 | select(this.el).select("svg").remove(); 65 | } 66 | /** 67 | * Redraws the bar chart with updated options. 68 | * @param {Object} opts - Updated configuration object for the bar chart. 69 | */ 70 | redraw(opts) { 71 | // 1. Remove the current SVG associated with the chart. 72 | this.remove(); 73 | 74 | // 2. Recalculate the size of the container. 75 | this.initChartValues(opts); 76 | 77 | // 3. Redraw everything. 78 | this.resolveFont(); 79 | this.drawChart = this.resolveData(opts.data); 80 | this.drawChart(); 81 | 82 | if (opts.title !== "undefined") { 83 | this.setTitle(opts.title); 84 | } 85 | } 86 | 87 | /** 88 | * Initialize the chart with default attributes. 89 | * @param {Object} opts - Configuration object for the chart. 90 | */ 91 | initChartValues(opts) { 92 | this.roughness = opts.roughness || this.roughness; 93 | this.stroke = opts.stroke || this.stroke; 94 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 95 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 96 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 97 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 98 | this.fillWeight = opts.fillWeight || this.fillWeight; 99 | this.fillStyle = opts.fillStyle || this.fillStyle; 100 | const divDimensions = select(this.el).node().getBoundingClientRect(); 101 | const width = divDimensions.width; 102 | const height = divDimensions.height; 103 | this.width = width - this.margin.left - this.margin.right; 104 | this.height = height - this.margin.top - this.margin.bottom; 105 | this.roughId = this.el + "_svg"; 106 | this.graphClass = this.el.substring(1, this.el.length); 107 | this.interactionG = "g." + this.graphClass; 108 | this.radius = Math.min(this.width, this.height) / 2; 109 | this.setSvg(); 110 | } 111 | 112 | // add this to abstract base 113 | resolveData(data) { 114 | if (typeof data === "string") { 115 | if (data.includes(".csv")) { 116 | return () => { 117 | csv(data).then((d) => { 118 | this.data = d; 119 | this.drawFromFile(); 120 | }); 121 | }; 122 | } else if (data.includes(".tsv")) { 123 | return () => { 124 | tsv(data).then((d) => { 125 | this.data = d; 126 | this.drawFromFile(); 127 | }); 128 | }; 129 | } else if (data.includes(".json")) { 130 | return () => { 131 | json(data).then((d) => { 132 | this.data = d; 133 | this.drawFromFile(); 134 | }); 135 | }; 136 | } 137 | } else { 138 | return () => { 139 | this.data = data; 140 | this.drawFromObject(); 141 | }; 142 | } 143 | } 144 | 145 | /** 146 | * Set the chart title with the given title. 147 | * @param {string} title - The title for the chart. 148 | */ 149 | setTitle(title) { 150 | this.svg 151 | .append("text") 152 | .attr("x", this.width / 2) 153 | .attr("y", 0 - this.margin.top / 3) 154 | .attr("class", "title") 155 | .attr("text-anchor", "middle") 156 | .style( 157 | "font-size", 158 | this.titleFontSize === undefined 159 | ? `${Math.min(40, Math.min(this.width, this.height) / 4)}px` 160 | : this.titleFontSize 161 | ) 162 | .style("font-family", this.fontFamily) 163 | .style("opacity", 0.8) 164 | .text(title); 165 | } 166 | 167 | /** 168 | * Add interaction elements to chart. 169 | */ 170 | addInteraction() { 171 | selectAll(this.interactionG) 172 | .append("g") 173 | .attr("transform", `translate(${this.width / 2}, ${this.height / 2})`) 174 | .data( 175 | this.dataFormat === "object" 176 | ? this.makePie(this.data[this.values]) 177 | : this.makePie(this.data) 178 | ) 179 | .append("path") 180 | .attr("d", this.makeArc) 181 | .attr("stroke-width", "0px") 182 | .attr("fill", "transparent"); 183 | 184 | // create tooltip 185 | const Tooltip = select(this.el) 186 | .append("div") 187 | .style("opacity", 0) 188 | .attr("class", "tooltip") 189 | .style("position", "absolute") 190 | .style("background-color", "white") 191 | .style("border", "solid") 192 | .style("border-width", "1px") 193 | .style("border-radius", "5px") 194 | .style("padding", "3px") 195 | .style("font-family", this.fontFamily) 196 | .style("font-size", this.tooltipFontSize) 197 | .style("pointer-events", "none"); 198 | 199 | // event functions 200 | let mouseover = function (d) { 201 | Tooltip.style("opacity", 1); 202 | }; 203 | 204 | const that = this; 205 | let thisColor; 206 | 207 | let mousemove = function (d) { 208 | const attrX = select(this).attr("attrX"); 209 | const attrY = select(this).attr("attrY"); 210 | const mousePos = mouse(this); 211 | // get size of enclosing div 212 | Tooltip.html(`${attrX}: ${attrY}`) 213 | .style("opacity", 0.95) 214 | .style( 215 | "transform", 216 | `translate(${mousePos[0] + that.margin.left}px, 217 | ${ 218 | mousePos[1] - 219 | (that.height + that.margin.top + that.margin.bottom / 2) 220 | }px)` 221 | ); 222 | }; 223 | let mouseleave = function (d) { 224 | Tooltip.style("opacity", 0); 225 | }; 226 | 227 | // d3 event handlers 228 | selectAll(this.interactionG).on("mouseover", function () { 229 | mouseover(); 230 | thisColor = select(this).selectAll("path").style("stroke"); 231 | that.highlight === undefined 232 | ? select(this).selectAll("path").style("opacity", 0.5) 233 | : select(this).selectAll("path").style("stroke", that.highlight); 234 | }); 235 | 236 | selectAll(this.interactionG).on("mouseout", function () { 237 | mouseleave(); 238 | select(this).selectAll("path").style("stroke", thisColor); 239 | select(this).selectAll("path").style("opacity", 1); 240 | }); 241 | 242 | selectAll(this.interactionG).on("mousemove", mousemove); 243 | } 244 | 245 | /** 246 | * Draw rough SVG elements on chart. 247 | */ 248 | initRoughObjects() { 249 | this.roughSvg = document.getElementById(this.roughId); 250 | this.rcAxis = rough.svg(this.roughSvg, { 251 | options: { strokeWidth: this.strokeWidth >= 3 ? 3 : this.strokeWidth }, 252 | }); 253 | this.rc = rough.svg(this.roughSvg, { 254 | options: { 255 | fill: this.color, 256 | strokeWidth: this.innerStrokeWidth, 257 | roughness: this.roughness, 258 | bowing: this.bowing, 259 | fillStyle: this.fillStyle, 260 | fillWeight: this.fillWeight, 261 | }, 262 | }); 263 | } 264 | 265 | /** 266 | * Draw chart from object input. 267 | */ 268 | drawFromObject() { 269 | this.initRoughObjects(); 270 | 271 | this.makePie = pie(); 272 | 273 | this.makeArc = arc().innerRadius(0).outerRadius(this.radius); 274 | 275 | this.arcs = this.makePie(this.data[this.values]); 276 | 277 | this.arcs.forEach((d, i) => { 278 | if (d.value !== 0) { 279 | const node = this.rc.arc( 280 | this.width / 2, // x 281 | this.height / 2, // y 282 | 2 * this.radius, // width 283 | 2 * this.radius, // height 284 | d.startAngle - Math.PI / 2, // start 285 | d.endAngle - Math.PI / 2, // stop 286 | true, 287 | { 288 | fill: this.colors[i], 289 | stroke: this.colors[i], 290 | } 291 | ); 292 | node.setAttribute("class", this.graphClass); 293 | const roughNode = this.roughSvg.appendChild(node); 294 | roughNode.setAttribute("attrY", this.data[this.values][i]); 295 | roughNode.setAttribute("attrX", this.data[this.labels][i]); 296 | } 297 | }); 298 | 299 | const donutNode = this.rc.circle( 300 | this.width / 2, 301 | this.height / 2, 302 | this.radius, 303 | { 304 | fill: "white", 305 | strokeWidth: 0.05, 306 | fillWeight: 10, 307 | fillStyle: "solid", 308 | } 309 | ); 310 | this.roughSvg.appendChild(donutNode); 311 | 312 | selectAll(this.interactionG) 313 | .selectAll("path:nth-child(2)") 314 | .style("stroke-width", this.strokeWidth); 315 | 316 | // ADD LEGEND 317 | const dataSources = this.data.labels; 318 | const legendItems = dataSources.map((key, i) => ({ 319 | color: this.colors[i], 320 | text: key, 321 | })); 322 | // find length of longest text item 323 | const legendWidth = 324 | legendItems.reduce( 325 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 326 | 0 327 | ) * 328 | 6 + 329 | 35; 330 | const legendHeight = legendItems.length * 11 + 8; 331 | 332 | if (this.legend === true) { 333 | addLegend(this, legendItems, legendWidth, legendHeight); 334 | } 335 | 336 | // If desired, add interactivity 337 | if (this.interactive === true) { 338 | this.addInteraction(); 339 | } 340 | } 341 | 342 | /** 343 | * Draw chart from file. 344 | */ 345 | drawFromFile() { 346 | this.initRoughObjects(); 347 | 348 | this.makePie = pie() 349 | .value((d) => d[this.values]) 350 | .sort(null); 351 | 352 | const valueArr = []; 353 | this.makeArc = arc().innerRadius(0).outerRadius(this.radius); 354 | 355 | this.arcs = this.makePie(this.data); 356 | 357 | this.arcs.forEach((d, i) => { 358 | if (d.value !== 0) { 359 | const node = this.rc.arc( 360 | this.width / 2, // x 361 | this.height / 2, // y 362 | 2 * this.radius, // width 363 | 2 * this.radius, // height 364 | d.startAngle - Math.PI / 2, // start 365 | d.endAngle - Math.PI / 2, // stop 366 | true, 367 | { 368 | fill: this.colors[i], 369 | stroke: this.colors[i], 370 | } 371 | ); 372 | node.setAttribute("class", this.graphClass); 373 | const roughNode = this.roughSvg.appendChild(node); 374 | roughNode.setAttribute("attrY", d.data[this.values]); 375 | roughNode.setAttribute("attrX", d.data[this.labels]); 376 | } 377 | valueArr.push(d.data[this.labels]); 378 | }); 379 | 380 | const donutNode = this.rc.circle( 381 | this.width / 2, 382 | this.height / 2, 383 | this.radius, 384 | { 385 | fill: "white", 386 | strokeWidth: 0.05, 387 | fillWeight: 10, 388 | fillStyle: "solid", 389 | } 390 | ); 391 | this.roughSvg.appendChild(donutNode); 392 | 393 | selectAll(this.interactionG) 394 | .selectAll("path:nth-child(2)") 395 | .style("stroke-width", this.strokeWidth); 396 | 397 | // ADD LEGEND 398 | const dataSources = valueArr; 399 | const legendItems = dataSources.map((key, i) => ({ 400 | color: this.colors[i], 401 | text: key, 402 | })); 403 | // find length of longest text item 404 | const legendWidth = 405 | legendItems.reduce( 406 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 407 | 0 408 | ) * 409 | 6 + 410 | 35; 411 | const legendHeight = legendItems.length * 11 + 8; 412 | 413 | if (this.legend === true) { 414 | addLegend(this, legendItems, legendWidth, legendHeight); 415 | } 416 | 417 | // If desired, add interactivity 418 | if (this.interactive === true) { 419 | this.addInteraction(); 420 | } 421 | } // draw 422 | } 423 | 424 | export default Donut; 425 | -------------------------------------------------------------------------------- /src/Force.js: -------------------------------------------------------------------------------- 1 | import { mouse, select, selectAll } from "d3-selection"; 2 | import rough from "roughjs/bundled/rough.esm.js"; 3 | import Chart from "./Chart"; 4 | import { colors } from "./utils/colors"; 5 | import { addLegend } from "./utils/addLegend"; 6 | import { roughCeiling } from "./utils/roughCeiling"; 7 | import { forceSimulation, forceCollide, forceCenter } from "d3-force"; 8 | import { min, max } from "d3-array"; 9 | import { scaleLinear } from "d3-scale"; 10 | 11 | /** 12 | * Force chart class, which extends the Chart class. 13 | */ 14 | class Force extends Chart { 15 | /** 16 | * Constructs a new Force instance. 17 | * @param {Object} opts - Configuration object for the force chart. 18 | */ 19 | constructor(opts) { 20 | super(opts); 21 | this.data = opts.data; 22 | this.margin = opts.margin || { top: 50, right: 20, bottom: 10, left: 20 }; 23 | this.colors = opts.colors || colors; 24 | this.highlight = opts.highlight; 25 | this.roughness = roughCeiling({ 26 | roughness: opts.roughness, 27 | ceiling: 30, 28 | defaultValue: 0, 29 | }); 30 | this.strokeWidth = opts.strokeWidth || 0.75; 31 | this.innerStrokeWidth = opts.innerStrokeWidth || 0.75; 32 | this.fillWeight = opts.fillWeight || 0.85; 33 | this.color = opts.color || "pink"; 34 | this.collision = opts.collision || 1; 35 | this.radiusExtent = opts.radiusExtent || [5, 20]; 36 | this.radius = opts.radius || "radius"; 37 | this.roughnessExtent = opts.roughnessExtent || [0, 10]; 38 | this.responsive = true; 39 | this.boundRedraw = this.redraw.bind(this, opts); 40 | const defaultTextCallback = (d) => ""; 41 | this.textCallback = opts.textCallback || defaultTextCallback; 42 | const defaultColorCallback = (d) => this.color; 43 | this.colorCallback = opts.colorCallback || defaultColorCallback; 44 | this.legend = opts.legend || false; 45 | this.legendPosition = opts.legendPosition || "right"; 46 | // new width 47 | this.initChartValues(opts); 48 | // resolve font 49 | this.resolveFont(); 50 | // create the chart 51 | this.drawChart = this.resolveData(opts.data); 52 | this.drawChart(); 53 | if (opts.title !== "undefined") this.setTitle(opts.title); 54 | } 55 | 56 | /** 57 | * Handles window resize to redraw chart if responsive. 58 | */ 59 | resizeHandler() { 60 | if (this.responsive) { 61 | this.boundRedraw(); 62 | } 63 | } 64 | 65 | /** 66 | * Removes SVG elements and tooltips associated with the chart. 67 | */ 68 | remove() { 69 | select(this.el).select("svg").remove(); 70 | } 71 | 72 | /** 73 | * Redraws the bar chart with updated options. 74 | * @param {Object} opts - Updated configuration object for the bar chart. 75 | */ 76 | redraw(opts) { 77 | // 1. Remove the current SVG associated with the chart. 78 | this.remove(); 79 | 80 | // 2. Recalculate the size of the container. 81 | this.initChartValues(opts); 82 | 83 | // 3. Redraw everything. 84 | this.resolveFont(); 85 | this.drawChart = this.resolveData(opts.data); 86 | this.drawChart(); 87 | 88 | if (opts.title !== "undefined") { 89 | this.setTitle(opts.title); 90 | } 91 | } 92 | 93 | /** 94 | * Initialize the chart with default attributes. 95 | * @param {Object} opts - Configuration object for the chart. 96 | */ 97 | initChartValues(opts) { 98 | this.roughness = opts.roughness || this.roughness; 99 | this.collision = opts.collision || this.collision; 100 | this.color = opts.color || this.color; 101 | this.stroke = opts.stroke || this.stroke; 102 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 103 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 104 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 105 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 106 | this.fillWeight = opts.fillWeight || this.fillWeight; 107 | this.fillStyle = opts.fillStyle || this.fillStyle; 108 | this.title = opts.title || this.title; 109 | const defaultTextCallback = (d) => ""; 110 | this.textCallback = opts.textCallback || defaultTextCallback; 111 | 112 | const divDimensions = select(this.el).node().getBoundingClientRect(); 113 | const width = divDimensions.width; 114 | const height = divDimensions.height; 115 | this.width = width - this.margin.left - this.margin.right; 116 | this.height = height - this.margin.top - this.margin.bottom; 117 | this.roughId = this.el + "_svg"; 118 | this.graphClass = this.el.substring(1, this.el.length); 119 | this.interactionG = "g." + this.graphClass; 120 | this.setSvg(); 121 | } 122 | 123 | // add this to abstract base 124 | resolveData(data) { 125 | return () => { 126 | this.data = data; 127 | this.drawFromObject(); 128 | }; 129 | } 130 | 131 | /** 132 | * Set the chart title with the given title. 133 | * @param {string} title - The title for the chart. 134 | */ 135 | setTitle(title) { 136 | this.svg 137 | .append("text") 138 | .attr("x", this.width / 2) 139 | .attr("y", 0 - this.margin.top / 3) 140 | .attr("class", "title") 141 | .attr("text-anchor", "middle") 142 | .style( 143 | "font-size", 144 | this.titleFontSize === undefined 145 | ? `${Math.min(40, Math.min(this.width, this.height) / 4)}px` 146 | : this.titleFontSize 147 | ) 148 | .style("font-family", this.fontFamily) 149 | .style("opacity", 0.8) 150 | .text(title); 151 | } 152 | 153 | /** 154 | * Add interaction elements to chart. 155 | */ 156 | addInteraction() { 157 | const that = this; 158 | let thisColor; 159 | 160 | let mouseleave = function (d) { 161 | select(this).selectAll("path:nth-child(1)").style("opacity", 1); 162 | select(this).selectAll("path:nth-child(1)").style("stroke", thisColor); 163 | select(this) 164 | .selectAll("path:nth-child(2)") 165 | .style("stroke-width", that.strokeWidth); 166 | 167 | select(this).select(".node-text").attr("opacity", 0); 168 | }; 169 | 170 | let mouseover = function (d) { 171 | thisColor = select(this).selectAll("path").style("stroke"); 172 | select(this).raise(); 173 | that.highlight === undefined 174 | ? select(this).selectAll("path:nth-child(1)").style("opacity", 0.4) 175 | : select(this) 176 | .selectAll("path:nth-child(1)") 177 | .style("stroke", that.highlight); 178 | select(this) 179 | .selectAll("path:nth-child(2)") 180 | .style("stroke-width", that.strokeWidth + 1.2); 181 | 182 | select(this).select(".node-text").attr("opacity", 1); 183 | 184 | select(this).select(".node-text").raise(); 185 | }; 186 | 187 | selectAll(".nodeGroup") 188 | .on("mouseover", mouseover) 189 | .on("mouseleave", mouseleave); 190 | } 191 | 192 | /** 193 | * Draw rough SVG elements on chart. 194 | */ 195 | initRoughObjects() { 196 | this.roughSvg = document.getElementById(this.roughId); 197 | this.rcAxis = rough.svg(this.roughSvg, { 198 | options: { strokeWidth: this.strokeWidth >= 3 ? 3 : this.strokeWidth }, 199 | }); 200 | this.rc = rough.svg(this.roughSvg, { 201 | options: { 202 | strokeWidth: this.innerStrokeWidth, 203 | fill: this.color, 204 | stroke: this.stroke === "none" ? undefined : this.stroke, 205 | roughness: this.roughness, 206 | bowing: this.bowing, 207 | fillStyle: this.fillStyle, 208 | }, 209 | }); 210 | } 211 | 212 | /** 213 | * Draw chart from object input. 214 | */ 215 | drawFromObject() { 216 | const that = this; 217 | let radiusScale; 218 | let roughnessScale; 219 | 220 | if (typeof this.radius === "number") { 221 | radiusScale = scaleLinear() 222 | .domain([0, 1]) 223 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 224 | } else { 225 | const dataMin = min(this.data, (d) => +d[this.radius]); 226 | const dataMax = max(this.data, (d) => +d[this.radius]); 227 | 228 | // Create a scale based on data's min and max values 229 | radiusScale = scaleLinear() 230 | .domain([dataMin, dataMax]) 231 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 232 | } 233 | 234 | if (typeof this.roughness === "number") { 235 | roughnessScale = scaleLinear() 236 | .domain([0, 1]) 237 | .range([this.roughnessExtent[0], this.roughnessExtent[1]]); 238 | } else { 239 | const roughnessMin = min(this.data, (d) => +d[this.radius]); 240 | const roughnessMax = max(this.data, (d) => +d[this.radius]); 241 | 242 | // Create a scale based on data's min and max values 243 | roughnessScale = scaleLinear() 244 | .domain([roughnessMin, roughnessMax]) 245 | .range([this.roughnessExtent[0], this.roughnessExtent[1]]); 246 | } 247 | 248 | this.initRoughObjects(); 249 | 250 | let nodeGroups = this.svg.selectAll(".nodeGroup").data(this.data); 251 | 252 | let nodeGroupsEnter = nodeGroups 253 | .enter() 254 | .append("g") 255 | .attr("class", "nodeGroup"); 256 | 257 | nodeGroups = nodeGroups.merge(nodeGroupsEnter); 258 | 259 | nodeGroups.each(function (d, i) { 260 | const nodeRadius = 261 | typeof that.radius === "number" 262 | ? that.radius 263 | : radiusScale(d[that.radius]); 264 | 265 | const nodeRoughness = 266 | typeof that.roughness === "number" 267 | ? that.roughness 268 | : roughnessScale(d[that.roughness]); 269 | 270 | const node = that.rc.circle(0, 0, nodeRadius, { 271 | fill: that.colorCallback(d), 272 | simplification: that.simplification, 273 | fillWeight: that.fillWeight, 274 | roughness: nodeRoughness, 275 | }); 276 | 277 | const roughNode = this.appendChild(node); 278 | roughNode.setAttribute("class", that.graphClass + "_node"); 279 | 280 | select(this) 281 | .append("circle") 282 | .attr("class", "node-circle") 283 | .attr("r", nodeRadius * 0.5) 284 | .attr("fill", "transparent") 285 | .attr("stroke-width", 0) 286 | .attr("stroke", "none"); 287 | 288 | select(this) 289 | .append("text") 290 | .attr("class", "node-text") 291 | .attr("x", 0) 292 | .attr("y", -10) // Adjust 15 based on your needs 293 | .attr("text-anchor", "middle") 294 | .style("pointer-events", "none") 295 | .attr("stroke", "black") 296 | .attr("fill", "white") 297 | .attr("stroke-linejoin", "fill") 298 | .attr("paint-order", "stroke fill") 299 | .attr("stroke-width", "5px") 300 | .attr("opacity", 0) 301 | .text((d) => that.textCallback(d)); 302 | }); 303 | 304 | const simulation = forceSimulation(this.data); 305 | simulation.alpha(1).restart(); 306 | 307 | simulation 308 | .force( 309 | "collide", 310 | forceCollide().radius((d) => d.radius * this.collision * 1.2) 311 | ) 312 | .force("center", forceCenter(this.width / 2, this.height / 2)); 313 | 314 | simulation.on("tick", () => { 315 | nodeGroups.attr("transform", (d) => `translate(${d.x}, ${d.y})`); 316 | nodeGroups.attr("attrX", (d) => +d.x); 317 | nodeGroups.attr("attrY", (d) => +d.y); 318 | }); 319 | 320 | selectAll(".nodeGroup") 321 | .selectAll("path:nth-child(2)") 322 | .style("stroke-width", this.strokeWidth); 323 | 324 | if (this.interactive === true) { 325 | this.addInteraction(); 326 | } 327 | 328 | if (this.legend) { 329 | const legendItems = this.legend; 330 | this.colors = this.legend.map((item) => item.color); 331 | 332 | const legendWidth = 333 | legendItems.reduce( 334 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 335 | 0 336 | ) * 337 | 6 + 338 | 35; 339 | const legendHeight = legendItems.length * 11 + 8; 340 | 341 | addLegend(this, legendItems, legendWidth, legendHeight); 342 | } 343 | } 344 | } 345 | 346 | export default Force; 347 | -------------------------------------------------------------------------------- /src/Line.js: -------------------------------------------------------------------------------- 1 | import { bisect, extent, max, min, range } from "d3-array"; 2 | import { axisBottom, axisLeft } from "d3-axis"; 3 | import { csv, tsv } from "d3-fetch"; 4 | import { format } from "d3-format"; 5 | import { scaleLinear, scalePoint } from "d3-scale"; 6 | import { mouse, select, selectAll } from "d3-selection"; 7 | import { line } from "d3-shape"; 8 | import rough from "roughjs/bundled/rough.esm.js"; 9 | import Chart from "./Chart"; 10 | import { addLegend } from "./utils/addLegend"; 11 | import { colors } from "./utils/colors"; 12 | import { roughCeiling } from "./utils/roughCeiling"; 13 | 14 | const allDataExtent = (data) => { 15 | // get extend for all keys in data 16 | const keys = Object.keys(data); 17 | const extents = keys.map((key) => extent(data[key])); 18 | const dataMin = min(extents, (d) => d[0]); 19 | const dataMax = max(extents, (d) => d[1]); 20 | return [dataMin, dataMax]; 21 | }; 22 | 23 | /** 24 | * Line chart class, which extends the Chart class. 25 | */ 26 | class Line extends Chart { 27 | /** 28 | * Constructs a new Line instance. 29 | * @param {Object} opts - Configuration object for the line chart. 30 | */ 31 | constructor(opts) { 32 | super(opts); 33 | 34 | // load in arguments from config object 35 | this.margin = opts.margin || { top: 50, right: 20, bottom: 50, left: 100 }; 36 | this.roughness = roughCeiling({ 37 | roughness: opts.roughness, 38 | defaultValue: 2.2, 39 | }); 40 | this.axisStrokeWidth = opts.axisStrokeWidth || 0.5; 41 | this.axisRoughness = opts.axisRoughness || 0.5; 42 | this.stroke = opts.stroke || "black"; 43 | this.fillWeight = opts.fillWeight || 0.5; 44 | this.colors = opts.colors; 45 | this.strokeWidth = opts.strokeWidth || 1; 46 | this.axisFontSize = opts.axisFontSize; 47 | this.x = opts.x; 48 | this.y = this.dataFormat === "object" ? "y" : opts.y; 49 | this.xValueFormat = opts.xValueFormat; 50 | this.yValueFormat = opts.yValueFormat; 51 | this.legend = opts.legend !== false; 52 | this.legendPosition = opts.legendPosition || "right"; 53 | this.circle = opts.circle !== false; 54 | this.circleRadius = opts.circleRadius || 10; 55 | this.circleRoughness = roughCeiling({ 56 | roughness: opts.circleRoughness, 57 | defaultValue: 2, 58 | }); 59 | this.xLabel = opts.xLabel || ""; 60 | this.yLabel = opts.yLabel || ""; 61 | this.labelFontSize = opts.labelFontSize || "1rem"; 62 | if (this.dataFormat === "file") { 63 | this.dataSources = []; 64 | this.yKeys = Object.keys(opts).filter((name) => /y/.test(name)); 65 | this.yKeys.map((key, i) => { 66 | if (key !== "yLabel") this.dataSources.push(opts[key]); 67 | }); 68 | } 69 | this.responsive = true; 70 | this.boundRedraw = this.redraw.bind(this, opts); 71 | // new width 72 | this.initChartValues(opts); 73 | // resolve font 74 | this.resolveFont(); 75 | // create the chart 76 | this.drawChart = this.resolveData(opts.data); 77 | this.drawChart(); 78 | if (opts.title !== "undefined") this.setTitle(opts.title); 79 | window.addEventListener("resize", this.resizeHandler.bind(this)); 80 | } 81 | 82 | /** 83 | * Handles window resize to redraw chart if responsive. 84 | */ 85 | resizeHandler() { 86 | if (this.responsive) { 87 | this.boundRedraw(); 88 | } 89 | } 90 | 91 | /** 92 | * Removes SVG elements and tooltips associated with the chart. 93 | */ 94 | remove() { 95 | select(this.el).select("svg").remove(); 96 | } 97 | 98 | /** 99 | * Redraws the bar chart with updated options. 100 | * @param {Object} opts - Updated configuration object for the bar chart. 101 | */ 102 | redraw(opts) { 103 | // 1. Remove the current SVG associated with the chart. 104 | this.remove(); 105 | 106 | // 2. Recalculate the size of the container. 107 | this.initChartValues(opts); 108 | 109 | // 3. Redraw everything. 110 | this.resolveFont(); 111 | this.drawChart = this.resolveData(opts.data); 112 | this.drawChart(); 113 | 114 | if (opts.title !== "undefined") { 115 | this.setTitle(opts.title); 116 | } 117 | } 118 | 119 | /** 120 | * Initialize the chart with default attributes. 121 | * @param {Object} opts - Configuration object for the chart. 122 | */ 123 | initChartValues(opts) { 124 | this.roughness = opts.roughness || this.roughness; 125 | this.stroke = opts.stroke || this.stroke; 126 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 127 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 128 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 129 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 130 | this.fillWeight = opts.fillWeight || this.fillWeight; 131 | this.fillStyle = opts.fillStyle || this.fillStyle; 132 | const divDimensions = select(this.el).node().getBoundingClientRect(); 133 | const width = divDimensions.width; 134 | const height = divDimensions.height; 135 | this.width = width - this.margin.left - this.margin.right; 136 | this.height = height - this.margin.top - this.margin.bottom; 137 | this.roughId = this.el + "_svg"; 138 | this.graphClass = this.el.substring(1, this.el.length); 139 | this.interactionG = "g." + this.graphClass; 140 | this.setSvg(); 141 | } 142 | 143 | // add this to abstract base 144 | resolveData(data) { 145 | if (typeof data === "string") { 146 | if (data.includes(".csv")) { 147 | return () => { 148 | csv(data).then((d) => { 149 | this.data = d; 150 | this.drawFromFile(); 151 | }); 152 | }; 153 | } else if (data.includes(".tsv")) { 154 | return () => { 155 | tsv(data).then((d) => { 156 | this.data = d; 157 | this.drawFromFile(); 158 | }); 159 | }; 160 | } 161 | } else { 162 | return () => { 163 | this.data = data; 164 | this.drawFromObject(); 165 | }; 166 | } 167 | } 168 | 169 | addScales() { 170 | let dataExtent; 171 | if (this.dataFormat !== "file") { 172 | dataExtent = allDataExtent(this.data); 173 | } else { 174 | const extents = this.dataSources.map((key) => 175 | extent(this.data, (d) => +d[key]) 176 | ); 177 | const dataMin = min(extents, (d) => d[0]); 178 | const dataMax = max(extents, (d) => d[1]); 179 | dataExtent = [dataMin, dataMax]; 180 | } 181 | // get value domains and pad axes by 5% 182 | // if this.x is undefined, use index for x 183 | let xExtent; 184 | if (this.x === undefined) { 185 | // get length of longest array 186 | const keys = Object.keys(this.data); 187 | const lengths = keys.map((key) => this.data[key].length); 188 | const maxLen = max(lengths); 189 | // Need to make xScale, when this.x is given, ordinal. 190 | xExtent = 191 | this.dataFormat === "file" ? [0, this.data.length] : [0, maxLen]; 192 | } else { 193 | xExtent = extent(this.x); 194 | } 195 | 196 | const yExtent = dataExtent; 197 | 198 | const yRange = yExtent[1] - yExtent[0]; 199 | 200 | this.xScale = 201 | this.x === undefined 202 | ? scalePoint() 203 | .range([0, this.width]) 204 | .domain([...Array(xExtent[1]).keys()]) 205 | : scalePoint().range([0, this.width]).domain(this.x); 206 | 207 | this.yScale = scaleLinear() 208 | .range([this.height, 0]) 209 | .domain([0, yExtent[1] + yRange * 0.05]); 210 | } 211 | 212 | /** 213 | * Create x and y labels for chart. 214 | */ 215 | addLabels() { 216 | // xLabel 217 | if (this.xLabel !== "") { 218 | this.svg 219 | .append("text") 220 | .attr("x", this.width / 2) 221 | .attr("y", this.height + this.margin.bottom / 1.3) 222 | .attr("dx", "1em") 223 | .attr("class", "labelText") 224 | .style("text-anchor", "middle") 225 | .style("font-family", this.fontFamily) 226 | .style("font-size", this.labelFontSize) 227 | .text(this.xLabel); 228 | } 229 | // yLabel 230 | if (this.yLabel !== "") { 231 | this.svg 232 | .append("text") 233 | .attr("transform", "rotate(-90)") 234 | .attr("y", 0 - this.margin.left / 2) 235 | .attr("x", 0 - this.height / 2) 236 | .attr("dy", "1em") 237 | .attr("class", "labelText") 238 | .style("text-anchor", "middle") 239 | .style("font-family", this.fontFamily) 240 | .style("font-size", this.labelFontSize) 241 | .text(this.yLabel); 242 | } 243 | } 244 | 245 | /** 246 | * Create x and y axes for chart. 247 | */ 248 | addAxes() { 249 | const xAxis = axisBottom(this.xScale) 250 | .tickSize(0) 251 | .tickFormat((d) => { 252 | return this.xValueFormat ? format(this.xValueFormat)(d) : d; 253 | }); 254 | 255 | const yAxis = axisLeft(this.yScale) 256 | .tickSize(0) 257 | .tickFormat((d) => { 258 | return this.yValueFormat ? format(this.yValueFormat)(d) : d; 259 | }); 260 | 261 | // x-axis 262 | this.svg 263 | .append("g") 264 | .attr("transform", "translate(0," + this.height + ")") 265 | .call(xAxis) 266 | .attr("class", `xAxis${this.graphClass}`) 267 | .selectAll("text") 268 | .attr("transform", "translate(-10, 0)rotate(-45)") 269 | .style("text-anchor", "end") 270 | .style("font-family", this.fontFamily) 271 | .style( 272 | "font-size", 273 | this.axisFontSize === undefined 274 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 275 | : this.axisFontSize 276 | ); 277 | 278 | // y-axis 279 | this.svg 280 | .append("g") 281 | .call(yAxis) 282 | .attr("class", `yAxis${this.graphClass}`) 283 | .selectAll("text") 284 | .style("font-family", this.fontFamily) 285 | .style( 286 | "font-size", 287 | this.axisFontSize === undefined 288 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 289 | : this.axisFontSize 290 | ); 291 | 292 | // hide original axes 293 | selectAll("path.domain").attr("stroke", "transparent"); 294 | 295 | selectAll("g.tick").style("opacity", 1); 296 | } 297 | 298 | makeAxesRough(roughSvg, rcAxis) { 299 | const xAxisClass = `xAxis${this.graphClass}`; 300 | const yAxisClass = `yAxis${this.graphClass}`; 301 | const roughXAxisClass = `rough-${xAxisClass}`; 302 | const roughYAxisClass = `rough-${yAxisClass}`; 303 | 304 | select(`.${xAxisClass}`) 305 | .selectAll("path.domain") 306 | .each(function (d, i) { 307 | const pathD = select(this).node().getAttribute("d"); 308 | const roughXAxis = rcAxis.path(pathD, { 309 | stroke: "black", 310 | fillStyle: "hachure", 311 | }); 312 | roughXAxis.setAttribute("class", roughXAxisClass); 313 | roughSvg.appendChild(roughXAxis); 314 | }); 315 | selectAll(`.${roughXAxisClass}`).attr( 316 | "transform", 317 | `translate(0, ${this.height})` 318 | ); 319 | 320 | select(`.${yAxisClass}`) 321 | .selectAll("path.domain") 322 | .each(function (d, i) { 323 | const pathD = select(this).node().getAttribute("d"); 324 | const roughYAxis = rcAxis.path(pathD, { 325 | stroke: "black", 326 | fillStyle: "hachure", 327 | }); 328 | roughYAxis.setAttribute("class", roughYAxisClass); 329 | roughSvg.appendChild(roughYAxis); 330 | }); 331 | } 332 | 333 | /** 334 | * Set the chart title with the given title. 335 | * @param {string} title - The title for the chart. 336 | */ 337 | setTitle(title) { 338 | this.svg 339 | .append("text") 340 | .attr("x", this.width / 2) 341 | .attr("y", 0 - this.margin.top / 2) 342 | .attr("text-anchor", "middle") 343 | .style( 344 | "font-size", 345 | this.titleFontSize === undefined 346 | ? `${Math.min(20, Math.min(this.width, this.height) / 4)}px` 347 | : this.titleFontSize 348 | ) 349 | .style("font-family", this.fontFamily) 350 | .style("opacity", 0.8) 351 | .text(title); 352 | } 353 | 354 | /** 355 | * Add interaction elements to chart. 356 | */ 357 | addInteraction() { 358 | const that = this; 359 | this.chartScreen = this.svg.append("g").attr("pointer-events", "all"); 360 | 361 | this.dataSources.map((key, idx) => { 362 | const yValues = this.dataFormat === "file" ? this.data : this.data[key]; 363 | const points = yValues.map((d, i) => { 364 | return this.x === undefined 365 | ? [this.xScale(i), this.yScale(d[key])] 366 | : [this.xScale(this.x[i]), this.yScale(+d[key])]; 367 | }); 368 | 369 | // remove undefined elements so no odd behavior 370 | const drawPoints = points.filter((d) => d[0] !== undefined); 371 | 372 | const lineGen = line() 373 | .x((d) => d[0]) 374 | .y((d) => d[1]); 375 | 376 | // create lines 377 | this.svg 378 | .append("path") 379 | .datum(drawPoints) 380 | .attr("fill", "none") 381 | .attr("stroke", "blue") 382 | .attr("stroke-width", 1.5) 383 | .attr("d", lineGen) 384 | .attr("visibility", "hidden"); 385 | 386 | // create tracking class (for interaction) 387 | const iClass = key + "class"; 388 | 389 | // create hover text 390 | this.svg 391 | .append("g") 392 | .attr("class", iClass + "text") 393 | .append("text") 394 | .style("font-size", this.tooltipFontSize) 395 | .style("opacity", 0) 396 | .style("font-family", this.fontFamily) 397 | .attr("text-anchor", "middle") 398 | .attr("alignment-baseline", "middle"); 399 | }); 400 | 401 | const mousemove = function (d) { 402 | // recover coordinate we need 403 | const xPos = mouse(this)[0]; 404 | const domain = that.xScale.domain(); 405 | const xRange = that.xScale.range(); 406 | const rangePoints = range(xRange[0], xRange[1] + 1, that.xScale.step()); 407 | const xSpot = bisect(rangePoints, xPos); 408 | const yPos = domain[xSpot]; 409 | 410 | that.dataSources.map((key, i) => { 411 | const hoverData = 412 | that.dataFormat === "file" 413 | ? that.x === undefined 414 | ? that.data[yPos] 415 | : that.data[xSpot] 416 | : that.data[key][xSpot]; 417 | // resolve select classes for hover effects 418 | const thatClass = "." + key + "class"; 419 | const textClass = thatClass + "text"; 420 | 421 | if (that.dataFormat === "file") { 422 | select(textClass) 423 | .selectAll("text") 424 | .style("opacity", 1) 425 | .html( 426 | that.x === undefined 427 | ? `(${xSpot},${hoverData[key]})` 428 | : `(${that.x[xSpot]}, ${hoverData[key]})` 429 | ) 430 | .attr( 431 | "x", 432 | that.x === undefined 433 | ? that.xScale(xSpot) 434 | : that.xScale(that.x[xSpot]) 435 | ) 436 | .attr("y", that.yScale(hoverData[key]) - 6); 437 | } else { 438 | select(textClass) 439 | .selectAll("text") 440 | .style("opacity", 1) 441 | .html( 442 | that.x === undefined 443 | ? `(${xSpot}, ${hoverData})` 444 | : `(${that.x[xSpot]}, ${hoverData})` 445 | ) 446 | .attr( 447 | "x", 448 | that.x === undefined 449 | ? that.xScale(xSpot) 450 | : that.xScale(that.x[xSpot]) 451 | ) 452 | .attr("y", that.yScale(hoverData)); 453 | } 454 | }); 455 | }; 456 | 457 | this.chartScreen 458 | .append("rect") 459 | .attr("width", this.width) 460 | .attr("height", this.height) 461 | .attr("fill", "none") 462 | .on("mousemove", mousemove) 463 | .on("mouseout", () => { 464 | that.dataSources.map((key) => { 465 | const thatClass = "." + key + "class"; 466 | const textClass = thatClass + "text"; 467 | select(textClass).selectAll("text").style("opacity", 0); 468 | }); 469 | }); 470 | } 471 | 472 | /** 473 | * Draw rough SVG elements on chart. 474 | */ 475 | initRoughObjects() { 476 | this.roughSvg = document.getElementById(this.roughId); 477 | this.rcAxis = rough.svg(this.roughSvg, { 478 | options: { 479 | strokeWidth: this.axisStrokeWidth, 480 | roughness: this.axisRoughness, 481 | }, 482 | }); 483 | this.rc = rough.svg(this.roughSvg, { 484 | options: { 485 | stroke: this.stroke === "none" ? undefined : this.stroke, 486 | strokeWidth: this.strokeWidth, 487 | roughness: this.roughness, 488 | bowing: this.bowing, 489 | fillStyle: this.fillStyle, 490 | }, 491 | }); 492 | } 493 | 494 | /** 495 | * Draw chart from object input. 496 | */ 497 | drawFromObject() { 498 | const that = this; 499 | // set default color 500 | if (this.colors === undefined) this.colors = colors; 501 | 502 | this.dataSources = Object.keys(this.data); 503 | this.initRoughObjects(); 504 | this.addScales(); 505 | this.dataSources.map((key, idx) => { 506 | const points = this.data[key].map((d, i) => { 507 | return this.x === undefined 508 | ? [this.xScale(i), this.yScale(+d)] 509 | : [this.xScale(this.x[i]), this.yScale(d)]; 510 | }); 511 | 512 | // remove undefined elements so no odd behavior 513 | const drawPoints = points.filter((d) => d[0] !== undefined); 514 | const node = this.rc.curve(drawPoints, { 515 | stroke: that.colors.length === 1 ? that.colors[0] : that.colors[idx], 516 | roughness: that.roughness, 517 | bowing: that.bowing, 518 | }); 519 | 520 | const roughNode = this.roughSvg.appendChild(node); 521 | roughNode.setAttribute("class", this.graphClass); 522 | if (this.circle === true) { 523 | points.forEach((d, i) => { 524 | const node = this.rc.circle(d[0], d[1], this.circleRadius, { 525 | stroke: this.colors[idx], 526 | fill: this.colors[idx], 527 | fillStyle: "solid", 528 | strokeWidth: 1, 529 | roughness: this.circleRoughness, 530 | }); 531 | this.roughSvg.appendChild(node); 532 | }); 533 | } 534 | }); 535 | // ADD LEGEND 536 | const legendItems = this.dataSources.map((key, i) => ({ 537 | color: this.colors[i], 538 | text: key, 539 | })); 540 | // find length of longest text item 541 | const legendWidth = 542 | legendItems.reduce( 543 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 544 | 0 545 | ) * 546 | 6 + 547 | 35; 548 | const legendHeight = legendItems.length * 11 + 8; 549 | 550 | if (this.legend === true) { 551 | addLegend(this, legendItems, legendWidth, legendHeight, 2); 552 | } 553 | 554 | this.addAxes(); 555 | this.addLabels(); 556 | this.makeAxesRough(this.roughSvg, this.rcAxis); 557 | 558 | if (this.interactive === true) { 559 | this.addInteraction(); 560 | } 561 | } 562 | 563 | /** 564 | * Draw chart from file. 565 | */ 566 | drawFromFile() { 567 | // set default colors 568 | if (this.colors === undefined) this.colors = colors; 569 | 570 | this.initRoughObjects(); 571 | this.addScales(); 572 | 573 | // Add scatterplot 574 | this.dataSources.map((key, idx) => { 575 | const points = this.data.map((d, i) => { 576 | return this.x === undefined 577 | ? [this.xScale(i), this.yScale(d[key])] 578 | : [this.xScale(this.x[i]), this.yScale(+d[key])]; 579 | }); 580 | 581 | // remove undefined elements so no odd behavior 582 | const drawPoints = points.filter((d) => d[0] !== undefined); 583 | const node = this.rc.curve(drawPoints, { 584 | stroke: this.colors[idx], 585 | strokeWidth: this.strokeWidth, 586 | roughness: 1, 587 | bowing: 10, 588 | }); 589 | 590 | this.roughSvg.appendChild(node); 591 | if (this.circle === true) { 592 | drawPoints.forEach((d, i) => { 593 | const node = this.rc.circle(d[0], d[1], this.circleRadius, { 594 | stroke: this.colors[idx], 595 | fill: this.colors[idx], 596 | fillStyle: "solid", 597 | strokeWidth: 1, 598 | roughness: this.circleRoughness, 599 | }); 600 | this.roughSvg.appendChild(node); 601 | }); 602 | } 603 | }); 604 | 605 | // ADD LEGEND 606 | const legendItems = this.dataSources.map((key, i) => ({ 607 | color: this.colors[i], 608 | text: key, 609 | })); 610 | // find length of longest text item 611 | const legendWidth = 612 | legendItems.reduce( 613 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 614 | 0 615 | ) * 616 | 6 + 617 | 35; 618 | const legendHeight = legendItems.length * 11 + 8; 619 | if (this.legend === true) { 620 | addLegend(this, legendItems, legendWidth, legendHeight, 2); 621 | } 622 | 623 | this.addAxes(); 624 | this.addLabels(); 625 | this.makeAxesRough(this.roughSvg, this.rcAxis); 626 | 627 | if (this.interactive === true) { 628 | this.addInteraction(); 629 | } 630 | } 631 | } 632 | 633 | export default Line; 634 | -------------------------------------------------------------------------------- /src/Network.js: -------------------------------------------------------------------------------- 1 | import { csv, tsv, json } from "d3-fetch"; 2 | import { mouse, select, selectAll } from "d3-selection"; 3 | import rough from "roughjs/bundled/rough.esm.js"; 4 | import Chart from "./Chart"; 5 | import { colors } from "./utils/colors"; 6 | import { addLegend } from "./utils/addLegend"; 7 | import { roughCeiling } from "./utils/roughCeiling"; 8 | import { min, max } from "d3-array"; 9 | import { scaleLinear } from "d3-scale"; 10 | import { 11 | forceSimulation, 12 | forceCollide, 13 | forceCenter, 14 | forceLink, 15 | } from "d3-force"; 16 | 17 | /** 18 | * Network chart class, which extends the Chart class. 19 | */ 20 | class Network extends Chart { 21 | /** 22 | * Constructs a new Network instance. 23 | * @param {Object} opts - Configuration object for the network chart. 24 | */ 25 | constructor(opts) { 26 | super(opts); 27 | // load in arguments from config object 28 | this.data = opts.data; 29 | this.links = opts.links; 30 | this.margin = opts.margin || { top: 50, right: 20, bottom: 10, left: 20 }; 31 | this.colors = opts.colors || colors; 32 | this.highlight = opts.highlight; 33 | this.roughness = roughCeiling({ 34 | roughness: opts.roughness, 35 | ceiling: 30, 36 | defaultValue: 0, 37 | }); 38 | this.strokeWidth = opts.strokeWidth || 0.75; 39 | this.innerStrokeWidth = opts.innerStrokeWidth || 0.75; 40 | this.fillWeight = opts.fillWeight || 0.85; 41 | this.color = opts.color || "skyblue"; 42 | this.collision = opts.collision || 1.4; 43 | this.radiusExtent = opts.radiusExtent || [5, 20]; 44 | this.radius = opts.radius || "radius"; 45 | const defaultTextCallback = (d) => ""; 46 | this.textCallback = opts.textCallback || defaultTextCallback; 47 | const defaultColorCallback = (d) => this.color; 48 | this.colorCallback = opts.colorCallback || defaultColorCallback; 49 | this.roughnessExtent = opts.roughnessExtent || [0, 10]; 50 | this.responsive = true; 51 | this.boundRedraw = this.redraw.bind(this, opts); 52 | this.legend = opts.legend || false; 53 | this.legendPosition = opts.legendPosition || "right"; 54 | // new width 55 | this.initChartValues(opts); 56 | // resolve font 57 | this.resolveFont(); 58 | // create the chart 59 | this.drawChart = this.resolveData(opts.data, opts.links); 60 | this.drawChart(); 61 | if (opts.title !== "undefined") this.setTitle(opts.title); 62 | } 63 | 64 | /** 65 | * Handles window resize to redraw chart if responsive. 66 | */ 67 | resizeHandler() { 68 | if (this.responsive) { 69 | this.boundRedraw(); 70 | } 71 | } 72 | 73 | /** 74 | * Removes SVG elements and tooltips associated with the chart. 75 | */ 76 | remove() { 77 | select(this.el).select("svg").remove(); 78 | } 79 | 80 | /** 81 | * Redraws the bar chart with updated options. 82 | * @param {Object} opts - Updated configuration object for the bar chart. 83 | */ 84 | redraw(opts) { 85 | // 1. Remove the current SVG associated with the chart. 86 | this.remove(); 87 | 88 | // 2. Recalculate the size of the container. 89 | this.initChartValues(opts); 90 | 91 | // 3. Redraw everything. 92 | this.resolveFont(); 93 | this.drawChart = this.resolveData(opts.data, opts.links); 94 | this.drawChart(); 95 | 96 | if (opts.title !== "undefined") { 97 | this.setTitle(opts.title); 98 | } 99 | } 100 | 101 | /** 102 | * Initialize the chart with default attributes. 103 | * @param {Object} opts - Configuration object for the chart. 104 | */ 105 | initChartValues(opts) { 106 | this.roughness = opts.roughness || this.roughness; 107 | this.collision = opts.collision || this.collision; 108 | this.color = opts.color || this.color; 109 | this.stroke = opts.stroke || this.stroke; 110 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 111 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 112 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 113 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 114 | this.fillWeight = opts.fillWeight || this.fillWeight; 115 | this.fillStyle = opts.fillStyle || this.fillStyle; 116 | this.title = opts.title || this.title; 117 | const defaultTextCallback = (d) => ""; 118 | this.textCallback = opts.textCallback || defaultTextCallback; 119 | const divDimensions = select(this.el).node().getBoundingClientRect(); 120 | const width = divDimensions.width; 121 | const height = divDimensions.height; 122 | this.width = width - this.margin.left - this.margin.right; 123 | this.height = height - this.margin.top - this.margin.bottom; 124 | this.roughId = this.el + "_svg"; 125 | this.graphClass = this.el.substring(1, this.el.length); 126 | this.interactionG = "g." + this.graphClass; 127 | this.setSvg(); 128 | } 129 | 130 | // add this to abstract base 131 | resolveData(data, links) { 132 | return () => { 133 | this.data = data; 134 | this.links = links; 135 | this.drawFromObject(); 136 | }; 137 | } 138 | 139 | /** 140 | * Set the chart title with the given title. 141 | * @param {string} title - The title for the chart. 142 | */ 143 | setTitle(title) { 144 | this.svg 145 | .append("text") 146 | .attr("x", this.width / 2) 147 | .attr("y", 0 - this.margin.top / 3) 148 | .attr("class", "title") 149 | .attr("text-anchor", "middle") 150 | .style( 151 | "font-size", 152 | this.titleFontSize === undefined 153 | ? `${Math.min(40, Math.min(this.width, this.height) / 4)}px` 154 | : this.titleFontSize 155 | ) 156 | .style("font-family", this.fontFamily) 157 | .style("opacity", 0.8) 158 | .text(title); 159 | } 160 | 161 | /** 162 | * Add interaction elements to chart. 163 | */ 164 | addInteraction() { 165 | const that = this; 166 | let thisColor; 167 | 168 | let mouseleave = function (d) { 169 | select(this).selectAll("path:nth-child(1)").style("opacity", 1); 170 | select(this).selectAll("path:nth-child(1)").style("stroke", thisColor); 171 | select(this) 172 | .selectAll("path:nth-child(2)") 173 | .style("stroke-width", that.innerStrokeWidth); 174 | 175 | select(this).select(".node-text").attr("opacity", 0); 176 | }; 177 | 178 | let mouseover = function (d) { 179 | select(this).raise(); 180 | thisColor = select(this).selectAll("path").style("stroke"); 181 | that.highlight === undefined 182 | ? select(this).selectAll("path:nth-child(1)").style("opacity", 0.4) 183 | : select(this) 184 | .selectAll("path:nth-child(1)") 185 | .style("stroke", that.highlight); 186 | select(this) 187 | .selectAll("path:nth-child(2)") 188 | .style("stroke-width", that.strokeWidth + 1.2); 189 | 190 | select(this).select(".node-text").attr("opacity", 1); 191 | }; 192 | 193 | // selectAll(this.interactionG).on("mousemove", mousemove); 194 | selectAll(".nodeGroup") 195 | .on("mouseover", mouseover) 196 | .on("mouseleave", mouseleave); 197 | } 198 | 199 | /** 200 | * Draw rough SVG elements on chart. 201 | */ 202 | initRoughObjects() { 203 | this.roughSvg = document.getElementById(this.roughId); 204 | this.rcAxis = rough.svg(this.roughSvg, { 205 | options: { strokeWidth: this.strokeWidth >= 3 ? 3 : this.strokeWidth }, 206 | }); 207 | this.rc = rough.svg(this.roughSvg, { 208 | options: { 209 | strokeWidth: this.innerStrokeWidth, 210 | fill: this.color, 211 | stroke: this.stroke === "none" ? undefined : this.stroke, 212 | roughness: this.roughness, 213 | bowing: this.bowing, 214 | fillStyle: this.fillStyle, 215 | }, 216 | }); 217 | } 218 | 219 | /** 220 | * Draw chart from object input. 221 | */ 222 | drawFromObject() { 223 | const that = this; 224 | let radiusScale; 225 | let roughnessScale; 226 | 227 | if (typeof this.radius === "number") { 228 | radiusScale = scaleLinear() 229 | .domain([0, 1]) 230 | .range([this.extent[0], this.radiusExtent[1]]); 231 | } else { 232 | const dataMin = min(this.data, (d) => +d[this.radius]); 233 | const dataMax = max(this.data, (d) => +d[this.radius]); 234 | 235 | // Create a scale based on data's min and max values 236 | radiusScale = scaleLinear() 237 | .domain([dataMin, dataMax]) 238 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 239 | } 240 | 241 | if (typeof this.roughness === "number") { 242 | roughnessScale = scaleLinear() 243 | .domain([0, 1]) 244 | .range([this.roughnessExtent[0], this.roughnessExtent[1]]); 245 | } else { 246 | const roughnessMin = min(this.data, (d) => +d[this.radius]); 247 | const roughnessMax = max(this.data, (d) => +d[this.radius]); 248 | 249 | // Create a scale based on data's min and max values 250 | roughnessScale = scaleLinear() 251 | .domain([roughnessMin, roughnessMax]) 252 | .range([this.roughnessExtent[0], this.roughnessExtent[1]]); 253 | } 254 | 255 | this.initRoughObjects(); 256 | 257 | const linkElements = this.svg 258 | .selectAll(".link") 259 | .data(this.links) 260 | .enter() 261 | .append("line") 262 | .attr("class", "link"); 263 | 264 | const nodeGroups = this.svg 265 | .selectAll(".nodeGroup") 266 | .data(this.data) 267 | .enter() 268 | .append("g") 269 | .attr("class", "nodeGroup"); 270 | 271 | nodeGroups.each(function (d, i) { 272 | const nodeRadius = 273 | typeof that.radius === "number" 274 | ? that.radius 275 | : radiusScale(d[that.radius]); 276 | 277 | const nodeRoughness = 278 | typeof that.roughness === "number" 279 | ? that.roughness 280 | : roughnessScale(d[that.roughness]); 281 | 282 | const node = that.rc.circle(0, 0, nodeRadius, { 283 | fill: that.colorCallback(d), 284 | simplification: that.simplification, 285 | fillWeight: that.fillWeight, 286 | roughness: nodeRoughness, 287 | }); 288 | 289 | this.appendChild(node); 290 | 291 | node.setAttribute("class", that.graphClass + "_node"); 292 | 293 | select(this) 294 | .append("circle") 295 | .attr("class", "node-circle") 296 | .attr("r", nodeRadius * 0.5) 297 | .attr("fill", "transparent") 298 | .attr("stroke-width", 0) 299 | .attr("stroke", "none"); 300 | 301 | select(this) 302 | .append("text") 303 | .attr("class", "node-text") 304 | .attr("x", 0) 305 | .attr("y", -10) // Adjust 15 based on your needs 306 | .attr("text-anchor", "middle") 307 | .style("pointer-events", "none") 308 | .attr("stroke", "black") 309 | .attr("fill", "white") 310 | .attr("stroke-linejoin", "fill") 311 | .attr("paint-order", "stroke fill") 312 | .attr("stroke-width", "5px") 313 | .attr("opacity", 0) 314 | .text((d) => that.textCallback(d)); 315 | }); 316 | 317 | const simulation = forceSimulation(this.data); 318 | simulation.alpha(1).restart(); 319 | 320 | simulation 321 | .force( 322 | "collide", 323 | forceCollide().radius((d) => d.radius * this.collision) 324 | ) 325 | .force("center", forceCenter(this.width / 2, this.height / 2)) 326 | .force("link", forceLink(this.links).distance(100)); 327 | simulation.on("tick", () => { 328 | nodeGroups.attr("transform", (d) => `translate(${d.x}, ${d.y})`); 329 | linkElements 330 | .attr("x1", (d) => d.source.x) 331 | .attr("y1", (d) => d.source.y) 332 | .attr("x2", (d) => d.target.x) 333 | .attr("y2", (d) => d.target.y); 334 | }); 335 | 336 | selectAll(".nodeGroup") 337 | .selectAll("path:nth-child(2)") 338 | .style("stroke-width", this.strokeWidth); 339 | 340 | if (this.interactive === true) { 341 | this.addInteraction(); 342 | } 343 | 344 | if (this.legend) { 345 | const legendItems = this.legend; 346 | this.colors = this.legend.map((item) => item.color); 347 | 348 | const legendWidth = 349 | legendItems.reduce( 350 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 351 | 0 352 | ) * 353 | 6 + 354 | 35; 355 | const legendHeight = legendItems.length * 11 + 8; 356 | 357 | addLegend(this, legendItems, legendWidth, legendHeight); 358 | } 359 | } 360 | } 361 | 362 | export default Network; 363 | -------------------------------------------------------------------------------- /src/Pie.js: -------------------------------------------------------------------------------- 1 | import { csv, tsv, json } from "d3-fetch"; 2 | import { mouse, select, selectAll } from "d3-selection"; 3 | import { arc, pie } from "d3-shape"; 4 | import rough from "roughjs/bundled/rough.esm.js"; 5 | import Chart from "./Chart"; 6 | import { colors } from "./utils/colors"; 7 | import { addLegend } from "./utils/addLegend"; 8 | import { roughCeiling } from "./utils/roughCeiling"; 9 | 10 | /** 11 | * Pie chart class, which extends the Chart class. 12 | */ 13 | class Pie extends Chart { 14 | /** 15 | * Constructs a new Pie instance. 16 | * @param {Object} opts - Configuration object for the pie chart. 17 | */ 18 | constructor(opts) { 19 | super(opts); 20 | // load in arguments from config object 21 | this.data = opts.data; 22 | this.margin = opts.margin || { top: 50, right: 20, bottom: 10, left: 20 }; 23 | this.colors = opts.colors || colors; 24 | this.highlight = opts.highlight; 25 | this.roughness = roughCeiling({ 26 | roughness: opts.roughness, 27 | ceiling: 30, 28 | defaultValue: 0, 29 | }); 30 | this.strokeWidth = opts.strokeWidth || 0.75; 31 | this.innerStrokeWidth = opts.innerStrokeWidth || 0.75; 32 | this.fillWeight = opts.fillWeight || 0.85; 33 | this.labels = this.dataFormat === "object" ? "labels" : opts.labels; 34 | this.values = this.dataFormat === "object" ? "values" : opts.values; 35 | if (this.labels === undefined || this.values === undefined) { 36 | console.log(`Error for ${this.el}: Must include labels and values when \ 37 | instantiating Donut chart. Skipping chart.`); 38 | return; 39 | } 40 | this.legend = opts.legend !== false; 41 | this.legendPosition = opts.legendPosition || "right"; 42 | this.responsive = true; 43 | this.boundRedraw = this.redraw.bind(this, opts); 44 | // new width 45 | this.initChartValues(opts); 46 | // resolve font 47 | this.resolveFont(); 48 | // create the chart 49 | this.drawChart = this.resolveData(opts.data); 50 | this.drawChart(); 51 | if (opts.title !== "undefined") this.setTitle(opts.title); 52 | } 53 | 54 | /** 55 | * Handles window resize to redraw chart if responsive. 56 | */ 57 | resizeHandler() { 58 | if (this.responsive) { 59 | this.boundRedraw(); 60 | } 61 | } 62 | 63 | /** 64 | * Removes SVG elements and tooltips associated with the chart. 65 | */ 66 | remove() { 67 | select(this.el).select("svg").remove(); 68 | } 69 | 70 | /** 71 | * Redraws the bar chart with updated options. 72 | * @param {Object} opts - Updated configuration object for the bar chart. 73 | */ 74 | redraw(opts) { 75 | // 1. Remove the current SVG associated with the chart. 76 | this.remove(); 77 | 78 | // 2. Recalculate the size of the container. 79 | this.initChartValues(opts); 80 | 81 | // 3. Redraw everything. 82 | this.resolveFont(); 83 | this.drawChart = this.resolveData(opts.data); 84 | this.drawChart(); 85 | 86 | if (opts.title !== "undefined") { 87 | this.setTitle(opts.title); 88 | } 89 | } 90 | 91 | /** 92 | * Initialize the chart with default attributes. 93 | * @param {Object} opts - Configuration object for the chart. 94 | */ 95 | initChartValues(opts) { 96 | this.roughness = opts.roughness || this.roughness; 97 | this.stroke = opts.stroke || this.stroke; 98 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 99 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 100 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 101 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 102 | this.fillWeight = opts.fillWeight || this.fillWeight; 103 | this.fillStyle = opts.fillStyle || this.fillStyle; 104 | const divDimensions = select(this.el).node().getBoundingClientRect(); 105 | const width = divDimensions.width; 106 | const height = divDimensions.height; 107 | this.width = width - this.margin.left - this.margin.right; 108 | this.height = height - this.margin.top - this.margin.bottom; 109 | this.roughId = this.el + "_svg"; 110 | this.graphClass = this.el.substring(1, this.el.length); 111 | this.interactionG = "g." + this.graphClass; 112 | this.radius = Math.min(this.width, this.height) / 2; 113 | this.setSvg(); 114 | } 115 | 116 | // add this to abstract base 117 | resolveData(data) { 118 | // if data from file, read in 119 | // else if data from json object, read in 120 | if (typeof data === "string") { 121 | if (data.includes(".csv")) { 122 | return () => { 123 | csv(data).then((d) => { 124 | this.data = d; 125 | this.drawFromFile(); 126 | }); 127 | }; 128 | } else if (data.includes(".tsv")) { 129 | return () => { 130 | tsv(data).then((d) => { 131 | this.data = d; 132 | this.drawFromFile(); 133 | }); 134 | }; 135 | } else if (data.includes(".json")) { 136 | return () => { 137 | json(data).then((d) => { 138 | this.data = d; 139 | this.drawFromFile(); 140 | }); 141 | }; 142 | } 143 | } else { 144 | return () => { 145 | this.data = data; 146 | this.drawFromObject(); 147 | }; 148 | } 149 | } 150 | 151 | /** 152 | * Set the chart title with the given title. 153 | * @param {string} title - The title for the chart. 154 | */ 155 | setTitle(title) { 156 | this.svg 157 | .append("text") 158 | .attr("x", this.width / 2) 159 | .attr("y", 0 - this.margin.top / 3) 160 | .attr("class", "title") 161 | .attr("text-anchor", "middle") 162 | .style( 163 | "font-size", 164 | this.titleFontSize === undefined 165 | ? `${Math.min(40, Math.min(this.width, this.height) / 4)}px` 166 | : this.titleFontSize 167 | ) 168 | .style("font-family", this.fontFamily) 169 | .style("opacity", 0.8) 170 | .text(title); 171 | } 172 | 173 | /** 174 | * Add interaction elements to chart. 175 | */ 176 | addInteraction() { 177 | selectAll(this.interactionG) 178 | .append("g") 179 | .attr("transform", `translate(${this.width / 2}, ${this.height / 2})`) 180 | .data( 181 | this.dataFormat === "object" 182 | ? this.makePie(this.data[this.values]) 183 | : this.makePie(this.data) 184 | ) 185 | .append("path") 186 | .attr("d", this.makeArc) 187 | .attr("stroke-width", "0px") 188 | .attr("fill", "transparent"); 189 | 190 | // create tooltip 191 | const Tooltip = select(this.el) 192 | .append("div") 193 | .style("opacity", 0) 194 | .attr("class", "tooltip") 195 | .style("position", "absolute") 196 | .style("background-color", "white") 197 | .style("border", "solid") 198 | .style("border-width", "1px") 199 | .style("border-radius", "5px") 200 | .style("padding", "3px") 201 | .style("font-family", this.fontFamily) 202 | .style("font-size", this.tooltipFontSize) 203 | .style("pointer-events", "none"); 204 | 205 | // event functions 206 | let mouseover = function (d) { 207 | Tooltip.style("opacity", 1); 208 | }; 209 | 210 | const that = this; 211 | let thisColor; 212 | 213 | let mousemove = function (d) { 214 | const attrX = select(this).attr("attrX"); 215 | const attrY = select(this).attr("attrY"); 216 | const mousePos = mouse(this); 217 | // get size of enclosing div 218 | Tooltip.html(`${attrX}: ${attrY}`) 219 | .style("opacity", 0.95) 220 | .style( 221 | "transform", 222 | `translate(${mousePos[0] + that.margin.left}px, 223 | ${ 224 | mousePos[1] - 225 | (that.height + that.margin.top + that.margin.bottom / 2) 226 | }px)` 227 | ); 228 | }; 229 | let mouseleave = function (d) { 230 | Tooltip.style("opacity", 0); 231 | }; 232 | 233 | // d3 event handlers 234 | selectAll(this.interactionG).on("mouseover", function () { 235 | mouseover(); 236 | thisColor = select(this).selectAll("path").style("stroke"); 237 | that.highlight === undefined 238 | ? select(this).selectAll("path").style("opacity", 0.5) 239 | : select(this).selectAll("path").style("stroke", that.highlight); 240 | }); 241 | 242 | selectAll(this.interactionG).on("mouseout", function () { 243 | mouseleave(); 244 | select(this).selectAll("path").style("stroke", thisColor); 245 | select(this).selectAll("path").style("opacity", 1); 246 | }); 247 | 248 | selectAll(this.interactionG).on("mousemove", mousemove); 249 | } 250 | 251 | /** 252 | * Draw rough SVG elements on chart. 253 | */ 254 | initRoughObjects() { 255 | this.roughSvg = document.getElementById(this.roughId); 256 | this.rcAxis = rough.svg(this.roughSvg, { 257 | options: { strokeWidth: this.strokeWidth >= 3 ? 3 : this.strokeWidth }, 258 | }); 259 | this.rc = rough.svg(this.roughSvg, { 260 | options: { 261 | fill: this.color, 262 | strokeWidth: this.innerStrokeWidth, 263 | roughness: this.roughness, 264 | bowing: this.bowing, 265 | fillStyle: this.fillStyle, 266 | }, 267 | }); 268 | } 269 | 270 | /** 271 | * Draw chart from object input. 272 | */ 273 | drawFromObject() { 274 | this.initRoughObjects(); 275 | this.makePie = pie(); 276 | 277 | this.makeArc = arc().innerRadius(0).outerRadius(this.radius); 278 | 279 | this.arcs = this.makePie(this.data[this.values]); 280 | this.arcs.forEach((d, i) => { 281 | if (d.value !== 0) { 282 | const node = this.rc.arc( 283 | this.width / 2, // x 284 | this.height / 2, // y 285 | 2 * this.radius, // width 286 | 2 * this.radius, // height 287 | d.startAngle - Math.PI / 2, // start 288 | d.endAngle - Math.PI / 2, // stop 289 | true, 290 | { 291 | fill: this.colors[i], 292 | stroke: this.colors[i], 293 | } 294 | ); 295 | node.setAttribute("class", this.graphClass); 296 | const roughNode = this.roughSvg.appendChild(node); 297 | roughNode.setAttribute("attrY", this.data[this.values][i]); 298 | roughNode.setAttribute("attrX", this.data[this.labels][i]); 299 | } 300 | }); 301 | 302 | selectAll(this.interactionG) 303 | .selectAll("path:nth-child(2)") 304 | .style("stroke-width", this.strokeWidth); 305 | 306 | const dataSources = this.data.labels; 307 | // ADD LEGEND 308 | const legendItems = dataSources.map((key, i) => ({ 309 | color: this.colors[i], 310 | text: key, 311 | })); 312 | // find length of longest text item 313 | const legendWidth = 314 | legendItems.reduce( 315 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 316 | 0 317 | ) * 318 | 6 + 319 | 35; 320 | const legendHeight = legendItems.length * 11 + 8; 321 | 322 | if (this.legend === true) { 323 | addLegend(this, legendItems, legendWidth, legendHeight); 324 | } 325 | 326 | // If desired, add interactivity 327 | if (this.interactive === true) { 328 | this.addInteraction(); 329 | } 330 | } 331 | 332 | /** 333 | * Draw chart from file. 334 | */ 335 | drawFromFile() { 336 | this.initRoughObjects(); 337 | 338 | this.makePie = pie() 339 | .value((d) => d[this.values]) 340 | .sort(null); 341 | 342 | const valueArr = []; 343 | this.makeArc = arc().innerRadius(0).outerRadius(this.radius); 344 | 345 | this.arcs = this.makePie(this.data); 346 | 347 | this.arcs.forEach((d, i) => { 348 | if (d.value !== 0) { 349 | // let c = this.makeArc.centroid(d); 350 | const node = this.rc.arc( 351 | this.width / 2, // x 352 | this.height / 2, // y 353 | 2 * this.radius, // width 354 | 2 * this.radius, // height 355 | d.startAngle - Math.PI / 2, // start 356 | d.endAngle - Math.PI / 2, // stop 357 | true, 358 | { 359 | fill: this.colors[i], 360 | stroke: this.colors[i], 361 | } 362 | ); 363 | node.setAttribute("class", this.graphClass); 364 | const roughNode = this.roughSvg.appendChild(node); 365 | roughNode.setAttribute("attrY", d.data[this.values]); 366 | roughNode.setAttribute("attrX", d.data[this.labels]); 367 | } 368 | valueArr.push(d.data[this.labels]); 369 | }); 370 | 371 | selectAll(this.interactionG) 372 | .selectAll("path:nth-child(2)") 373 | .style("stroke-width", this.strokeWidth); 374 | 375 | // ADD LEGEND 376 | const dataSources = valueArr; 377 | const legendItems = dataSources.map((key, i) => ({ 378 | color: this.colors[i], 379 | text: key, 380 | })); 381 | // find length of longest text item 382 | const legendWidth = 383 | legendItems.reduce( 384 | (pre, cur) => (pre > cur.text.length ? pre : cur.text.length), 385 | 0 386 | ) * 387 | 6 + 388 | 35; 389 | const legendHeight = legendItems.length * 11 + 8; 390 | 391 | if (this.legend === true) { 392 | addLegend(this, legendItems, legendWidth, legendHeight); 393 | } 394 | 395 | // If desired, add interactivity 396 | if (this.interactive === true) { 397 | this.addInteraction(); 398 | } 399 | } // draw 400 | } 401 | 402 | export default Pie; 403 | -------------------------------------------------------------------------------- /src/Scatter.js: -------------------------------------------------------------------------------- 1 | import { extent, min, max } from "d3-array"; 2 | import { axisBottom, axisLeft } from "d3-axis"; 3 | import { csv, tsv } from "d3-fetch"; 4 | import { format } from "d3-format"; 5 | import { scaleLinear, scaleOrdinal } from "d3-scale"; 6 | import { mouse, select, selectAll } from "d3-selection"; 7 | import rough from "roughjs/bundled/rough.esm.js"; 8 | import Chart from "./Chart"; 9 | import { roughCeiling } from "./utils/roughCeiling"; 10 | 11 | const defaultColors = [ 12 | "pink", 13 | "skyblue", 14 | "coral", 15 | "gold", 16 | "teal", 17 | "darkgreen", 18 | "brown", 19 | "slateblue", 20 | "orange", 21 | ]; 22 | 23 | /** 24 | * Scatter chart class, which extends the Chart class. 25 | */ 26 | class Scatter extends Chart { 27 | /** 28 | * Constructs a new Scatter instance. 29 | * @param {Object} opts - Configuration object for the scatter chart. 30 | */ 31 | constructor(opts) { 32 | super(opts); 33 | 34 | // load in arguments from config object 35 | // this.data = opts.data; 36 | this.margin = opts.margin || { top: 50, right: 20, bottom: 50, left: 100 }; 37 | this.colorVar = opts.colorVar; 38 | this.roughness = roughCeiling({ roughness: opts.roughness }); 39 | this.highlight = opts.highlight; 40 | this.highlightLabel = opts.highlightLabel || "xy"; 41 | // this.radius = opts.radius || 8; 42 | this.radiusExtent = opts.radiusExtent || [5, 20]; 43 | this.radius = opts.radius || 20; 44 | this.axisStrokeWidth = opts.axisStrokeWidth || 0.4; 45 | this.axisRoughness = opts.axisRoughness || 0.9; 46 | this.curbZero = opts.curbZero === true; 47 | this.innerStrokeWidth = opts.innerStrokeWidth || 1; 48 | this.stroke = opts.stroke || "black"; 49 | this.fillWeight = opts.fillWeight || 0.85; 50 | this.colors = opts.colors || defaultColors; 51 | this.strokeWidth = opts.strokeWidth || 1; 52 | this.axisFontSize = opts.axisFontSize; 53 | this.x = this.dataFormat === "object" ? "x" : opts.x; 54 | this.y = this.dataFormat === "object" ? "y" : opts.y; 55 | this.xValueFormat = opts.xValueFormat; 56 | this.yValueFormat = opts.yValueFormat; 57 | this.xLabel = opts.xLabel || ""; 58 | this.yLabel = opts.yLabel || ""; 59 | this.labelFontSize = opts.labelFontSize || "1rem"; 60 | this.responsive = true; 61 | this.boundRedraw = this.redraw.bind(this, opts); 62 | this.radiusScale; 63 | // new width 64 | this.initChartValues(opts); 65 | // resolve font 66 | this.resolveFont(); 67 | // create the chart 68 | this.drawChart = this.resolveData(opts.data); 69 | this.drawChart(); 70 | if (opts.title !== "undefined") this.setTitle(opts.title); 71 | window.addEventListener("resize", this.resizeHandler.bind(this)); 72 | } 73 | 74 | /** 75 | * Handles window resize to redraw chart if responsive. 76 | */ 77 | resizeHandler() { 78 | if (this.responsive) { 79 | this.boundRedraw(); 80 | } 81 | } 82 | 83 | /** 84 | * Removes SVG elements and tooltips associated with the chart. 85 | */ 86 | remove() { 87 | select(this.el).select("svg").remove(); 88 | } 89 | 90 | /** 91 | * Redraws the bar chart with updated options. 92 | * @param {Object} opts - Updated configuration object for the bar chart. 93 | */ 94 | redraw(opts) { 95 | // 1. Remove the current SVG associated with the chart. 96 | this.remove(); 97 | 98 | // 2. Recalculate the size of the container. 99 | this.initChartValues(opts); 100 | 101 | // 3. Redraw everything. 102 | this.resolveFont(); 103 | this.drawChart = this.resolveData(opts.data); 104 | this.drawChart(); 105 | 106 | // if (opts.title !== "undefined") { 107 | // this.setTitle(opts.title); 108 | // } 109 | } 110 | 111 | /** 112 | * Initialize the chart with default attributes. 113 | * @param {Object} opts - Configuration object for the chart. 114 | */ 115 | initChartValues(opts) { 116 | this.roughness = opts.roughness || this.roughness; 117 | this.stroke = opts.stroke || this.stroke; 118 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 119 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 120 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 121 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 122 | this.fillWeight = opts.fillWeight || this.fillWeight; 123 | this.fillStyle = opts.fillStyle || this.fillStyle; 124 | this.colors = opts.colors || this.colors; 125 | const divDimensions = select(this.el).node().getBoundingClientRect(); 126 | const width = divDimensions.width; 127 | const height = divDimensions.height; 128 | this.width = width - this.margin.left - this.margin.right; 129 | this.height = height - this.margin.top - this.margin.bottom; 130 | this.roughId = this.el + "_svg"; 131 | this.graphClass = this.el.substring(1, this.el.length); 132 | this.interactionG = "g." + this.graphClass; 133 | this.setSvg(); 134 | } 135 | 136 | // add this to abstract base 137 | resolveData(data) { 138 | if (typeof data === "string") { 139 | if (data.includes(".csv")) { 140 | return () => { 141 | csv(data).then((d) => { 142 | this.data = d; 143 | this.drawFromFile(); 144 | }); 145 | }; 146 | } else if (data.includes(".tsv")) { 147 | return () => { 148 | tsv(data).then((d) => { 149 | this.data = d; 150 | this.drawFromFile(); 151 | }); 152 | }; 153 | } 154 | } else { 155 | return () => { 156 | this.data = data; 157 | this.drawFromObject(); 158 | }; 159 | } 160 | } 161 | 162 | addScaleLine() { 163 | let dataExtent; 164 | if (this.dataFormat !== "file") { 165 | dataExtent = allDataExtent(this.data); 166 | } else { 167 | const extents = this.dataSources.map((key) => 168 | extent(this.data, (d) => +d[key]) 169 | ); 170 | const dataMin = min(extents, (d) => d[0]); 171 | const dataMax = max(extents, (d) => d[1]); 172 | dataExtent = [dataMin, dataMax]; 173 | } 174 | // get value domains and pad axes by 5% 175 | // if this.x is undefined, use index for x 176 | let xExtent; 177 | if (this.x === undefined) { 178 | // get length of longest array 179 | const keys = Object.keys(this.data); 180 | const lengths = keys.map((key) => this.data[key].length); 181 | const maxLen = max(lengths); 182 | // Need to make xScale, when this.x is given, ordinal. 183 | xExtent = 184 | this.dataFormat === "file" ? [0, this.data.length] : [0, maxLen]; 185 | } else { 186 | xExtent = extent(this.x); 187 | } 188 | 189 | const yExtent = dataExtent; 190 | 191 | const yRange = yExtent[1] - yExtent[0]; 192 | 193 | this.xScale = 194 | this.x === undefined 195 | ? scalePoint() 196 | .range([0, this.width]) 197 | .domain([...Array(xExtent[1]).keys()]) 198 | : scalePoint().range([0, this.width]).domain(this.x); 199 | 200 | this.yScale = scaleLinear() 201 | .range([this.height, 0]) 202 | .domain([0, yExtent[1] + yRange * 0.05]); 203 | } 204 | 205 | addScales() { 206 | // get value domains and pad axes by 5% 207 | const xExtent = 208 | this.dataFormat === "file" 209 | ? extent(this.data, (d) => +d[this.x]) 210 | : extent(this.data[this.x]); 211 | const xRange = xExtent[1] - xExtent[0]; 212 | const yExtent = 213 | this.dataFormat === "file" 214 | ? extent(this.data, (d) => +d[this.y]) 215 | : extent(this.data[this.y]); 216 | const yRange = yExtent[1] - yExtent[0]; 217 | // todo: why use xRange? 218 | // todo: why use yRange? 219 | 220 | const colorExtent = 221 | this.dataFormat === "file" 222 | ? extent(this.data, (d) => d[this.colorVar]) 223 | : [1, 1]; 224 | 225 | if (this.dataFormat === "file") { 226 | const radiusExtent = extent(this.data, (d) => +d[this.radius]); 227 | const radiusMax = Math.min(this.width, this.height) / 2 / 2; 228 | this.radiusScale = scaleLinear() 229 | .range([8, radiusMax]) 230 | .domain(radiusExtent); 231 | } else { 232 | this.radiusScale = scaleLinear() 233 | .domain([0, 20]) 234 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 235 | } 236 | 237 | // force zero baseline if all data is positive 238 | if (this.curbZero === true) { 239 | if (yExtent[0] > 0) { 240 | yExtent[0] = 0; 241 | } 242 | if (xExtent[0] > 0) { 243 | xExtent[0] = 0; 244 | } 245 | } 246 | 247 | this.xScale = scaleLinear() 248 | .range([0, this.width]) 249 | .domain([xExtent[0] - xRange * 0.05, xExtent[1] + xRange * 0.05]); 250 | 251 | this.yScale = scaleLinear() 252 | .range([this.height, 0]) 253 | .domain([yExtent[0] - yRange * 0.05, yExtent[1] + yRange * 0.05]); 254 | 255 | this.colorScale = scaleOrdinal().range(this.colors).domain(colorExtent); 256 | } 257 | 258 | /** 259 | * Create x and y labels for chart. 260 | */ 261 | addLabels() { 262 | // xLabel 263 | if (this.xLabel !== "") { 264 | this.svg 265 | .append("text") 266 | .attr("x", this.width / 2) 267 | .attr("y", this.height + this.margin.bottom / 1.3) 268 | .attr("dx", "1em") 269 | .attr("class", "labelText") 270 | .style("text-anchor", "middle") 271 | .style("font-family", this.fontFamily) 272 | .style("font-size", this.labelFontSize) 273 | .text(this.xLabel); 274 | } 275 | // yLabel 276 | if (this.yLabel !== "") { 277 | this.svg 278 | .append("text") 279 | .attr("transform", "rotate(-90)") 280 | .attr("y", 0 - this.margin.left / 2) 281 | .attr("x", 0 - this.height / 2) 282 | .attr("dy", "1em") 283 | .attr("class", "labelText") 284 | .style("text-anchor", "middle") 285 | .style("font-family", this.fontFamily) 286 | .style("font-size", this.labelFontSize) 287 | .text(this.yLabel); 288 | } 289 | } 290 | 291 | /** 292 | * Create x and y axes for chart. 293 | */ 294 | addAxes() { 295 | const xAxis = axisBottom(this.xScale) 296 | .tickSize(0) 297 | .tickFormat((d) => { 298 | return this.xValueFormat ? format(this.xValueFormat)(d) : d; 299 | }); 300 | 301 | const yAxis = axisLeft(this.yScale) 302 | .tickSize(0) 303 | .tickFormat((d) => { 304 | return this.yValueFormat ? format(this.yValueFormat)(d) : d; 305 | }); 306 | 307 | // x-axis 308 | this.svg 309 | .append("g") 310 | .attr("transform", "translate(0," + this.height + ")") 311 | .call(xAxis) 312 | .attr("class", `xAxis${this.graphClass}`) 313 | .selectAll("text") 314 | .attr("transform", "translate(-10, 0)rotate(-45)") 315 | .style("text-anchor", "end") 316 | .style("font-family", this.fontFamily) 317 | .style( 318 | "font-size", 319 | this.axisFontSize === undefined 320 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 321 | : this.axisFontSize 322 | ); 323 | 324 | // y-axis 325 | this.svg 326 | .append("g") 327 | .call(yAxis) 328 | .attr("class", `yAxis${this.graphClass}`) 329 | .selectAll("text") 330 | .style("font-family", this.fontFamily) 331 | .style( 332 | "font-size", 333 | this.axisFontSize === undefined 334 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 335 | : this.axisFontSize 336 | ); 337 | 338 | // hide original axes 339 | selectAll("path.domain").attr("stroke", "transparent"); 340 | 341 | selectAll("g.tick").style("opacity", 1); 342 | } 343 | 344 | makeAxesRough(roughSvg, rcAxis) { 345 | const xAxisClass = `xAxis${this.graphClass}`; 346 | const yAxisClass = `yAxis${this.graphClass}`; 347 | const roughXAxisClass = `rough-${xAxisClass}`; 348 | const roughYAxisClass = `rough-${yAxisClass}`; 349 | 350 | select(`.${xAxisClass}`) 351 | .selectAll("path.domain") 352 | .each(function (d, i) { 353 | const pathD = select(this).node().getAttribute("d"); 354 | const roughXAxis = rcAxis.path(pathD, { 355 | stroke: "black", 356 | fillStyle: "hachure", 357 | }); 358 | roughXAxis.setAttribute("class", roughXAxisClass); 359 | roughSvg.appendChild(roughXAxis); 360 | }); 361 | selectAll(`.${roughXAxisClass}`).attr( 362 | "transform", 363 | `translate(0, ${this.height})` 364 | ); 365 | 366 | select(`.${yAxisClass}`) 367 | .selectAll("path.domain") 368 | .each(function (d, i) { 369 | const pathD = select(this).node().getAttribute("d"); 370 | const roughYAxis = rcAxis.path(pathD, { 371 | stroke: "black", 372 | fillStyle: "hachure", 373 | }); 374 | roughYAxis.setAttribute("class", roughYAxisClass); 375 | roughSvg.appendChild(roughYAxis); 376 | }); 377 | } 378 | 379 | /** 380 | * Set the chart title with the given title. 381 | * @param {string} title - The title for the chart. 382 | */ 383 | setTitle(title) { 384 | this.svg 385 | .append("text") 386 | .attr("x", this.width / 2) 387 | .attr("y", 0 - this.margin.top / 2) 388 | .attr("text-anchor", "middle") 389 | .style( 390 | "font-size", 391 | this.titleFontSize === undefined 392 | ? `${Math.min(20, Math.min(this.width, this.height) / 4)}px` 393 | : this.titleFontSize 394 | ) 395 | .style("font-family", this.fontFamily) 396 | .style("opacity", 0.8) 397 | .text(title); 398 | } 399 | 400 | /** 401 | * Add interaction elements to chart. 402 | */ 403 | addInteraction() { 404 | // const that = this; 405 | // add highlight helper dom nodes 406 | const circles = selectAll(this.interactionG) 407 | .data(this.dataFormat === "file" ? this.data : this.data.x) 408 | .append("circle") 409 | .attr("cx", (d, i) => { 410 | // return 5; 411 | return this.dataFormat === "file" 412 | ? this.xScale(+d[this.x]) 413 | : this.xScale(+this.data[this.x][i]); 414 | }) 415 | .attr("cy", (d, i) => { 416 | return this.dataFormat === "file" 417 | ? this.yScale(+d[this.y]) 418 | : this.yScale(+this.data[this.y][i]); 419 | }); 420 | 421 | if (this.dataFormat === "file") { 422 | circles 423 | .attr("r", (d) => 424 | typeof this.radius === "number" 425 | ? this.radius * 0.7 426 | : this.radiusScale(+d[this.radius]) * 0.6 427 | ) 428 | .attr("fill", "transparent"); 429 | } else { 430 | circles 431 | .attr("r", (d, i) => { 432 | const nodeRadius = this.data[this.radius][i]; 433 | return typeof this.radius === "number" 434 | ? this.radius * 0.7 435 | : this.radiusScale(nodeRadius); 436 | }) 437 | .attr("fill", "transparent"); 438 | } 439 | 440 | // create tooltip 441 | let Tooltip = select(this.el) 442 | .append("div") 443 | .style("opacity", 0) 444 | .attr("class", "tooltip") 445 | .style("position", "absolute") 446 | .style("background-color", "white") 447 | .style("border", "solid") 448 | .style("border-width", "1px") 449 | .style("border-radius", "5px") 450 | .style("padding", "3px") 451 | .style("font-family", this.fontFamily) 452 | .style("font-size", this.tooltipFontSize) 453 | .style("pointer-events", "none"); 454 | 455 | // event functions 456 | let mouseover = function (d) { 457 | Tooltip.style("opacity", 1); 458 | }; 459 | 460 | const that = this; 461 | let thisColor; 462 | 463 | let mousemove = function (d) { 464 | const attrX = select(this).attr("attrX"); 465 | const attrY = select(this).attr("attrY"); 466 | const attrHighlightLabel = select(this).attr("attrHighlightLabel"); 467 | const mousePos = mouse(this); 468 | // get size of enclosing div 469 | Tooltip.html( 470 | that.highlightLabel === "xy" 471 | ? `x: ${attrX}
y: ${attrY}` 472 | : `${attrHighlightLabel}` 473 | ) 474 | .attr("class", function (d) {}) 475 | .style( 476 | "transform", 477 | `translate(${mousePos[0] + that.margin.left}px, 478 | ${ 479 | mousePos[1] - 480 | (that.height + that.margin.top + that.margin.bottom / 2) 481 | }px)` 482 | ); 483 | }; 484 | let mouseleave = function (d) { 485 | Tooltip.style("opacity", 0); 486 | }; 487 | 488 | // d3 event handlers 489 | selectAll(this.interactionG).on("mouseover", function () { 490 | mouseover(); 491 | thisColor = select(this).selectAll("path").style("stroke"); 492 | that.highlight === undefined 493 | ? select(this).selectAll("path:nth-child(1)").style("opacity", 0.4) 494 | : select(this) 495 | .selectAll("path:nth-child(1)") 496 | .style("stroke", that.highlight); 497 | select(this) 498 | .selectAll("path:nth-child(2)") 499 | .style("stroke-width", that.strokeWidth + 1.2); 500 | }); 501 | 502 | selectAll(this.interactionG).on("mouseout", function () { 503 | mouseleave(); 504 | select(this).selectAll("path").style("opacity", 1); 505 | 506 | select(this).selectAll("path:nth-child(1)").style("stroke", thisColor); 507 | // highlight stroke back to its color 508 | select(this).selectAll("path:nth-child(2)").style("stroke", that.stroke); 509 | select(this) 510 | .selectAll("path:nth-child(2)") 511 | .style("stroke-width", that.strokeWidth); 512 | }); 513 | 514 | selectAll(this.interactionG).on("mousemove", mousemove); 515 | } 516 | 517 | /** 518 | * Draw rough SVG elements on chart. 519 | */ 520 | initRoughObjects() { 521 | this.roughSvg = document.getElementById(this.roughId); 522 | this.rcAxis = rough.svg(this.roughSvg, { 523 | options: { 524 | strokeWidth: this.axisStrokeWidth, 525 | roughness: this.axisRoughness, 526 | }, 527 | }); 528 | this.rc = rough.svg(this.roughSvg, { 529 | options: { 530 | // fill: this.color, 531 | stroke: this.stroke === "none" ? undefined : this.stroke, 532 | strokeWidth: this.innerStrokeWidth, 533 | roughness: this.roughness, 534 | bowing: this.bowing, 535 | fillStyle: this.fillStyle, 536 | }, 537 | }); 538 | } 539 | 540 | /** 541 | * Draw chart from object input. 542 | */ 543 | drawFromObject() { 544 | const that = this; 545 | this.radiusScale = scaleLinear() 546 | .domain([0, 20]) 547 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 548 | 549 | let radiusScale; 550 | let roughnessScale; 551 | 552 | if (typeof this.radius === "number") { 553 | radiusScale = scaleLinear() 554 | .domain([0, 1]) 555 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 556 | } else { 557 | const dataMin = min(this.data[this.radius]); 558 | const dataMax = max(this.data[this.radius]); 559 | // Create a scale based on data's min and max values 560 | radiusScale = scaleLinear() 561 | .domain([dataMin, dataMax]) 562 | .range([this.radiusExtent[0], this.radiusExtent[1]]); 563 | } 564 | 565 | // set default color 566 | if (typeof this.colors === "string") this.colors = this.colors; 567 | if (this.colors === undefined) this.colors = defaultColors[0]; 568 | 569 | this.initRoughObjects(); 570 | this.addScales(); 571 | this.addAxes(); 572 | this.makeAxesRough(this.roughSvg, this.rcAxis); 573 | this.addLabels(); 574 | 575 | // Add scatterplot 576 | this.data.x.forEach((d, i) => { 577 | const nodeRadius = 578 | typeof that.radius === "number" 579 | ? that.radius 580 | : radiusScale(+this.data[that.radius][i]); 581 | const node = this.rc.circle( 582 | this.xScale(+d), 583 | this.yScale(+this.data[this.y][i]), 584 | nodeRadius, 585 | { 586 | fill: 587 | typeof this.colors === "string" 588 | ? this.colors 589 | : this.colors.length === 1 590 | ? this.colors[0] 591 | : this.colors[i], 592 | simplification: this.simplification, 593 | fillWeight: this.fillWeight, 594 | } 595 | ); 596 | const roughNode = this.roughSvg.appendChild(node); 597 | roughNode.setAttribute("class", this.graphClass); 598 | roughNode.setAttribute("attrX", d); 599 | roughNode.setAttribute("attrY", this.data[this.y][i]); 600 | roughNode.setAttribute( 601 | "attrHighlightLabel", 602 | this.data[this.highlightLabel] 603 | ); 604 | }); 605 | 606 | selectAll(this.interactionG) 607 | .selectAll("path:nth-child(2)") 608 | .style("stroke-width", this.strokeWidth); 609 | // If desired, add interactivity 610 | if (this.interactive === true) { 611 | this.addInteraction(); 612 | } 613 | } 614 | 615 | /** 616 | * Draw chart from file. 617 | */ 618 | drawFromFile() { 619 | // set default colors 620 | if (this.colors === undefined) this.colors = defaultColors; 621 | 622 | this.initRoughObjects(); 623 | this.addScales(); 624 | this.addAxes(); 625 | this.makeAxesRough(this.roughSvg, this.rcAxis); 626 | this.addLabels(); 627 | 628 | // Add scatterplot 629 | this.data.forEach((d, i) => { 630 | const node = this.rc.circle( 631 | this.xScale(+d[this.x]), 632 | this.yScale(+d[this.y]), 633 | typeof this.radius === "number" 634 | ? this.radius 635 | : this.radiusScale(+d[this.radius]), 636 | { 637 | fill: 638 | this.colorVar === undefined 639 | ? this.colors[0] 640 | : this.colorScale(d[this.colorVar]), 641 | simplification: this.simplification, 642 | fillWeight: this.fillWeight, 643 | } 644 | ); 645 | const roughNode = this.roughSvg.appendChild(node); 646 | roughNode.setAttribute("class", this.graphClass); 647 | roughNode.setAttribute("attrX", d[this.x]); 648 | roughNode.setAttribute("attrY", d[this.y]); 649 | roughNode.setAttribute("attrHighlightLabel", d[this.highlightLabel]); 650 | }); 651 | 652 | selectAll(this.interactionG) 653 | .selectAll("path:nth-child(2)") 654 | .style("stroke-width", this.strokeWidth); 655 | // If desired, add interactivity 656 | if (this.interactive === true) { 657 | this.addInteraction(); 658 | } 659 | } 660 | } 661 | 662 | export default Scatter; 663 | -------------------------------------------------------------------------------- /src/StackedBar.js: -------------------------------------------------------------------------------- 1 | import { max } from "d3-array"; 2 | import { axisBottom, axisLeft } from "d3-axis"; 3 | import { csv, tsv } from "d3-fetch"; 4 | import { scaleBand, scaleLinear, scaleOrdinal } from "d3-scale"; 5 | import { mouse, select, selectAll } from "d3-selection"; 6 | import rough from "roughjs/bundled/rough.esm.js"; 7 | import Chart from "./Chart"; 8 | import { colors } from "./utils/colors"; 9 | import { roughCeiling } from "./utils/roughCeiling"; 10 | 11 | /** 12 | * StackedBar chart class, which extends the Chart class. 13 | */ 14 | class StackedBar extends Chart { 15 | /** 16 | * Constructs a new StackedBar instance. 17 | * @param {Object} opts - Configuration object for the stacked bar chart. 18 | */ 19 | constructor(opts) { 20 | super(opts); 21 | 22 | // load in arguments from config object 23 | this.data = opts.data; 24 | this.margin = opts.margin || { top: 50, right: 20, bottom: 70, left: 100 }; 25 | this.color = opts.color || "red"; 26 | this.highlight = opts.highlight || "coral"; 27 | this.roughness = roughCeiling({ roughness: opts.roughness }); 28 | this.stroke = opts.stroke || "black"; 29 | this.strokeWidth = opts.strokeWidth || 1; 30 | this.axisStrokeWidth = opts.axisStrokeWidth || 0.5; 31 | this.axisRoughness = opts.axisRoughness || 0.5; 32 | this.innerStrokeWidth = opts.innerStrokeWidth || 1; 33 | this.fillWeight = opts.fillWeight || 0.5; 34 | this.axisFontSize = opts.axisFontSize; 35 | this.labels = opts.labels; 36 | this.values = opts.values; 37 | this.stackColorMapping = {}; 38 | this.padding = opts.padding || 0.1; 39 | this.xLabel = opts.xLabel || ""; 40 | this.yLabel = opts.yLabel || ""; 41 | this.labelFontSize = opts.labelFontSize || "1rem"; 42 | this.responsive = true; 43 | this.boundRedraw = this.redraw.bind(this, opts); 44 | // new width 45 | this.initChartValues(opts); 46 | // resolve font 47 | this.resolveFont(); 48 | // create the chart 49 | this.drawChart = this.resolveData(opts.data); 50 | this.drawChart(); 51 | if (opts.title !== "undefined") this.setTitle(opts.title); 52 | window.addEventListener("resize", this.resizeHandler.bind(this)); 53 | } 54 | 55 | /** 56 | * Handles window resize to redraw chart if responsive. 57 | */ 58 | resizeHandler() { 59 | if (this.responsive) { 60 | this.boundRedraw(); 61 | } 62 | } 63 | 64 | /** 65 | * Removes SVG elements and tooltips associated with the chart. 66 | */ 67 | remove() { 68 | select(this.el).select("svg").remove(); 69 | } 70 | 71 | /** 72 | * Redraws the bar chart with updated options. 73 | * @param {Object} opts - Updated configuration object for the bar chart. 74 | */ 75 | redraw(opts) { 76 | // 1. Remove the current SVG associated with the chart. 77 | this.remove(); 78 | 79 | // 2. Recalculate the size of the container. 80 | this.initChartValues(opts); 81 | 82 | // 3. Redraw everything. 83 | this.resolveFont(); 84 | this.drawChart = this.resolveData(opts.data); 85 | this.drawChart(); 86 | 87 | if (opts.title !== "undefined") { 88 | this.setTitle(opts.title); 89 | } 90 | } 91 | 92 | /** 93 | * Initialize the chart with default attributes. 94 | * @param {Object} opts - Configuration object for the chart. 95 | */ 96 | initChartValues(opts) { 97 | this.roughness = opts.roughness || this.roughness; 98 | this.stroke = opts.stroke || this.stroke; 99 | this.strokeWidth = opts.strokeWidth || this.strokeWidth; 100 | this.axisStrokeWidth = opts.axisStrokeWidth || this.axisStrokeWidth; 101 | this.axisRoughness = opts.axisRoughness || this.axisRoughness; 102 | this.innerStrokeWidth = opts.innerStrokeWidth || this.innerStrokeWidth; 103 | this.fillWeight = opts.fillWeight || this.fillWeight; 104 | this.fillStyle = opts.fillStyle || this.fillStyle; 105 | const divDimensions = select(this.el).node().getBoundingClientRect(); 106 | const width = divDimensions.width; 107 | const height = divDimensions.height; 108 | this.width = width - this.margin.left - this.margin.right; 109 | this.height = height - this.margin.top - this.margin.bottom; 110 | this.roughId = this.el + "_svg"; 111 | this.graphClass = this.el.substring(1, this.el.length); 112 | this.interactionG = "g." + this.graphClass; 113 | this.setSvg(); 114 | } 115 | 116 | // Helper Method to get the Total Value of the Stack 117 | getTotal(d) { 118 | for (let x = 0; x < d.length; x++) { 119 | let t = 0; 120 | for (let i = 0; i < d.columns.length; ++i) { 121 | if (d.columns[i] !== this.labels) { 122 | t += d[x][d.columns[i]] = +d[x][d.columns[i]]; 123 | } 124 | } 125 | d[x].total = t; 126 | } 127 | return d; 128 | } 129 | 130 | updateColorMapping(label) { 131 | if (!this.stackColorMapping[label]) { 132 | // If there isn't a color already mapped to the label then use the next color available 133 | this.stackColorMapping[label] = 134 | colors[Object.keys(this.stackColorMapping).length]; 135 | } 136 | } 137 | 138 | // add this to abstract base 139 | resolveData(data) { 140 | if (typeof data === "string") { 141 | if (data.includes(".csv")) { 142 | return () => { 143 | csv(data).then((d) => { 144 | this.getTotal(d); 145 | this.data = d; 146 | this.drawFromFile(); 147 | }); 148 | }; 149 | } else if (data.includes(".tsv")) { 150 | return () => { 151 | tsv(data).then((d) => { 152 | this.getTotal(d); 153 | this.data = d; 154 | this.drawFromFile(); 155 | }); 156 | }; 157 | } 158 | } else { 159 | return () => { 160 | this.data = data; 161 | 162 | // reset total key (need in case resize) 163 | data = data.map((d) => { 164 | if (Object.keys(d).includes("total")) { 165 | d["total"] = 0; 166 | } 167 | return d; 168 | }); 169 | 170 | for (let i = 0; i < data.length; ++i) { 171 | let t = 0; 172 | const keys = Object.keys(data[i]); 173 | keys.forEach((d) => { 174 | if (d !== this.labels && d !== "total") { 175 | // exclude "total" key from accumulating 176 | this.updateColorMapping(d); 177 | t += data[i][d]; 178 | } 179 | }); 180 | data[i].total = t; 181 | } 182 | 183 | this.drawFromObject(); 184 | }; 185 | } 186 | } 187 | 188 | addScales() { 189 | this.xScale = scaleBand() 190 | .rangeRound([0, this.width]) 191 | .padding(this.padding) 192 | .domain(this.data.map((d) => d[this.labels])); 193 | 194 | this.yScale = scaleLinear() 195 | .rangeRound([this.height, 0]) 196 | .domain([ 197 | 0, 198 | max(this.data, (d) => { 199 | return d.total; 200 | }), 201 | ]) 202 | .nice(); 203 | 204 | // set the colors 205 | const keys = 206 | this.dataFormat === "object" 207 | ? this.data.map((d) => d[this.labels]) 208 | : this.data.columns; 209 | this.zScale = scaleOrdinal() 210 | .range([ 211 | "#98abc5", 212 | "#8a89a6", 213 | "#7b6888", 214 | "#6b486b", 215 | "#a05d56", 216 | "#d0743c", 217 | "#ff8c00", 218 | ]) 219 | .domain(keys); 220 | } 221 | 222 | /** 223 | * Create x and y labels for chart. 224 | */ 225 | addLabels() { 226 | // xLabel 227 | if (this.xLabel !== "") { 228 | this.svg 229 | .append("text") 230 | .attr("x", this.width / 2) 231 | .attr("y", this.height + this.margin.bottom / 2) 232 | .attr("dx", "1em") 233 | .attr("class", "labelText") 234 | .style("text-anchor", "middle") 235 | .style("font-family", this.fontFamily) 236 | .style("font-size", this.labelFontSize) 237 | .text(this.xLabel); 238 | } 239 | // yLabel 240 | if (this.yLabel !== "") { 241 | this.svg 242 | .append("text") 243 | .attr("transform", "rotate(-90)") 244 | .attr("y", 0 - this.margin.left / 1.4) 245 | .attr("x", 0 - this.height / 2) 246 | .attr("dy", "1em") 247 | .attr("class", "labelText") 248 | .style("text-anchor", "middle") 249 | .style("font-family", this.fontFamily) 250 | .style("font-size", this.labelFontSize) 251 | .text(this.yLabel); 252 | } 253 | } 254 | 255 | /** 256 | * Create x and y axes for chart. 257 | */ 258 | addAxes() { 259 | const xAxis = axisBottom(this.xScale).tickSize(0); 260 | 261 | // x-axis 262 | this.svg 263 | .append("g") 264 | .attr("transform", "translate(0," + this.height + ")") 265 | .call(xAxis) 266 | .attr("class", `xAxis${this.graphClass}`) 267 | .selectAll("text") 268 | .attr("transform", "translate(-10,0)rotate(-45)") 269 | .style("text-anchor", "end") 270 | .style("font-family", this.fontFamily) 271 | .style( 272 | "font-size", 273 | this.axisFontSize === undefined 274 | ? `${Math.min(0.8, Math.min(this.width, this.height) / 140)}rem` 275 | : this.axisFontSize 276 | ) 277 | .style("opacity", 0.9); 278 | 279 | // y-axis 280 | const yAxis = axisLeft(this.yScale).tickSize(0); 281 | this.svg 282 | .append("g") 283 | .call(yAxis) 284 | .attr("class", `yAxis${this.graphClass}`) 285 | .selectAll("text") 286 | .style("font-family", this.fontFamily) 287 | .style( 288 | "font-size", 289 | this.axisFontSize === undefined 290 | ? `${Math.min(0.95, Math.min(this.width, this.height) / 140)}rem` 291 | : this.axisFontSize 292 | ) 293 | .style("opacity", 0.9); 294 | 295 | // hide original axes 296 | selectAll("path.domain").attr("stroke", "transparent"); 297 | } 298 | 299 | makeAxesRough(roughSvg, rcAxis) { 300 | const xAxisClass = `xAxis${this.graphClass}`; 301 | const yAxisClass = `yAxis${this.graphClass}`; 302 | const roughXAxisClass = `rough-${xAxisClass}`; 303 | const roughYAxisClass = `rough-${yAxisClass}`; 304 | 305 | select(`.${xAxisClass}`) 306 | .selectAll("path.domain") 307 | .each(function (d, i) { 308 | const pathD = select(this).node().getAttribute("d"); 309 | const roughXAxis = rcAxis.path(pathD, { 310 | fillStyle: "hachure", 311 | }); 312 | roughXAxis.setAttribute("class", roughXAxisClass); 313 | roughSvg.appendChild(roughXAxis); 314 | }); 315 | selectAll(`.${roughXAxisClass}`).attr( 316 | "transform", 317 | `translate(0, ${this.height})` 318 | ); 319 | 320 | select(`.${yAxisClass}`) 321 | .selectAll("path.domain") 322 | .each(function (d, i) { 323 | const pathD = select(this).node().getAttribute("d"); 324 | const roughYAxis = rcAxis.path(pathD, { 325 | fillStyle: "hachure", 326 | }); 327 | roughYAxis.setAttribute("class", roughYAxisClass); 328 | roughSvg.appendChild(roughYAxis); 329 | }); 330 | } 331 | 332 | /** 333 | * Set the chart title with the given title. 334 | * @param {string} title - The title for the chart. 335 | */ 336 | setTitle(title) { 337 | this.svg 338 | .append("text") 339 | .attr("x", this.width / 2) 340 | .attr("y", 0 - this.margin.top / 2) 341 | .attr("class", "title") 342 | .attr("text-anchor", "middle") 343 | .style( 344 | "font-size", 345 | this.titleFontSize === undefined 346 | ? `${Math.min(40, Math.min(this.width, this.height) / 5)}px` 347 | : this.titleFontSize 348 | ) 349 | .style("font-family", this.fontFamily) 350 | .style("opacity", 0.8) 351 | .text(title); 352 | } 353 | 354 | /** 355 | * Add interaction elements to chart. 356 | */ 357 | addInteraction() { 358 | selectAll(this.interactionG) 359 | // .data(this.data) 360 | // .append('rect') 361 | .each(function (d, i) { 362 | const attr = this["attributes"]; 363 | select(this) 364 | .append("rect") 365 | .attr("x", attr["x"].value) 366 | .attr("y", attr["y"].value) 367 | .attr("width", attr["width"].value) 368 | .attr("height", attr["height"].value) 369 | .attr("fill", "transparent"); 370 | }); 371 | 372 | // create tooltip 373 | const Tooltip = select(this.el) 374 | .append("div") 375 | .style("opacity", 0) 376 | .attr("class", "tooltip") 377 | .style("position", "absolute") 378 | .style("background-color", "white") 379 | .style("border", "solid") 380 | .style("border-width", "1px") 381 | .style("border-radius", "5px") 382 | .style("padding", "3px") 383 | .style("font-family", this.fontFamily) 384 | .style("font-size", this.tooltipFontSize) 385 | .style("pointer-events", "none"); 386 | 387 | // event functions 388 | const mouseover = function (d) { 389 | Tooltip.style("opacity", 1); 390 | }; 391 | const that = this; 392 | let thisColor; 393 | 394 | let mousemove = function (d) { 395 | const attrX = select(this).attr("attrX"); 396 | const attrY = select(this).attr("attrY"); 397 | const mousePos = mouse(this); 398 | // get size of enclosing div 399 | Tooltip.html(`${attrX}: ${attrY}`) 400 | .style("opacity", 0.95) 401 | .attr("class", function (d) {}) 402 | .style( 403 | "transform", 404 | `translate(${mousePos[0] + that.margin.left}px, 405 | ${ 406 | mousePos[1] - 407 | (that.height + that.margin.top + that.margin.bottom / 2) 408 | }px)` 409 | ); 410 | }; 411 | const mouseleave = function (d) { 412 | Tooltip.style("opacity", 0); 413 | }; 414 | 415 | // d3 event handlers 416 | selectAll(this.interactionG).on("mouseover", function () { 417 | mouseover(); 418 | thisColor = select(this).selectAll("path").style("stroke"); 419 | select(this).select("path").style("stroke", that.highlight); 420 | select(this) 421 | .selectAll("path:nth-child(2)") 422 | .style("stroke-width", that.strokeWidth + 1.2); 423 | }); 424 | 425 | selectAll(this.interactionG).on("mouseout", function () { 426 | mouseleave(); 427 | select(this).select("path").style("stroke", thisColor); 428 | select(this) 429 | .selectAll("path:nth-child(2)") 430 | .style("stroke-width", that.strokeWidth); 431 | }); 432 | 433 | selectAll(this.interactionG).on("mousemove", mousemove); 434 | } 435 | 436 | /** 437 | * Draw rough SVG elements on chart. 438 | */ 439 | initRoughObjects() { 440 | this.roughSvg = document.getElementById(this.roughId); 441 | this.rcAxis = rough.svg(this.roughSvg, { 442 | options: { 443 | strokeWidth: this.axisStrokeWidth, 444 | roughness: this.axisRoughness, 445 | }, 446 | }); 447 | this.rc = rough.svg(this.roughSvg, { 448 | options: { 449 | // fill: this.color, 450 | stroke: this.stroke === "none" ? undefined : this.stroke, 451 | strokeWidth: this.innerStrokeWidth, 452 | roughness: this.roughness, 453 | bowing: this.bowing, 454 | fillStyle: this.fillStyle, 455 | }, 456 | }); 457 | } 458 | 459 | // Helper Method to create the Stack 460 | stacking() { 461 | // Add Stackedbarplot 462 | this.data.forEach((d) => { 463 | const keys = Object.keys(d); 464 | let yStack = 0; 465 | keys.forEach((yValue, i) => { 466 | if (i > 0 && yValue !== "total") { 467 | yStack += parseInt(d[yValue], 10); 468 | const x = this.xScale(d[this.labels]); 469 | const y = this.yScale(yStack); 470 | const width = this.xScale.bandwidth(); 471 | const height = this.height - this.yScale(+d[yValue]); 472 | const node = this.rc.rectangle(x, y, width, height, { 473 | fill: this.stackColorMapping[yValue] || this.colors[i], 474 | stroke: this.stackColorMapping[yValue] || this.colors[i], 475 | simplification: this.simplification, 476 | fillWeight: this.fillWeight, 477 | }); 478 | const roughNode = this.roughSvg.appendChild(node); 479 | roughNode.setAttribute("class", this.graphClass); 480 | roughNode.setAttribute("attrX", d[this.labels]); 481 | roughNode.setAttribute("keyY", yValue); 482 | roughNode.setAttribute("attrY", +d[yValue]); 483 | // Set Attributes to access them later 484 | roughNode.setAttribute("x", x); 485 | roughNode.setAttribute("y", y); 486 | roughNode.setAttribute("width", width); 487 | roughNode.setAttribute("height", height); 488 | } 489 | }); 490 | }); 491 | } 492 | 493 | /** 494 | * Draw chart from object input. 495 | */ 496 | drawFromObject() { 497 | this.initRoughObjects(); 498 | this.addScales(); 499 | this.addAxes(); 500 | this.makeAxesRough(this.roughSvg, this.rcAxis); 501 | this.addLabels(); 502 | // Add Stackedbarplot 503 | this.stacking(); 504 | 505 | selectAll(this.interactionG) 506 | .selectAll("path:nth-child(2)") 507 | .style("stroke-width", this.strokeWidth); 508 | // If desired, add interactivity 509 | if (this.interactive === true) { 510 | this.addInteraction(); 511 | } 512 | } // draw 513 | 514 | /** 515 | * Draw chart from file. 516 | */ 517 | drawFromFile() { 518 | this.initRoughObjects(); 519 | this.addScales(); 520 | this.addAxes(); 521 | this.makeAxesRough(this.roughSvg, this.rcAxis); 522 | this.addLabels(); 523 | // Add Stackedbar 524 | this.stacking(); 525 | 526 | selectAll(this.interactionG) 527 | .selectAll("path:nth-child(2)") 528 | .style("stroke-width", this.strokeWidth); 529 | // If desired, add interactivity 530 | if (this.interactive === true) { 531 | this.addInteraction(); 532 | } 533 | } // draw 534 | } 535 | 536 | export default StackedBar; 537 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Parcel 5 | 6 | 7 | 8 |

main index

9 | 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Bar from "./Bar"; 2 | import BarH from "./BarH"; 3 | import Donut from "./Donut"; 4 | import Line from "./Line"; 5 | import Network from "./Network"; 6 | import Force from "./Force"; 7 | import Pie from "./Pie"; 8 | import Scatter from "./Scatter"; 9 | import StackedBar from "./StackedBar"; 10 | 11 | export { Bar, BarH, Donut, Line, Network, Force, Pie, Scatter, StackedBar }; 12 | -------------------------------------------------------------------------------- /src/utils/addLegend.js: -------------------------------------------------------------------------------- 1 | import { select } from "d3-selection"; 2 | 3 | export const addLegend = ( 4 | parent, 5 | legendItems, 6 | legendWidth, 7 | legendHeight, 8 | left 9 | ) => { 10 | parent.svg 11 | .append("svg") 12 | .attr( 13 | "x", 14 | parent.legendPosition === "left" ? 5 : parent.width - (legendWidth + 2) 15 | ) 16 | .attr("y", 0); 17 | 18 | // allow custom left-padding where chart overlaps with y-axis 19 | const leftPadding = left === undefined ? -parent.margin.left + 5 : left; 20 | 21 | const nodeLegend = parent.rc.rectangle( 22 | parent.legendPosition === "left" 23 | ? leftPadding // left 24 | : parent.width + parent.margin.right - 2 - legendWidth, // right 25 | -(parent.margin.top / 3), // y 26 | legendWidth, // width 27 | legendHeight, // height 28 | { 29 | fill: "white", 30 | fillWeight: 0.1, 31 | strokeWidth: 0.75, 32 | roughness: 2, 33 | } 34 | ); 35 | 36 | const roughLegend = parent.roughSvg.appendChild(nodeLegend); 37 | const legendClass = "rough" + parent.el.substring(1, parent.el.length); 38 | roughLegend.setAttribute("class", legendClass); 39 | 40 | legendItems.forEach((item, i) => { 41 | const g = select("." + legendClass) 42 | .append("g") 43 | .attr( 44 | "transform", 45 | `translate( 46 | ${ 47 | parent.legendPosition === "left" 48 | ? 5 49 | : parent.width - (legendWidth + 2) 50 | }, 51 | ${0})` 52 | ); 53 | 54 | g.append("rect") 55 | .style("fill", parent.colors[i]) 56 | .attr("width", 20) 57 | .attr("height", 8) 58 | .attr( 59 | "x", 60 | parent.legendPosition === "left" ? leftPadding : parent.margin.right + 5 61 | ) 62 | .attr("y", 6 + 11 * i - parent.margin.top / 3); 63 | 64 | g.append("text") 65 | .style("font-size", ".8rem") 66 | .style("font-family", parent.fontFamily) 67 | .attr( 68 | "x", 69 | parent.legendPosition === "left" 70 | ? leftPadding + 25 71 | : parent.margin.right + 30 72 | ) 73 | .attr("y", 6 + 11 * i + 8 - parent.margin.top / 3) 74 | .text(item.text); 75 | }); 76 | }; 77 | -------------------------------------------------------------------------------- /src/utils/colors.js: -------------------------------------------------------------------------------- 1 | export const colors = [ 2 | "coral", 3 | "skyblue", 4 | "#66c2a5", 5 | "tan", 6 | "#8da0cb", 7 | "#e78ac3", 8 | "#a6d854", 9 | "#ffd92f", 10 | "coral", 11 | "skyblue", 12 | "tan", 13 | "orange", 14 | ]; 15 | -------------------------------------------------------------------------------- /src/utils/roughCeiling.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_CEILING = 20; 2 | export const DEFAULT_VALUE = 1; 3 | 4 | export const roughCeiling = ({ 5 | roughness, 6 | ceiling = DEFAULT_CEILING, 7 | defaultValue = DEFAULT_VALUE, 8 | }) => { 9 | if (roughness === undefined || typeof roughness !== "number") 10 | return defaultValue; 11 | 12 | return roughness > ceiling ? ceiling : roughness; 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/saveToPng.js: -------------------------------------------------------------------------------- 1 | export saveToPng(filename = "chart.png") { 2 | // 1. Serialize the SVG to a string 3 | const svgString = new XMLSerializer().serializeToString(document.querySelector(this.el + " svg")); 4 | 5 | // 2. Convert the SVG string to a Data URL 6 | const svgDataUri = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString))); 7 | 8 | // 3. Render the Data URL in an Image Element 9 | const img = new Image(); 10 | 11 | img.onload = function() { 12 | // 4. Draw the Image onto a Canvas 13 | const canvas = document.createElement('canvas'); 14 | canvas.width = img.width; 15 | canvas.height = img.height; 16 | const ctx = canvas.getContext('2d'); 17 | ctx.drawImage(img, 0, 0); 18 | 19 | // 5. Save Canvas as PNG 20 | // For this example, we'll just create a link and click it 21 | const a = document.createElement('a'); 22 | a.download = filename; 23 | a.href = canvas.toDataURL('image/png'); 24 | document.body.appendChild(a); // This line is needed for Firefox 25 | a.click(); 26 | document.body.removeChild(a); // Cleanup for Firefox 27 | }; 28 | 29 | img.src = svgDataUri; 30 | } -------------------------------------------------------------------------------- /tests/Bar.test.js: -------------------------------------------------------------------------------- 1 | // import roughBars from '../src'; 2 | 3 | describe('Test Chart', () => { 4 | test('Attributes should correctly propagate', () => { 5 | 6 | 7 | // let padding = barChart.padding; 8 | const padding = 0.15; 9 | 10 | 11 | // const output = [{ id: 3, url: "https://www.link3.dev" }]; 12 | expect(padding).toEqual(0.15); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/BarH.test.js: -------------------------------------------------------------------------------- 1 | // import roughBars from '../src'; 2 | 3 | describe('Filter function', () => { 4 | test('it should filter by a search term (link)', () => { 5 | // const barChart = new roughBars.BarH({}); 6 | 7 | // let padding = barChart.padding; 8 | const padding = 0.15; 9 | 10 | 11 | // const output = [{ id: 3, url: "https://www.link3.dev" }]; 12 | expect(padding).toEqual(0.15); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/Donut.test.js: -------------------------------------------------------------------------------- 1 | // import roughBars from '../src'; 2 | 3 | describe('Test Chart', () => { 4 | test('Attributes should correctly propagate', () => { 5 | 6 | 7 | // let padding = barChart.padding; 8 | const padding = 0.15; 9 | 10 | 11 | // const output = [{ id: 3, url: "https://www.link3.dev" }]; 12 | expect(padding).toEqual(0.15); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/Pie.test.js: -------------------------------------------------------------------------------- 1 | // import roughBars from '../src'; 2 | 3 | describe('Test Chart', () => { 4 | test('Attributes should correctly propagate', () => { 5 | 6 | 7 | // let padding = barChart.padding; 8 | const padding = 0.15; 9 | 10 | 11 | // const output = [{ id: 3, url: "https://www.link3.dev" }]; 12 | expect(padding).toEqual(0.15); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/Scatter.test.js: -------------------------------------------------------------------------------- 1 | // import roughBars from '../src'; 2 | 3 | describe('Test Chart', () => { 4 | test('Attributes should correctly propagate', () => { 5 | 6 | 7 | // let padding = barChart.padding; 8 | const padding = 0.15; 9 | 10 | 11 | // const output = [{ id: 3, url: "https://www.link3.dev" }]; 12 | expect(padding).toEqual(0.15); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/utils/roughCeiling.test.js: -------------------------------------------------------------------------------- 1 | import { roughCeiling, DEFAULT_CEILING, DEFAULT_VALUE } from '../../src/utils/roughCeiling'; 2 | 3 | describe('roughCeiling', () => { 4 | test('Should return the value when below the default ceiling', () => { 5 | const roughness = DEFAULT_CEILING - 4; 6 | const result = roughCeiling({ roughness }); 7 | 8 | expect(result).toEqual(roughness); 9 | }); 10 | 11 | test('Should return the default ceiling value when above the default ceiling', () => { 12 | const roughness = DEFAULT_CEILING + 3; 13 | const result = roughCeiling({ roughness }); 14 | 15 | expect(result).toEqual(DEFAULT_CEILING); 16 | }); 17 | 18 | test('Should return the given ceiling when roughness value is above', () => { 19 | const ceiling = 12; 20 | const roughness = ceiling + 3; 21 | const result = roughCeiling({ roughness, ceiling }); 22 | 23 | expect(result).toEqual(ceiling); 24 | }); 25 | 26 | test('Should return the default value when no values are provided', () => { 27 | const result = roughCeiling({}); 28 | 29 | expect(result).toEqual(DEFAULT_VALUE); 30 | }); 31 | 32 | test('Should return the given default value when a roughness value is not provided', () => { 33 | const defaultValue = 4; 34 | const result = roughCeiling({ defaultValue }); 35 | 36 | expect(result).toEqual(defaultValue); 37 | }); 38 | 39 | test('Should return the value when 0', () => { 40 | const roughness = 0; 41 | const result = roughCeiling({ roughness, ceiling: 25, defaultValue: 1 }); 42 | 43 | expect(result).toEqual(roughness); 44 | }); 45 | 46 | test('Should return the default value when roughness is not a number', () => { 47 | const roughness = '100'; 48 | const result = roughCeiling({ roughness, ceiling: 25 }); 49 | 50 | expect(result).toEqual(DEFAULT_VALUE); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { terser } from "rollup-plugin-terser"; 3 | 4 | export default defineConfig({ 5 | build: { 6 | lib: { 7 | entry: "src/index.js", 8 | name: "roughViz", 9 | formats: ["es", "umd", "cjs"], 10 | fileName: (format) => `roughviz.${format}.js`, 11 | }, 12 | rollupOptions: { 13 | plugins: [terser()], 14 | output: { 15 | globals: {}, 16 | }, 17 | }, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /website/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | roughViz.js 5 | 6 | 7 | 8 | 9 | 10 | 110 | 114 | 115 | 116 | 117 |
118 |
119 |
120 | 125 | 126 |

127 | Easy, responsive JavaScript library for creating hand-drawn looking 128 | charts in the browser.
129 | 130 |

131 | Star 139 |
140 |
141 | 157 |
158 |
159 | 162 | 170 |
171 | 172 | 175 | 176 | 177 | 178 | 181 | 187 |
188 | 191 | 198 | 199 | 202 | 209 | 210 | 215 | 222 | 223 | 226 | 233 | 234 | 237 | 244 |

245 | To see the full list of options for each chart, visit the 246 | GitHub repo. 249 |

250 |
251 | 252 |
253 |
254 |
255 |

256 | Why? roughViz was built to provide an easy way to create 257 | interactive, "sketchy" plots in the browser. Use these charts where 258 | the communication goal is to show intent or generality, and not 259 | absolute precision. Or just because they're fun and look weird! 260 |

261 |

262 | Live, editable examples: 263 | available on observable.
266 | Documentation & API: 267 | available on GitHub. 268 |

269 |

Getting Started Is Simple

270 |

271 | 1. Import roughViz.js: First, import the library via 272 | a script tag or via npm: 273 |

274 |
275 |         
276 |     // Install with cdn:
277 |     <script src="https://unpkg.com/rough-viz@2.0.5"></script>
278 | 
279 |     # Install with npm:
280 |     $ npm install rough-viz 
281 |     
282 |

283 | 2. Create container: Simply create a container div 284 | and assign some dimensions (e.g. width and height). By default, the 285 | roughViz chart will be sized and responsive according to these 286 | dimensions. 287 |

288 |
289 |         
290 |    <div id="viz0" style="width: 500px; height: 500px;" ></div>
291 |     
292 |

293 | 3. Call chart: Use roughViz to create the desired 294 | chart, and feed in the required arguments. 295 |

296 |
297 |         
298 |   // create Donut chart using defined data & customize plot options
299 |   new roughViz.Donut(
300 |     {
301 |       element: '#viz0',
302 |       data: {
303 |         labels: ['North', 'South', 'East', 'West'],
304 |         values: [10, 5, 8, 3]
305 |       },
306 |       title: "Regions",
307 |       roughness: 8,
308 |       colors: ['red', 'orange', 'blue', 'skyblue'],
309 |       stroke: 'black',
310 |       strokeWidth: 3,
311 |       fillStyle: 'cross-hatch',
312 |       fillWeight: 3.5,
313 |     }
314 |   ); 
315 |     
316 |

317 | If you're using npm, simply import the module and use 318 | the roughViz namespace: 319 |

320 |
321 |       
322 |       # Install with npm:
323 |       $ npm install rough-viz 
324 |     
325 |       import { Bar, ... } from 'rough-viz';
326 |     
327 |

328 | 329 |

Made by Jared Wilber

330 |
331 |
332 |
333 | 334 | 335 | 336 | 352 | 353 | 354 | -------------------------------------------------------------------------------- /website/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jwilber/roughViz/17c7ea86bfa2e9f1d76fbc8ebcff3f414d374d76/website/logo.png -------------------------------------------------------------------------------- /website/main.js: -------------------------------------------------------------------------------- 1 | import "./style.css"; 2 | import { Bar } from "rough-viz"; 3 | 4 | // logo 5 | new Bar({ 6 | element: "#vizLogo", // container selection 7 | data: { labels: ["a", "b", "c", "d"], values: [20, 16, 5, 15] }, 8 | axisFontSize: 0, 9 | roughness: 3, 10 | color: "pink", 11 | fillStyle: "cross-hatch", 12 | margin: { top: 10, right: 20, bottom: 15, left: 20 }, 13 | interactive: false, 14 | }); 15 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rvtest", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 13 | "vite": "^5.0.0" 14 | }, 15 | "dependencies": { 16 | "d3-array": "^3.2.4", 17 | "d3-selection": "^3.0.0", 18 | "rough-viz": "^2.0.3" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /website/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /website/roughDemo.js: -------------------------------------------------------------------------------- 1 | import { 2 | Bar, 3 | BarH, 4 | Donut, 5 | Line, 6 | Network, 7 | Force, 8 | Pie, 9 | Scatter, 10 | StackedBar, 11 | } from "rough-viz"; 12 | import { range } from "d3-array"; 13 | import { select } from "d3-selection"; 14 | 15 | const anchor = "#introViz"; 16 | 17 | // event listeners for elements 18 | const roughSlider = document.getElementById("roughness-slider"); 19 | const roughLabel = document.getElementById("label-roughness-slider"); 20 | 21 | const strokeWidthSlider = document.getElementById("strokeWidth-slider"); 22 | const strokeWidthLabel = document.getElementById("label-strokeWidth-slider"); 23 | 24 | const innerStrokeWidthSlider = document.getElementById( 25 | "innerStrokeWidth-slider" 26 | ); 27 | const innerStrokeWidthLabel = document.getElementById( 28 | "label-innerStrokeWidth-slider" 29 | ); 30 | 31 | const fillWeightSlider = document.getElementById("fillWeight-slider"); 32 | const fillWeightLabel = document.getElementById("label-fillWeight-slider"); 33 | 34 | const axisRoughnessSlider = document.getElementById("axisRoughness-slider"); 35 | const axisRoughnessLabel = document.getElementById( 36 | "label-axisRoughness-slider" 37 | ); 38 | 39 | const colorSlider = document.getElementById("color-picker"); 40 | const colorLabel = document.getElementById("label-color-picker"); 41 | const strokeSlider = document.getElementById("stroke-picker"); 42 | const strokeLabel = document.getElementById("label-stroke-picker"); 43 | 44 | // const numNodes = 200; 45 | const numNodes = 104; 46 | const radius = 5; 47 | 48 | const dataLength = 20; 49 | 50 | const scatterData = { 51 | x: Array.from({ length: dataLength }, () => Math.random() * dataLength), 52 | y: Array.from({ length: dataLength }, () => Math.random() * dataLength), 53 | radius: Array.from( 54 | { length: dataLength }, 55 | () => Math.floor(Math.random() * 20) + 1 56 | ), 57 | }; 58 | 59 | function createNodes(numNodes) { 60 | return range(numNodes).map(() => { 61 | const randomValue = Math.random(); 62 | 63 | let multiplier = 64 | randomValue < 0.05 65 | ? 5 66 | : randomValue < 0.6 67 | ? 1 68 | : randomValue < 0.8 69 | ? 2 70 | : 3; 71 | 72 | return { 73 | radius: multiplier * radius, 74 | }; 75 | }); 76 | } 77 | 78 | function createLinks(numNodes) { 79 | return range(numNodes - 1).map((d, i) => ({ 80 | source: i, 81 | target: i + 1, 82 | })); 83 | } 84 | 85 | // resolve slider values 86 | 87 | let roughnessValue = parseFloat(roughSlider.value); 88 | let strokeWidthValue = parseFloat(strokeWidthSlider.value); 89 | let innerStrokeWidthValue = parseFloat(innerStrokeWidthSlider.value); 90 | let fillWeightValue = parseFloat(fillWeightSlider.value); 91 | let axisRoughnessValue = parseFloat(axisRoughnessSlider.value); 92 | let fillStyleValue = "hachure"; 93 | let colorVal = colorSlider.value; 94 | let strokeVal = strokeSlider.value; 95 | 96 | function getUpdatedValues() { 97 | colorVal = colorSlider.value; 98 | strokeVal = strokeSlider.value; 99 | 100 | roughnessValue = parseFloat(roughSlider.value); 101 | 102 | strokeWidthValue = parseFloat(strokeWidthSlider.value); 103 | 104 | innerStrokeWidthValue = parseFloat(innerStrokeWidthSlider.value); 105 | 106 | fillWeightValue = parseFloat(fillWeightSlider.value); 107 | 108 | axisRoughnessValue = parseFloat(axisRoughnessSlider.value); 109 | 110 | fillStyleValue = getCurrentFillStyle(); 111 | } 112 | 113 | // starting chart 114 | 115 | let mainViz = new Force({ 116 | element: anchor, 117 | data: createNodes(numNodes), 118 | collision: 1.2, 119 | textCallback: (d) => "Size: " + d.radius, 120 | radiusExtent: [10, 60], 121 | roughness: roughnessValue, 122 | fillStyle: fillStyleValue, 123 | stroke: strokeVal, 124 | color: colorVal, 125 | strokeWidth: strokeWidthValue, 126 | innerStrokeWidth: innerStrokeWidthValue, 127 | fillWeight: fillWeightValue, 128 | axisRoughness: axisRoughnessValue, 129 | }); 130 | 131 | function resolveControls() { 132 | console.log("resolveControls", selectedViz); 133 | 134 | if (["Donut", "Pie", "Force", "Network"].includes(selectedViz)) { 135 | select("#axisRoughness-slider").style("display", "none"); 136 | select("#label-axisRoughness-slider").style("display", "none"); 137 | } else { 138 | select("#axisRoughness-slider").style("display", "inline-block"); 139 | select("#label-axisRoughness-slider").style("display", "inline-block"); 140 | } 141 | if (["Donut", "Pie", "StackedBar", "Line"].includes(selectedViz)) { 142 | select("#color-picker").style("display", "none"); 143 | select("#label-color-picker").style("display", "none"); 144 | select("#stroke-picker").style("display", "none"); 145 | select("#label-stroke-picker").style("display", "none"); 146 | } else { 147 | select("#color-picker").style("display", "inline-block"); 148 | select("#label-color-picker").style("display", "inline-block"); 149 | select("#stroke-picker").style("display", "inline-block"); 150 | select("#label-stroke-picker").style("display", "inline-block"); 151 | } 152 | if (selectedViz === "Line") { 153 | select("#fillWeight-slider").style("display", "none"); 154 | select("#label-fillWeight-slider").style("display", "none"); 155 | select("#innerStrokeWidth-slider").style("display", "none"); 156 | select("#label-innerStrokeWidth-slider").style("display", "none"); 157 | select("#fillStyle-dropdown").style("display", "none"); 158 | select("#label-fillStyle-dropdown").style("display", "none"); 159 | } else { 160 | select("#fillWeight-slider").style("display", "inline-block"); 161 | select("#label-fillWeight-slider").style("display", "inline-block"); 162 | select("#innerStrokeWidth-slider").style("display", "inline-block"); 163 | select("#label-innerStrokeWidth-slider").style("display", "inline-block"); 164 | select("#fillStyle-dropdown").style("display", "inline-block"); 165 | select("#label-fillStyle-dropdown").style("display", "inline-block"); 166 | } 167 | } 168 | resolveControls(); 169 | function newChart() { 170 | getUpdatedValues(); 171 | resolveControls(); 172 | 173 | mainViz.remove(); 174 | if (selectedViz === "Force") { 175 | mainViz = new Force({ 176 | element: anchor, 177 | data: createNodes(numNodes), 178 | collision: 1.2, 179 | textCallback: (d) => "Size: " + d.radius, 180 | radiusExtent: [10, 60], 181 | roughness: roughnessValue, 182 | fillStyle: fillStyleValue, 183 | stroke: strokeVal, 184 | color: colorVal, 185 | strokeWidth: strokeWidthValue, 186 | innerStrokeWidth: innerStrokeWidthValue, 187 | fillWeight: fillWeightValue, 188 | axisRoughness: axisRoughnessValue, 189 | }); 190 | } 191 | if (selectedViz === "Bar") { 192 | mainViz = new Bar({ 193 | element: anchor, 194 | data: { 195 | labels: ["North", "South", "East", "West"], 196 | values: [10, 5, 8, 3], 197 | }, 198 | roughness: roughnessValue, 199 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 200 | highlight: "steelblue", 201 | stroke: strokeVal, 202 | color: colorVal, 203 | fillStyle: fillStyleValue, 204 | strokeWidth: strokeWidthValue, 205 | axisStrokeWidth: 1, 206 | innerStrokeWidth: innerStrokeWidthValue, 207 | fillWeight: fillWeightValue, 208 | axisRoughness: axisRoughnessValue, 209 | }); 210 | } 211 | if (selectedViz === "BarH") { 212 | mainViz = new BarH({ 213 | element: anchor, 214 | data: { 215 | labels: ["North", "South", "East", "West"], 216 | values: [10, 5, 8, 3], 217 | }, 218 | roughness: roughnessValue, 219 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 220 | highlight: "steelblue", 221 | stroke: strokeVal, 222 | color: colorVal, 223 | fillStyle: fillStyleValue, 224 | strokeWidth: strokeWidthValue, 225 | axisStrokeWidth: 1, 226 | innerStrokeWidth: innerStrokeWidthValue, 227 | fillWeight: fillWeightValue, 228 | axisRoughness: axisRoughnessValue, 229 | padding: 0.15, 230 | }); 231 | } 232 | if (selectedViz === "Donut") { 233 | mainViz = new Donut({ 234 | element: anchor, 235 | data: { 236 | labels: ["North", "South", "East", "West"], 237 | values: [10, 5, 8, 3], 238 | }, 239 | roughness: roughnessValue, 240 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 241 | stroke: strokeVal, 242 | color: colorVal, 243 | highlight: "steelblue", 244 | fillStyle: fillStyleValue, 245 | strokeWidth: strokeWidthValue, 246 | axisStrokeWidth: 1, 247 | innerStrokeWidth: innerStrokeWidthValue, 248 | fillWeight: fillWeightValue, 249 | axisRoughness: axisRoughnessValue, 250 | }); 251 | } 252 | if (selectedViz === "Pie") { 253 | mainViz = new Pie({ 254 | element: anchor, 255 | data: { 256 | labels: ["North", "South", "East", "West"], 257 | values: [10, 5, 8, 3], 258 | }, 259 | roughness: roughnessValue, 260 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 261 | stroke: strokeVal, 262 | color: colorVal, 263 | fillStyle: fillStyleValue, 264 | strokeWidth: strokeWidthValue, 265 | axisStrokeWidth: 1, 266 | innerStrokeWidth: innerStrokeWidthValue, 267 | fillWeight: fillWeightValue, 268 | axisRoughness: axisRoughnessValue, 269 | }); 270 | } 271 | if (selectedViz === "Network") { 272 | mainViz = new Network({ 273 | element: anchor, 274 | data: createNodes(35), 275 | links: createLinks(35), 276 | collision: 3.05, 277 | radiusExtent: [10, 60], 278 | roughness: roughnessValue, 279 | fillStyle: fillStyleValue, 280 | stroke: strokeVal, 281 | color: colorVal, 282 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 283 | textCallback: (d) => "Size: " + d.radius, 284 | strokeWidth: strokeWidthValue, 285 | fillStyle: fillStyleValue, 286 | strokeWidth: strokeWidthValue, 287 | axisStrokeWidth: 1, 288 | innerStrokeWidth: innerStrokeWidthValue, 289 | fillWeight: fillWeightValue, 290 | axisRoughness: axisRoughnessValue, 291 | }); 292 | } 293 | if (selectedViz === "Line") { 294 | mainViz = new Line({ 295 | element: anchor, 296 | data: { y: scatterData["y"], y2: scatterData["x"] }, 297 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 298 | roughness: roughnessValue, 299 | stroke: strokeVal, 300 | color: colorVal, 301 | fillStyle: fillStyleValue, 302 | strokeWidth: strokeWidthValue, 303 | axisStrokeWidth: 1, 304 | innerStrokeWidth: innerStrokeWidthValue, 305 | fillWeight: fillWeightValue, 306 | axisRoughness: axisRoughnessValue, 307 | circle: false, 308 | }); 309 | } 310 | if (selectedViz === "Scatter") { 311 | mainViz = new Scatter({ 312 | element: anchor, 313 | data: scatterData, 314 | roughness: roughnessValue, 315 | fillStyle: fillStyleValue, 316 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 317 | stroke: strokeVal, 318 | colors: colorVal, 319 | fillStyle: fillStyleValue, 320 | radius: 20, 321 | strokeWidth: strokeWidthValue, 322 | axisStrokeWidth: 1, 323 | innerStrokeWidth: innerStrokeWidthValue, 324 | fillWeight: fillWeightValue, 325 | axisRoughness: axisRoughnessValue, 326 | }); 327 | } 328 | if (selectedViz === "StackedBar") { 329 | mainViz = new StackedBar({ 330 | element: anchor, 331 | data: [ 332 | { month: "Jan", A: 20, B: 5, C: 8, D: 12 }, 333 | { month: "Feb", A: 25, B: 10, C: 9, D: 5 }, 334 | { month: "March", A: 15, B: 5, C: 19, D: 9 }, 335 | ], 336 | labels: "month", 337 | roughness: roughnessValue, 338 | fillStyle: fillStyleValue, 339 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 340 | stroke: strokeVal, 341 | color: colorVal, 342 | fillStyle: fillStyleValue, 343 | strokeWidth: strokeWidthValue, 344 | axisStrokeWidth: 1, 345 | innerStrokeWidth: innerStrokeWidthValue, 346 | fillWeight: fillWeightValue, 347 | axisRoughness: axisRoughnessValue, 348 | }); 349 | } 350 | } 351 | 352 | function updateChart() { 353 | mainViz.remove(); 354 | getUpdatedValues(); 355 | 356 | updateSliderLabels(); 357 | if (selectedViz === "Force") { 358 | mainViz.redraw({ 359 | element: anchor, 360 | data: createNodes(numNodes), 361 | collision: 1.2, 362 | textCallback: (d) => "Size: " + d.radius, 363 | radiusExtent: [10, 60], 364 | roughness: roughnessValue, 365 | fillStyle: fillStyleValue, 366 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 367 | stroke: strokeVal, 368 | color: colorVal, 369 | strokeWidth: strokeWidthValue, 370 | axisStrokeWidth: 1, 371 | innerStrokeWidth: innerStrokeWidthValue, 372 | fillWeight: fillWeightValue, 373 | axisRoughness: axisRoughnessValue, 374 | }); 375 | } 376 | if (selectedViz === "Bar") { 377 | mainViz.redraw({ 378 | element: anchor, 379 | data: { 380 | labels: ["North", "South", "East", "West"], 381 | values: [10, 5, 8, 3], 382 | }, 383 | roughness: roughnessValue, 384 | fillStyle: fillStyleValue, 385 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 386 | stroke: strokeVal, 387 | color: colorVal, 388 | highlight: "steelblue", 389 | strokeWidth: strokeWidthValue, 390 | axisStrokeWidth: 1, 391 | innerStrokeWidth: innerStrokeWidthValue, 392 | fillWeight: fillWeightValue, 393 | axisRoughness: axisRoughnessValue, 394 | }); 395 | } 396 | if (selectedViz === "BarH") { 397 | mainViz.redraw({ 398 | element: anchor, 399 | data: { 400 | labels: ["North", "South", "East", "West"], 401 | values: [10, 5, 8, 3], 402 | }, 403 | roughness: roughnessValue, 404 | fillStyle: fillStyleValue, 405 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 406 | stroke: strokeVal, 407 | color: colorVal, 408 | strokeWidth: strokeWidthValue, 409 | axisStrokeWidth: 1, 410 | innerStrokeWidth: innerStrokeWidthValue, 411 | fillWeight: fillWeightValue, 412 | axisRoughness: axisRoughnessValue, 413 | }); 414 | } 415 | if (selectedViz === "Pie") { 416 | mainViz.redraw({ 417 | element: anchor, 418 | data: { 419 | labels: ["North", "South", "East", "West"], 420 | values: [10, 5, 8, 3], 421 | }, 422 | roughness: roughnessValue, 423 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 424 | stroke: strokeVal, 425 | color: colorVal, 426 | fillStyle: fillStyleValue, 427 | strokeWidth: strokeWidthValue, 428 | axisStrokeWidth: 1, 429 | innerStrokeWidth: innerStrokeWidthValue, 430 | fillWeight: fillWeightValue, 431 | axisRoughness: axisRoughnessValue, 432 | }); 433 | } 434 | if (selectedViz === "Donut") { 435 | mainViz.redraw({ 436 | element: anchor, 437 | data: { 438 | labels: ["North", "South", "East", "West"], 439 | values: [10, 5, 8, 3], 440 | }, 441 | roughness: roughnessValue, 442 | fillStyle: fillStyleValue, 443 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 444 | stroke: strokeVal, 445 | color: colorVal, 446 | strokeWidth: strokeWidthValue, 447 | axisStrokeWidth: 1, 448 | innerStrokeWidth: innerStrokeWidthValue, 449 | fillWeight: fillWeightValue, 450 | axisRoughness: axisRoughnessValue, 451 | }); 452 | } 453 | if (selectedViz === "Network") { 454 | mainViz.redraw({ 455 | element: anchor, 456 | data: createNodes(35), 457 | links: createLinks(35), 458 | collision: 3.05, 459 | radiusExtent: [10, 60], 460 | roughness: roughnessValue, 461 | fillStyle: fillStyleValue, 462 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 463 | textCallback: (d) => "Size: " + d.radius, 464 | stroke: strokeVal, 465 | color: colorVal, 466 | strokeWidth: strokeWidthValue, 467 | axisStrokeWidth: 1, 468 | innerStrokeWidth: innerStrokeWidthValue, 469 | fillWeight: fillWeightValue, 470 | axisRoughness: axisRoughnessValue, 471 | }); 472 | } 473 | if (selectedViz === "Line") { 474 | mainViz.redraw({ 475 | element: anchor, 476 | data: { y: scatterData["y"], y2: scatterData["x"] }, 477 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 478 | roughness: roughnessValue, 479 | stroke: strokeVal, 480 | color: colorVal, 481 | fillStyle: fillStyleValue, 482 | strokeWidth: strokeWidthValue, 483 | axisStrokeWidth: 1, 484 | innerStrokeWidth: innerStrokeWidthValue, 485 | fillWeight: fillWeightValue, 486 | axisRoughness: axisRoughnessValue, 487 | circle: false, 488 | }); 489 | } 490 | if (selectedViz === "Scatter") { 491 | mainViz.redraw({ 492 | element: anchor, 493 | data: scatterData, 494 | x: "x", 495 | y: "y", 496 | roughness: roughnessValue, 497 | fillStyle: fillStyleValue, 498 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 499 | stroke: strokeVal, 500 | colors: colorVal, 501 | radius: 20, 502 | strokeWidth: strokeWidthValue, 503 | axisStrokeWidth: 1, 504 | innerStrokeWidth: innerStrokeWidthValue, 505 | fillWeight: fillWeightValue, 506 | axisRoughness: axisRoughnessValue, 507 | }); 508 | } 509 | 510 | if (selectedViz === "StackedBar") { 511 | mainViz.redraw({ 512 | element: anchor, 513 | data: [ 514 | { month: "Jan", A: 20, B: 5, C: 8, D: 12 }, 515 | { month: "Feb", A: 25, B: 10, C: 9, D: 5 }, 516 | { month: "March", A: 15, B: 5, C: 19, D: 9 }, 517 | ], 518 | labels: "month", 519 | roughness: roughnessValue, 520 | fillStyle: fillStyleValue, 521 | margin: { top: 100, left: 100, right: 100, bottom: 100 }, 522 | stroke: strokeVal, 523 | color: colorVal, 524 | strokeWidth: strokeWidthValue, 525 | axisStrokeWidth: 1, 526 | innerStrokeWidth: innerStrokeWidthValue, 527 | fillWeight: fillWeightValue, 528 | axisRoughness: axisRoughnessValue, 529 | }); 530 | } 531 | } 532 | 533 | function handleDropdownChange(event) { 534 | const selectedValue = event.target.value; 535 | 536 | if (fillStyleValue !== selectedValue) { 537 | fillStyleValue = selectedValue; 538 | } 539 | 540 | updateChart(); 541 | } 542 | 543 | function getCurrentFillStyle() { 544 | const dropdown = document.querySelector('[name="fillStyle"]'); 545 | if (dropdown) { 546 | return dropdown.value; 547 | } 548 | 549 | return "null"; 550 | } 551 | 552 | function updateSliderLabels() { 553 | roughLabel.textContent = "Roughness: " + roughSlider.value; 554 | strokeWidthLabel.textContent = "strokeWidth: " + strokeWidthSlider.value; 555 | innerStrokeWidthLabel.textContent = 556 | "innerStrokeWidth: " + innerStrokeWidthSlider.value; 557 | fillWeightLabel.textContent = "fillWeight: " + fillWeightSlider.value; 558 | axisRoughnessLabel.textContent = 559 | "axisRoughness: " + axisRoughnessSlider.value; 560 | colorLabel.textContent = "Color: " + colorSlider.value; 561 | strokeLabel.textContent = "Stroke: " + strokeSlider.value; 562 | } 563 | 564 | // Assuming the ID of the dropdown is "fillStyle-dropdown", add an event listener to handle changes: 565 | document 566 | .getElementById("fillStyle-dropdown") 567 | .addEventListener("change", handleDropdownChange); 568 | 569 | roughSlider.addEventListener("change", updateChart); 570 | 571 | strokeWidthSlider.addEventListener("change", updateChart); 572 | 573 | innerStrokeWidthSlider.addEventListener("change", updateChart); 574 | 575 | fillWeightSlider.addEventListener("change", updateChart); 576 | axisRoughnessSlider.addEventListener("change", updateChart); 577 | 578 | colorSlider.addEventListener("change", updateChart); 579 | strokeSlider.addEventListener("change", updateChart); 580 | 581 | document.querySelectorAll(".menuItem").forEach((item) => { 582 | item.addEventListener("click", (event) => { 583 | newChart(); 584 | }); 585 | }); 586 | -------------------------------------------------------------------------------- /website/style.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Gaegu"); 2 | 3 | main { 4 | margin: auto; 5 | max-width: 1400px; 6 | } 7 | 8 | #viz0, #viz1, #viz2, #viz3, #viz4, #viz5 { 9 | border: 2px solid black; 10 | width: 500px; 11 | height: 250px; 12 | } 13 | 14 | .side { 15 | display: flex; 16 | } 17 | 18 | 19 | .wrapper { 20 | display: flex; 21 | flex: wrap; 22 | } 23 | * { 24 | font-family: "Gaegu"; 25 | } 26 | h1, 27 | h4 { 28 | font-family: "Gaegu"; 29 | font-weight: 0.1; 30 | padding-bottom: 0; 31 | margin-bottom: 0; 32 | } 33 | 34 | .logo { 35 | display: flex; 36 | flex-direction: row; 37 | margin: auto; 38 | justify-content: center; 39 | height: 100%; 40 | } 41 | 42 | #vizLogo { 43 | width: 10%; 44 | } 45 | .wrapper { 46 | display: flex; 47 | flex: wrap; 48 | } 49 | 50 | h1, 51 | h2, 52 | h4 { 53 | font-family: "Gaegu"; 54 | font-weight: lighter; 55 | padding-bottom: 0; 56 | margin-bottom: 0; 57 | margin-top: 0; 58 | } 59 | 60 | h2 { 61 | font-family: "Gaegu"; 62 | font-size: 2.5rem; 63 | } 64 | 65 | p { 66 | font-family: "Gaegu"; 67 | color: black; 68 | font-size: 18px; 69 | } 70 | 71 | 72 | 73 | .project-grid { 74 | width: 100%; 75 | display: flex; 76 | justify-content: center; 77 | flex-wrap: wrap; 78 | } 79 | 80 | .project-wrapper { 81 | flex-basis: 25%; 82 | padding: 1.9rem; 83 | margin-bottom: 0; 84 | } 85 | 86 | .desc { 87 | font-size: 1.1rem; 88 | } 89 | 90 | hr { 91 | max-width: 50%; 92 | opacity: 0.3; 93 | } 94 | 95 | .start { 96 | max-width: 45%; 97 | } 98 | 99 | body { 100 | background-color: #fff; 101 | } 102 | 103 | .blue { 104 | color: #4fabc9; 105 | } 106 | .red { 107 | color: coral; 108 | } 109 | .purple { 110 | color: orange; 111 | } 112 | .yellow { 113 | color: #b88747; 114 | } 115 | .green { 116 | color: #4fabc9; 117 | } 118 | 119 | #introViz { 120 | margin: auto; 121 | width: 100%; 122 | height: 80vh; 123 | } 124 | .link { 125 | stroke: black; 126 | stroke-width: 1; 127 | } 128 | 129 | h3 { 130 | margin: 2px; 131 | } 132 | 133 | 134 | 135 | 136 | code { 137 | display: block; 138 | text-align: left; 139 | padding-right: 0.5em; 140 | overflow-x: auto; 141 | max-width: 100%; 142 | border: 1px solid pink; 143 | font-family: monospace; 144 | text-align: left; 145 | width: 100%; 146 | font-size: 15px; 147 | padding: 0; 148 | margin: 0; 149 | } 150 | 151 | pre, code { 152 | margin: 0; 153 | padding: 0; 154 | border: none; 155 | } 156 | 157 | pre, code { 158 | white-space: pre-wrap; 159 | } 160 | 161 | pre { 162 | padding: 0 1em; 163 | border-radius: 5px; 164 | border: 1.5px solid black; 165 | background: snow; 166 | 167 | } 168 | 169 | a { 170 | text-decoration: underline; 171 | text-decoration-color: pink; 172 | } 173 | 174 | 175 | -------------------------------------------------------------------------------- /website/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [svelte()], 7 | base: "", 8 | }); 9 | --------------------------------------------------------------------------------