├── 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 | [](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 | [](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 |
--------------------------------------------------------------------------------