├── LICENSE ├── README.md ├── index.js └── package.json /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Bumbeishvili 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://github.com/vshymanskyy/StandWithUkraine/blob/main/docs/README.md) 2 | 3 | 4 | # Data driven range slider 5 | 6 | Add interactivity to your web apps 7 | 8 | [![NPM Version](https://d25lcipzij17d.cloudfront.net/badge.svg?id=js&type=6&v=1.0.0&x2=0)](https://npmjs.org/package/data-driven-range-slider) 9 | 10 |

11 | 12 | 13 |

14 | 15 | D3 and svg based data driven range slider, with good performance 16 | 17 | Check out [Introduction](https://dev.to/dbumbeishvili/data-driven-range-slider-introduction-4mj) 18 | 19 | Check out examples 20 | * [Observable example](https://observablehq.com/@bumbeishvili/data-driven-range-slider) (Most Updated) 21 | * [JSFiddle example](https://jsfiddle.net/079nk83L/2/) 22 | 23 | Check out several libraries and frameworks integrations 24 | ### Integrations 25 | * [Vue.js Integration](https://stackblitz.com/edit/data-driven-range-slider-vue-integration) 26 | * [React integration](https://stackblitz.com/edit/data-driven-range-slider-react-integration) 27 | * [Angular integration](https://stackblitz.com/edit/data-driven-range-slider-angular-integration) 28 | 29 | 30 | ### Installing 31 | 32 | ``` 33 | npm i data-driven-range-slider 34 | ``` 35 | 36 | ### Usage 37 | ```javascript 38 | const RangeSlider = require ('https://bundle.run/data-driven-range-slider@1.0.0'); 39 | 40 | 41 | new RangeSlider() 42 | .container() 43 | .data() 44 | .accessor(d=> d.) 45 | .aggregator(group => group.values.length) 46 | .onBrush(d=> /* Handle range values */) 47 | 48 | .svgWidth(800) 49 | .svgHeight(100) 50 | .render() 51 | 52 | ``` 53 | 54 | ## Author 55 | [David B (twitter)](https://twitter.com/dbumbeishvili) 56 | [David B (linkedin)](https://www.linkedin.com/in/bumbeishvili/) 57 | 58 | I am available for freelance data visualization work. Please [contact me](https://davidb.dev/about) in case you'd like me to help you with my experience and expertise 59 | 60 | You can also [book data viz related consultation session](https://www.fiverr.com/share/4XxG21) with me 61 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | d3 = window.d3||require('d3'); 2 | 3 | class RangeSlider { 4 | constructor() { 5 | const attrs = { 6 | id: "ID" + Math.floor(Math.random() * 1000000), 7 | svgWidth: 400, 8 | svgHeight: 400, 9 | marginTop: 10, 10 | marginBottom: 0, 11 | marginRight: 0, 12 | marginLeft: 40, 13 | container: "body", 14 | defaultTextFill: "#2C3E50", 15 | defaultFont: "Helvetica", 16 | data: null, 17 | accessor: null, 18 | aggregator: null, 19 | yScale: null, 20 | freezeMin: null, 21 | onBrush: (d) => d, 22 | yScale: d3.scaleLinear(), 23 | yTicks: 4, 24 | freezeMin: false, 25 | startSelection: 100, 26 | svg:null 27 | }; 28 | 29 | 30 | this.getChartState = () => attrs; 31 | 32 | Object.keys(attrs).forEach((key) => { 33 | //@ts-ignore 34 | this[key] = function (_) { 35 | var string = `attrs['${key}'] = _`; 36 | if (!arguments.length) { 37 | return eval(`attrs['${key}'];`); 38 | } 39 | eval(string); 40 | return this; 41 | }; 42 | }); 43 | 44 | this.initializeEnterExitUpdatePattern(); 45 | } 46 | 47 | // Fancy version of d3 join 48 | initializeEnterExitUpdatePattern() { 49 | d3.selection.prototype.patternify = function (params) { 50 | var container = this; 51 | var selector = params.selector; 52 | var elementTag = params.tag; 53 | var data = params.data || [selector]; 54 | 55 | // Pattern in action 56 | var selection = container.selectAll("." + selector).data(data, (d, i) => { 57 | if (typeof d === "object") { 58 | if (d.id) { 59 | return d.id; 60 | } 61 | } 62 | return i; 63 | }); 64 | selection.exit().remove(); 65 | selection = selection.enter().append(elementTag).merge(selection); 66 | selection.attr("class", selector); 67 | return selection; 68 | }; 69 | } 70 | 71 | drawChartTemplate() { 72 | const attrs = this.getChartState(); 73 | const calc = attrs.calc; 74 | 75 | //Drawing containers 76 | var container = d3.select(attrs.container); 77 | var containerRect = container.node().getBoundingClientRect(); 78 | if (containerRect.width > 0) attrs.svgWidth = containerRect.width; 79 | 80 | //Add svg 81 | var svg = container 82 | .patternify({ 83 | tag: "svg", 84 | selector: "svg-chart-container", 85 | }) 86 | .style("overflow", "visible") 87 | .attr("width", attrs.svgWidth) 88 | .attr("height", attrs.svgHeight) 89 | .attr("font-family", attrs.defaultFont); 90 | 91 | //Add container g element 92 | var chart = svg 93 | .patternify({ 94 | tag: "g", 95 | selector: "chart", 96 | }) 97 | .attr( 98 | "transform", 99 | "translate(" + calc.chartLeftMargin + "," + calc.chartTopMargin + ")" 100 | ); 101 | 102 | // Share chart 103 | attrs.chart = chart; 104 | attrs.svg = svg; 105 | } 106 | 107 | drawBrushHandles() { 108 | const attrs = this.getChartState(); 109 | const brush = attrs.brush; 110 | const calc = attrs.calc; 111 | 112 | const handlerWidth = 2, 113 | handlerFill = "#3F434D", 114 | middleHandlerWidth = 10, 115 | middleHandlerStroke = "#D4D7DF", 116 | middleHandlerFill = "#486878"; 117 | 118 | var handle = brush 119 | .patternify({ 120 | tag: "g", 121 | selector: "custom-handle", 122 | data: [ 123 | { 124 | left: true, 125 | }, 126 | { 127 | left: false, 128 | }, 129 | ], 130 | }) 131 | .attr("cursor", "ew-resize") 132 | .attr("pointer-events", "all") 133 | 134 | 135 | handle 136 | .patternify({ 137 | tag: "rect", 138 | selector: "custom-handle-rect", 139 | data: (d) => [d], 140 | }) 141 | .attr("width", handlerWidth) 142 | .attr("height", calc.chartHeight) 143 | .attr("fill", handlerFill) 144 | .attr("stroke", handlerFill) 145 | .attr("y", -calc.chartHeight / 2) 146 | .attr("pointer-events", "none"); 147 | 148 | handle 149 | .patternify({ 150 | tag: "rect", 151 | selector: "custom-handle-rect-middle", 152 | data: (d) => [d], 153 | }) 154 | .attr("width", middleHandlerWidth) 155 | .attr("height", 30) 156 | .attr("fill", middleHandlerFill) 157 | .attr("stroke", middleHandlerStroke) 158 | .attr("y", -16) 159 | .attr("x", -middleHandlerWidth / 4) 160 | .attr("pointer-events", "none") 161 | .attr("rx", 3); 162 | 163 | handle 164 | .patternify({ 165 | tag: "rect", 166 | selector: "custom-handle-rect-line-left", 167 | data: (d) => [d], 168 | }) 169 | .attr("width", 0.7) 170 | .attr("height", 20) 171 | .attr("fill", middleHandlerStroke) 172 | .attr("stroke", middleHandlerStroke) 173 | .attr("y", -100 / 6 + 5) 174 | .attr("x", -middleHandlerWidth / 4 + 3) 175 | .attr("pointer-events", "none"); 176 | 177 | handle 178 | .patternify({ 179 | tag: "rect", 180 | selector: "custom-handle-rect-line-right", 181 | data: (d) => [d], 182 | }) 183 | .attr("width", 0.7) 184 | .attr("height", 20) 185 | .attr("fill", middleHandlerStroke) 186 | .attr("stroke", middleHandlerStroke) 187 | .attr("y", -100 / 6 + 5) 188 | .attr("x", -middleHandlerWidth / 4 + middleHandlerWidth - 3) 189 | .attr("pointer-events", "none"); 190 | 191 | handle.attr("display", "none"); 192 | 193 | // Share props 194 | attrs.handle = handle 195 | } 196 | 197 | createScales() { 198 | const attrs = this.getChartState(); 199 | const dataFinal = attrs.dataFinal; 200 | const accessorFunc = attrs.accessorFunc; 201 | const isDate = attrs.isDate; 202 | const dateScale = attrs.dateScale; 203 | const calc = attrs.calc; 204 | 205 | const groupedInitial = this.group(dataFinal) 206 | .by((d, i) => { 207 | const field = accessorFunc(d); 208 | if (isDate) { 209 | return Math.round(dateScale(field)); 210 | } 211 | return field; 212 | }) 213 | .orderBy((d) => d.key) 214 | .run(); 215 | 216 | const grouped = groupedInitial.map((d) => 217 | Object.assign(d, { value: typeof attrs.aggregator == "function" ? attrs.aggregator(d) : d.values.length }) 218 | ); 219 | 220 | const values = grouped.map((d) => d.value); 221 | const max = d3.max(values); 222 | const maxX = grouped[grouped.length - 1].key; 223 | const minX = grouped[0].key; 224 | 225 | var minDiff = d3.min(grouped, (d, i, arr) => { 226 | if (!i) return Infinity; 227 | return d.key - arr[i - 1].key; 228 | }); 229 | 230 | let eachBarWidth = calc.chartWidth / minDiff / (maxX - minX); 231 | if (eachBarWidth > 20) { eachBarWidth = 20; } 232 | if (minDiff < 1) { eachBarWidth = eachBarWidth * minDiff; } 233 | if (eachBarWidth < 1) { eachBarWidth = 1; } 234 | 235 | const scale = attrs.yScale 236 | .domain([calc.minY, max]) 237 | .range([0, calc.chartHeight - 25]); 238 | const scaleY = scale 239 | .copy() 240 | .domain([max, calc.minY]) 241 | .range([0, calc.chartHeight - 25]); 242 | 243 | const scaleX = d3 244 | .scaleLinear() 245 | .domain([minX, maxX]) 246 | .range([0, calc.chartWidth]); 247 | 248 | attrs.scale = scale; 249 | attrs.scaleX = scaleX; 250 | attrs.scaleY = scaleY; 251 | attrs.max = max; 252 | attrs.minX = minX; 253 | attrs.maxX = maxX; 254 | attrs.grouped = grouped; 255 | attrs.eachBarWidth = eachBarWidth; 256 | attrs.scale = scale; 257 | } 258 | 259 | render() { 260 | const that = this; 261 | const attrs = this.getChartState(); 262 | 263 | //Calculated properties 264 | var calc = { 265 | id: null, 266 | chartTopMargin: null, 267 | chartLeftMargin: null, 268 | chartWidth: null, 269 | chartHeight: null, 270 | }; 271 | calc.id = "ID" + Math.floor(Math.random() * 1000000); // id for event handlings 272 | calc.chartLeftMargin = attrs.marginLeft; 273 | calc.chartTopMargin = attrs.marginTop; 274 | calc.chartWidth = attrs.svgWidth - attrs.marginRight - calc.chartLeftMargin; 275 | calc.chartHeight = attrs.svgHeight - attrs.marginBottom - calc.chartTopMargin; 276 | calc.minY = attrs.yScale ? 0.0001 : 0; 277 | attrs.calc = calc; 278 | 279 | var accessorFunc = (d) => d; 280 | if (attrs.data[0].value != null) { 281 | accessorFunc = (d) => d.value; 282 | } 283 | if (attrs.accessor && typeof attrs.accessor == "function") { 284 | accessorFunc = attrs.accessor; 285 | } 286 | const dataFinal = attrs.data; 287 | attrs.accessorFunc = accessorFunc; 288 | const isDate = Object.prototype.toString.call(accessorFunc(dataFinal[0])) === "[object Date]"; 289 | attrs.isDate = isDate; 290 | 291 | 292 | var dateExtent, 293 | dateScale, 294 | scaleTime, 295 | dateRangesCount, 296 | dateRanges, 297 | scaleTime; 298 | if (isDate) { 299 | dateExtent = d3.extent(dataFinal.map(accessorFunc)); 300 | dateRangesCount = Math.round(calc.chartWidth / 5); 301 | dateScale = d3.scaleTime().domain(dateExtent).range([0, dateRangesCount]); 302 | scaleTime = d3.scaleTime().domain(dateExtent).range([0, calc.chartWidth]); 303 | dateRanges = d3 304 | .range(dateRangesCount) 305 | .map((d) => [dateScale.invert(d), dateScale.invert(d + 1)]); 306 | } 307 | 308 | attrs.dateScale = dateScale; 309 | attrs.dataFinal = dataFinal; 310 | attrs.scaleTime = scaleTime; 311 | 312 | this.drawChartTemplate(); 313 | var chart = attrs.chart; 314 | var svg = attrs.svg; 315 | 316 | this.createScales(); 317 | const scaleX = attrs.scaleX; 318 | const scaleY = attrs.scaleY; 319 | const max = attrs.max; 320 | const grouped = attrs.grouped; 321 | const eachBarWidth = attrs.eachBarWidth; 322 | const scale = attrs.scale; 323 | 324 | var axis = d3.axisBottom(scaleX); 325 | if (isDate) { 326 | axis = d3.axisBottom(scaleTime); 327 | } 328 | const axisY = d3 329 | .axisLeft(scaleY) 330 | .tickSize(-calc.chartWidth - 20) 331 | .ticks(max == 1 ? 1 : attrs.yTicks) 332 | .tickFormat(d3.format(".2s")); 333 | 334 | const bars = chart 335 | .patternify({ tag: "rect", selector: "bar", data: grouped }) 336 | .attr("class", "bar") 337 | .attr("pointer-events", "none") 338 | .attr("width", eachBarWidth) 339 | .attr("height", (d) => scale(d.value)) 340 | .attr("fill", "#424853") 341 | .attr("y", (d) => -scale(d.value) + (calc.chartHeight - 25)) 342 | .attr("x", (d, i) => scaleX(d.key) - eachBarWidth / 2) 343 | .attr("opacity", 0.9); 344 | 345 | const xAxisWrapper = chart 346 | .patternify({ tag: "g", selector: "x-axis" }) 347 | .attr("transform", `translate(${0},${calc.chartHeight - 25})`) 348 | .call(axis); 349 | 350 | const yAxisWrapper = chart 351 | .patternify({ tag: "g", selector: "y-axis" }) 352 | .attr("transform", `translate(${-10},${0})`) 353 | .call(axisY); 354 | 355 | const brush = chart.patternify({ tag: "g", selector: "brush" }).call( 356 | d3 357 | .brushX() 358 | .extent([ 359 | [0, 0], 360 | [calc.chartWidth, calc.chartHeight], 361 | ]) 362 | .on("start", brushStarted) 363 | .on("end", brushEnded) 364 | .on("brush", brushed) 365 | ); 366 | 367 | attrs.brush = brush; 368 | this.drawBrushHandles(); 369 | const handle = attrs.handle; 370 | 371 | chart 372 | .selectAll(".selection") 373 | .attr("fill-opacity", 0.1) 374 | .attr("fill", "white") 375 | .attr("stroke-opacity", 0.4); 376 | 377 | 378 | 379 | function brushStarted() { 380 | if (d3.event.selection) { 381 | attrs.startSelection = d3.event.selection[0]; 382 | } 383 | } 384 | 385 | function brushEnded() { 386 | const attrs = that.getChartState(); 387 | var minX = attrs.minX; 388 | var maxX = attrs.maxX; 389 | 390 | if (!d3.event.selection) { 391 | handle.attr("display", "none"); 392 | 393 | output({ 394 | range: [minX, maxX], 395 | }); 396 | return; 397 | } 398 | if (d3.event.sourceEvent.type === "brush") return; 399 | 400 | var d0 = d3.event.selection.map(scaleX.invert), 401 | d1 = d0.map(d3.timeDay.round); 402 | 403 | if (d1[0] >= d1[1]) { 404 | d1[0] = d3.timeDay.floor(d0[0]); 405 | d1[1] = d3.timeDay.offset(d1[0]); 406 | } 407 | } 408 | 409 | function brushed(d) { 410 | if (d3.event.sourceEvent.type === "brush") return; 411 | if (attrs.freezeMin) { 412 | if (d3.event.selection[0] < attrs.startSelection) { 413 | d3.event.selection[1] = Math.min( 414 | d3.event.selection[0], 415 | d3.event.selection[1] 416 | ); 417 | } 418 | if (d3.event.selection[0] >= attrs.startSelection) { 419 | d3.event.selection[1] = Math.max( 420 | d3.event.selection[0], 421 | d3.event.selection[1] 422 | ); 423 | } 424 | 425 | d3.event.selection[0] = 0; 426 | d3.select(this).call(d3.event.target.move, d3.event.selection); 427 | } 428 | 429 | var d0 = d3.event.selection.map(scaleX.invert); 430 | const s = d3.event.selection; 431 | 432 | handle.attr("display", null).attr("transform", function (d, i) { 433 | return "translate(" + (s[i] - 2) + "," + (calc.chartHeight / 2 - 25) + ")"; 434 | }); 435 | output({ 436 | range: d0, 437 | }); 438 | } 439 | 440 | yAxisWrapper.selectAll(".domain").remove(); 441 | xAxisWrapper.selectAll(".domain").attr("opacity", 0.1); 442 | xAxisWrapper.selectAll("text").attr("fill", "#9CA1AE"); 443 | yAxisWrapper.selectAll("text").attr("fill", "#9CA1AE"); 444 | svg.selectAll('.selection').attr('transform', 'translate(0,-25)') 445 | 446 | chart 447 | .selectAll(".tick line") 448 | .attr("opacity", 0.1) 449 | .attr("stroke-dasharray", "2 2"); 450 | 451 | function output(value) { 452 | const result = value; 453 | result.data = getData(result.range); 454 | if (isDate) { 455 | result.range = value.range.map((d) => dateScale.invert(d)); 456 | } 457 | attrs.onBrush(result); 458 | } 459 | 460 | function getData(range) { 461 | const dataBars = bars 462 | .attr("fill", "#535966") 463 | .filter((d) => { 464 | return d.key >= range[0] && d.key <= range[1]; 465 | }) 466 | .attr("fill", "#72A3B7") 467 | .nodes() 468 | .map((d) => d.__data__) 469 | .map((d) => d.values) 470 | .reduce((a, b) => a.concat(b), []); 471 | 472 | return dataBars; 473 | } 474 | 475 | return this; 476 | } 477 | 478 | updateData(data) { 479 | const attrs = this.getChartState(); 480 | return this; 481 | } 482 | 483 | // Advanced group by func 484 | group(arr) { 485 | const that = this; 486 | const operations = []; 487 | const initialData = arr; 488 | const resultObj = {}; 489 | let resultArr; 490 | let sort = function (a, b) { 491 | return a.values.length < b.values.length ? 1 : -1; 492 | }; 493 | 494 | // Group by 495 | this.group.by = function (groupFuncs) { 496 | const length = arguments.length; 497 | for (let j = 0; j < initialData.length; j++) { 498 | const dataObj = initialData[j]; 499 | const keys = []; 500 | for (let i = 0; i < length; i++) { 501 | const key = arguments[i]; 502 | keys.push(key(dataObj, j)); 503 | } 504 | const strKey = JSON.stringify(keys); 505 | if (!resultObj[strKey]) { 506 | resultObj[strKey] = []; 507 | } 508 | resultObj[strKey].push(dataObj); 509 | } 510 | operations.push("by"); 511 | return that.group; 512 | }; 513 | 514 | // Order by func 515 | this.group.orderBy = function (func) { 516 | sort = function (a, b) { 517 | var a = func(a); 518 | var b = func(b); 519 | if (typeof a === "string" || a instanceof String) { 520 | return a.localeCompare(b); 521 | } 522 | return a - b; 523 | }; 524 | operations.push("orderBy"); 525 | return that.group; 526 | }; 527 | 528 | // Order by descending func 529 | this.group.orderByDescending = function (func) { 530 | sort = function (a, b) { 531 | var a = func(a); 532 | var b = func(b); 533 | if (typeof a === "string" || a instanceof String) { 534 | return a.localeCompare(b); 535 | } 536 | return b - a; 537 | }; 538 | operations.push("orderByDescending"); 539 | return that.group; 540 | }; 541 | 542 | // Custom sort 543 | this.group.sort = function (v) { 544 | sort = v; 545 | operations.push("sort"); 546 | return that.group; 547 | }; 548 | 549 | // Run result 550 | this.group.run = function () { 551 | resultArr = Object.keys(resultObj).map((k) => { 552 | const result = {}; 553 | const keys = JSON.parse(k); 554 | if (keys.length == 1) { 555 | result.key = keys[0]; 556 | } else { 557 | result.keys = keys; 558 | } 559 | result.values = resultObj[k]; 560 | return result; 561 | }); 562 | 563 | if (sort) { 564 | resultArr.sort(sort); 565 | } 566 | return resultArr; 567 | }; 568 | 569 | return this.group; 570 | }; 571 | } 572 | 573 | 574 | typeof module!='undefined' && (module.exports = RangeSlider); 575 | 576 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-driven-range-slider", 3 | "version": "1.0.1", 4 | "description": "d3.js based data driven range slider", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/bumbeishvili/data-driven-range-slider.git" 12 | }, 13 | "keywords": [ 14 | "d3", 15 | "range-slider", 16 | "time-range-slider", 17 | "data-driven-slider", 18 | "react-range-slider", 19 | "angular-range-slider", 20 | "vue-range-slider" 21 | ], 22 | "dependencies": { 23 | "d3": "*" 24 | }, 25 | "author": "David B.", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/bumbeishvili/data-driven-range-slider/issues" 29 | }, 30 | "homepage": "https://github.com/bumbeishvili/data-driven-range-slider#readme" 31 | } 32 | --------------------------------------------------------------------------------