├── .babelrc.json ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── nodejs.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .storybook └── main.js ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example └── index.html ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── d3-compat.js ├── index.js └── slider.js ├── stories ├── basic-functionality.stories.js ├── demos │ ├── color-picker.stories.js │ ├── music-player-controls.css │ ├── music-player-controls.stories.js │ └── new-york-times.stories.js ├── extended-functionality.stories.js └── styling.stories.mdx └── test ├── .eslintrc.json ├── slider-horizontal.html ├── slider-test.js └── slider-vertical.html /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100 9 | } 10 | } 11 | ] 12 | ], 13 | "plugins": [] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaVersion": 11 6 | }, 7 | "env": { 8 | "es6": true 9 | }, 10 | "rules": { 11 | "no-cond-assign": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [master] 9 | schedule: 10 | - cron: '0 18 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v1 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v1 67 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [18.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install, build, and test 20 | run: | 21 | npm ci 22 | npm run build --if-present 23 | npm test 24 | env: 25 | CI: true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules 4 | npm-debug.log 5 | storybook-static 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist/*.zip 2 | test/ 3 | .storybook/ 4 | stories/ 5 | storybook-static/ 6 | example/ 7 | .travis.yml 8 | .prettierrc 9 | .eslintrc 10 | rollup.config.js 11 | .prettierignore 12 | .eslintrc.yaml 13 | .github/workflows/nodejs.yml 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | test/slider-horizontal.html 2 | test/slider-vertical.html -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.@(js|mdx)'], 3 | addons: ['@storybook/addon-essentials', '@storybook/addon-mdx-gfm'], 4 | framework: { 5 | name: '@storybook/html-webpack5', 6 | options: {}, 7 | }, 8 | docs: { 9 | autodocs: true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | The production source code does not make use of most modern syntax and runtime features. The exception is importing and exporting modules. This reflects the approach used by official d3 packages at the time this project was started. 2 | 3 | With newer versions of d3 modules now starting to use newer features, e.g. [d3-array](https://github.com/d3/d3-array) we may introduce new language features but it would be a breaking change and require a new major version. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017-2021 John Walley 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # d3-simple-slider 2 | 3 | [![npm](https://img.shields.io/npm/v/d3-simple-slider.svg)](https://npmjs.com/package/d3-simple-slider) 4 | [![Build status](https://github.com/johnwalley/d3-simple-slider/actions/workflows/nodejs.yml/badge.svg)](https://github.com/johnwalley/d3-simple-slider/actions/workflows/nodejs.yml) 5 | 6 | Render a simple interactive slider using SVG. 7 | 8 | ![d3-simple-slider](https://user-images.githubusercontent.com/981531/32612807-1b4bc7d0-c561-11e7-95cf-1af7c10788d2.gif) 9 | 10 | Examples: 11 | 12 | - [Storybook](https://d3-simple-slider.mulberryhousesoftware.com) 13 | - [Bl.ocks.org](https://bl.ocks.org/johnwalley/e1d256b81e51da68f7feb632a53c3518) 14 | 15 | Inspired by The New York Times [Is It Better to Rent or Buy?](https://www.nytimes.com/interactive/2014/upshot/buy-rent-calculator.html) 16 | 17 | ## Why use d3-simple-slider? 18 | 19 | If you need to include a slider within an svg element this is for you. For example, use a slider in place of an axis in a chart. 20 | 21 | If you don't need to work inside an svg element then I would consider using one of the many excellent html-based components which may be better suited to your needs. Of course you might just love using d3! 22 | 23 | ## Installing 24 | 25 | There are three ways to use this library. 26 | 27 | ### Include the file directly 28 | 29 | You must include the [d3 library](http://d3js.org/) before including the slider file. Then you can add the [compiled js file](https://github.com/johnwalley/d3-simple-slider/releases/latest/download/d3-simple-slider.zip) to your website. 30 | 31 | The d3-simple-slider functionality is added to the `d3` global object. 32 | 33 | ```html 34 | 35 | 36 | 37 |

38 |
39 | 40 | 60 | ``` 61 | 62 | ### Using a CDN 63 | 64 | You can add the latest version of [d3-simple-slider hosted on unpkg](https://unpkg.com/d3-simple-slider). 65 | 66 | The d3-simple-slider functionality is added to the `d3` global object. 67 | 68 | ```html 69 | 70 | 71 | 72 |

73 |
74 | 75 | 95 | ``` 96 | 97 | ### Using npm or yarn 98 | 99 | You can add d3-simple-slider as a node module by running 100 | 101 | ```node 102 | npm install d3-simple-slider 103 | ``` 104 | 105 | or 106 | 107 | ```node 108 | yarn add d3-simple-slider 109 | ``` 110 | 111 | You can then import any of the exported functions: `sliderHorizontal`, `sliderVertical`, `sliderTop`, `sliderRight`, `sliderBottom`, `sliderLeft`. 112 | 113 | ```js 114 | import * as d3 from 'd3'; 115 | import { sliderBottom } from 'd3-simple-slider'; 116 | 117 | const div = document.createElement('div'); 118 | const slider = sliderBottom().min(0).max(10).step(1).width(300); 119 | 120 | const g = d3 121 | .select(div) 122 | .append('svg') 123 | .attr('width', 500) 124 | .attr('height', 100) 125 | .append('g') 126 | .attr('transform', 'translate(30,30)'); 127 | 128 | g.call(slider); 129 | ``` 130 | 131 | ## Styling 132 | 133 | To change the font size: 134 | 135 | ```css 136 | .axis text, 137 | .slider text { 138 | font-size: 18px; 139 | } 140 | ``` 141 | 142 | To change the tick text color: 143 | 144 | ```css 145 | .axis text { 146 | fill: red; 147 | } 148 | ``` 149 | 150 | To change the parameter value text color: 151 | 152 | ```css 153 | .slider text { 154 | fill: green; 155 | } 156 | ``` 157 | 158 | ## API Reference 159 | 160 | Regardless of orientation, sliders are always rendered at the origin. To change the position of the slider specify a [transform attribute](http://www.w3.org/TR/SVG/coords.html#TransformAttribute) on the containing element. For example: 161 | 162 | ```js 163 | d3.select('body') 164 | .append('svg') 165 | .attr('width', 1440) 166 | .attr('height', 30) 167 | .append('g') 168 | .attr('transform', 'translate(0,30)') 169 | .call(slider); 170 | ``` 171 | 172 | The orientation of a slider is fixed; to change the orientation, remove the old slider and create a new slider. 173 | 174 | All sliders may take a [scale](https://github.com/d3/d3-scale) as an argument. If _scale_ is specified, the slider will use the scale to render the slider. This must be either [scaleLinear](https://github.com/d3/d3-scale#scaleLinear) or [scaleTime](https://github.com/d3/d3-scale#scaleTime). The domain will be used to calculate minimum and maximum values. The range will be used to calculate the width or height of the slider. This means you do not need to set these if passing a scale. 175 | 176 | # d3.sliderHorizontal([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L859 'Source') 177 | 178 | Constructs a new horizontal slider generator. _Note that this is equivalent to [`sliderBottom`](#sliderBottom)._ 179 | 180 | # d3.sliderVertical([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L863 'Source') 181 | 182 | Constructs a new vertical slider generator. _Note that this is equivalent to [`sliderLeft`](#sliderLeft)._ 183 | 184 | # d3.sliderTop([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L867 'Source') 185 | 186 | Constructs a new horizontal slider generator. Ticks on top. 187 | 188 | # d3.sliderRight([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L839 'Source') 189 | 190 | Constructs a new vertical slider generator. Ticks to the right; 191 | 192 | # d3.sliderBottom([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L871 'Source') 193 | 194 | Constructs a new horizontal slider generator. Ticks on the bottom. 195 | 196 | # d3.sliderLeft([scale]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L879 'Source') 197 | 198 | Constructs a new vertical slider generator. Ticks to the left; 199 | 200 | # slider(context) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L30 'Source') 201 | 202 | Render the slider to the given _context_, which may be either a [selection](https://github.com/d3/d3-selection) of SVG containers (either SVG or G elements) or a corresponding [transition](https://github.com/d3/d3-transition). 203 | 204 | # slider.ticks([count]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L729 'Source') 205 | 206 | To generate twenty ticks: 207 | 208 | ```js 209 | slider.ticks(20); 210 | ``` 211 | 212 | # slider.tickValues([values]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L815 'Source') 213 | 214 | If a values array is specified, the specified values are used for ticks rather than using the sliders' automatic tick generator. If values is null, clears any previously-set explicit tick values and reverts back to the sliders' tick generator. If values is not specified, returns the current tick values, which defaults to null. For example, to generate ticks at specific values: 215 | 216 | ```js 217 | slider.tickValues([1, 2, 3, 5, 8, 13, 21]); 218 | ``` 219 | 220 | # slider.tickPadding([padding]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L821 'Source') 221 | 222 | If _padding_ is specified, sets the padding to the specified value in pixels and returns the axis. If _padding_ is not specified, returns the current padding which defaults to 3 pixels. 223 | 224 | # slider.tickFormat([format]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L717 'Source') 225 | 226 | If _format_ is specified, sets the tick format function and returns the slider. If _format_ is not specified, returns the current format function, which defaults to null. A null format indicates that the slider's default formatter should be used. 227 | 228 | See [d3-format](https://github.com/d3/d3-format) and [d3-time-format](https://github.com/d3/d3-time-format) for help creating formatters. For example, to display integers with comma-grouping for thousands: 229 | 230 | ```js 231 | slider.tickFormat(d3.format(',.0f')); 232 | ``` 233 | 234 | # slider.displayFormat([format]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L723 'Source') 235 | 236 | If _format_ is specified, sets the function used to format the highlighted value and returns the slider. If _format_ is not specified, returns the current format function, which defaults to null. A null format indicates that the tickFormat should be used. If tickFormat is null then the slider's default formatter should be used. 237 | 238 | See [d3-format](https://github.com/d3/d3-format) and [d3-time-format](https://github.com/d3/d3-time-format) for help creating formatters. For example, to display integers with comma-grouping for thousands: 239 | 240 | ```js 241 | slider.displayFormat(d3.format(',.0f')); 242 | ``` 243 | 244 | # slider.value([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L735 'Source') 245 | 246 | If _value_ is specified, sets the value of the slider to the specified value and returns the slider. If _value_ is not specified, returns the current value. 247 | 248 | If _value_ is an array of length two then the values represent a range. 249 | 250 | # slider.silentValue([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L762 'Source') 251 | 252 | If _value_ is specified, sets the value of the slider to the specified value and returns the slider _without_ invoking any listeners. If _value_ is not specified, returns the current value. 253 | 254 | If _value_ is an array of length two then the values represent a range. 255 | 256 | # slider.displayValue([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L839 'Source') 257 | 258 | If _value_ is specified, sets the whether the highlighted value of the slider should be shown and returns the slider. If _value_ is not specified, returns the current value, which defaults to true. 259 | 260 | # slider.handle([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L833 'Source') 261 | 262 | If _value_ is specified, sets the [SVG path definition](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d) used to render the slider handle and returns the slider. If _value_ is not specified, returns the current value, which defaults to 'M-5.5,-5.5v10l6,5.5l6,-5.5v-10z'. 263 | 264 | # slider.width([size]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L695 'Source') 265 | 266 | If _size_ is specified, sets the width of the slider to the specified value and returns the slider. If _size_ is not specified, returns the current width, which defaults to 100. This property only affects horizontal sliders and is ignored otherwise. 267 | 268 | # slider.height([size]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L706 'Source') 269 | 270 | If _size_ is specified, sets the height of the slider to the specified value and returns the slider. If _size_ is not specified, returns the current height, which defaults to 100. This property only affects vertical sliders and is ignored otherwise. 271 | 272 | # slider.min([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L662 'Source') 273 | 274 | If _value_ is specified, sets the minimum value of the slider to the specified value and returns the slider. If _value_ is not specified, returns the current minimum value, which defaults to 0. 275 | 276 | # slider.max([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L673 'Source') 277 | 278 | If _value_ is specified, sets the maximum value of the slider to the specified value and returns the slider. If _value_ is not specified, returns the current maximum value, which defaults to 10. 279 | 280 | # slider.domain([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L684 'Source') 281 | 282 | If _value_ is specified, an array which sets the minimum and maximum values of the slider and returns the slider. If _value_ is not specified, returns the current maximum value, which defaults to [0, 10]. 283 | 284 | # slider.fill([color]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L845 'Source') 285 | 286 | If _color_ is specified, sets the color of the slider track-fill and returns the slider. If _color_ is not specified, returns the current value, which defaults to null. 287 | 288 | # slider.step([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L809 'Source') 289 | 290 | If _value_ is specified, sets the increment which the slider will move in and returns the slider. If _value_ is not specified, returns the current value, which defaults to null. 291 | 292 | # slider.marks([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L827 'Source') 293 | 294 | If _value_ is specified, sets the values to which the slider will snap to and returns the slider. If _value_ is not specified, returns the current value, which defaults to null. 295 | 296 | # slider.default([value]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L789 'Source') 297 | 298 | If _value_ is specified, sets the initial value of the slider and returns the slider. If _value_ is not specified, returns the current value, which defaults to null. 299 | 300 | # slider.on(typenames, [listener]) [<>](https://github.com/johnwalley/d3-simple-slider/blob/main/src/slider.js#L851 'Source') 301 | 302 | If _listener_ is specified, sets the event _listener_ for the specified _typenames_ and returns the slider. If an event listener was already registered for the same type and name, the existing listener is removed before the new listener is added. If _listener_ is null, removes the current event listeners for the specified _typenames_, if any. If _listener_ is not specified, returns the first currently-assigned listener matching the specified _typenames_, if any. When a specified event is dispatched, each _listener_ will be invoked with the same context and arguments as [_selection_.on](https://github.com/d3/d3-selection#selection_on) listeners: the current datum `d` and index `i`, with the `this` context as the current DOM element. 303 | 304 | The _typenames_ is a string containing one or more _typename_ separated by whitespace. Each _typename_ is a _type_, optionally followed by a period (`.`) and a _name_, such as `drag.foo` and `drag.bar`; the name allows multiple listeners to be registered for the same _type_. The _type_ must be one of the following: 305 | 306 | - `onchange` - after the slider value has changed. 307 | - `start` - after a new pointer becomes active (on mousedown or touchstart). 308 | - `drag` - after an active pointer moves (on mousemove or touchmove). 309 | - `end` - after an active pointer becomes inactive (on mouseup, touchend or touchcancel). 310 | 311 | You might consider throttling `onchange` and `drag` events. For example using [`lodash.throttle`](https://lodash.com/docs/4.17.4#throttle). 312 | 313 | See [_dispatch_.on](https://github.com/d3/d3-dispatch#dispatch_on) for more. 314 | 315 | ## 🤝 How to Contribute 316 | 317 | Please read the [contribution guidelines for this project](CONTRIBUTING.md) 318 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | d3-simple-slider 7 | 8 | 9 | 10 | 11 | 17 | 18 |
19 |

Basic functionality

20 |

Simple

21 |
22 |

23 |
24 |
25 |

Step

26 |
27 |

28 |
29 |
30 |

Time

31 |
32 |

33 |
34 |
35 |

Fill

36 |
37 |

38 |
39 |
40 |

Range

41 |
42 |

43 |
44 |
45 |

Vertical

46 |
47 |

48 |
49 |
50 |

Extended functionality

51 |

Alternative handle

52 |
53 |

54 |
55 |
56 |

Transition

57 |
58 |

59 |
60 |
61 |

Examples

62 |

New York Times

63 |
64 |

65 |
66 |
67 |

Color picker

68 |
69 |

70 |
71 |
72 |
73 | 74 | 441 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "d3-simple-slider", 3 | "version": "2.0.0", 4 | "description": "Renders an SVG slider", 5 | "keywords": [ 6 | "d3", 7 | "d3-module", 8 | "slider", 9 | "visualization" 10 | ], 11 | "homepage": "https://github.com/johnwalley/d3-simple-slider", 12 | "license": "BSD-3-Clause", 13 | "author": { 14 | "name": "John Walley", 15 | "url": "http://www.walley.org.uk" 16 | }, 17 | "type": "module", 18 | "main": "dist/d3-simple-slider.js", 19 | "unpkg": "dist/d3-simple-slider.min.js", 20 | "jsdelivr": "dist/d3-simple-slider.min.js", 21 | "module": "src/index.js", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/johnwalley/d3-simple-slider.git" 25 | }, 26 | "files": [ 27 | "dist/**/*.js", 28 | "src/**/*.js" 29 | ], 30 | "scripts": { 31 | "test": "mocha 'test/**/*-test.js' && eslint src test", 32 | "prepublishOnly": "rm -rf dist && npm test && rollup -c", 33 | "postpublish": "zip -j dist/${npm_package_name}.zip -- LICENSE README.md dist/${npm_package_name}.js dist/${npm_package_name}.min.js", 34 | "storybook": "storybook dev -p 6006", 35 | "build-storybook": "storybook build", 36 | "format:fix": "pretty-quick --staged", 37 | "format:fixall": "pretty-quick" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "npm run-s format:fix" 42 | } 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "7.21.8", 46 | "@babel/preset-env": "7.21.5", 47 | "@storybook/addon-essentials": "7.0.11", 48 | "@storybook/addon-mdx-gfm": "^7.0.11", 49 | "@storybook/html": "7.0.11", 50 | "@storybook/html-webpack5": "7.0.11", 51 | "babel-loader": "9.1.2", 52 | "d3-format": "3.1.0", 53 | "d3-shape": "3.2.0", 54 | "d3-time-format": "4.1.0", 55 | "eslint": "8.40.0", 56 | "eslint-plugin-storybook": "0.6.12", 57 | "husky": "4.3.8", 58 | "jsdom": "16.7.0", 59 | "mocha": "10.2.0", 60 | "prettier": "2.8.8", 61 | "pretty-quick": "3.1.3", 62 | "react": "^18.2.0", 63 | "react-dom": "^18.2.0", 64 | "rollup": "2.56.3", 65 | "rollup-plugin-terser": "7.0.2", 66 | "storybook": "7.0.11" 67 | }, 68 | "dependencies": { 69 | "d3-array": "3", 70 | "d3-axis": "3", 71 | "d3-dispatch": "3", 72 | "d3-drag": "3", 73 | "d3-ease": "3", 74 | "d3-scale": "4", 75 | "d3-selection": "3", 76 | "d3-transition": "3" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import * as meta from './package.json'; 2 | 3 | import { terser } from 'rollup-plugin-terser'; 4 | 5 | const config = { 6 | input: 'src/index.js', 7 | external: Object.keys(meta.dependencies || {}).filter((key) => 8 | /^d3-/.test(key) 9 | ), 10 | output: { 11 | file: `dist/${meta.name}.js`, 12 | name: 'd3', 13 | format: 'umd', 14 | indent: false, 15 | extend: true, 16 | banner: `// ${meta.homepage} v${ 17 | meta.version 18 | } Copyright ${new Date().getFullYear()} ${meta.author.name}`, 19 | globals: Object.assign( 20 | {}, 21 | ...Object.keys(meta.dependencies || {}) 22 | .filter((key) => /^d3-/.test(key)) 23 | .map((key) => ({ [key]: 'd3' })) 24 | ), 25 | }, 26 | plugins: [], 27 | }; 28 | 29 | export default [ 30 | config, 31 | { 32 | ...config, 33 | output: { 34 | ...config.output, 35 | file: `dist/${meta.name}.min.js`, 36 | }, 37 | plugins: [ 38 | ...config.plugins, 39 | terser({ 40 | output: { 41 | preamble: config.output.banner, 42 | }, 43 | }), 44 | ], 45 | }, 46 | ]; 47 | -------------------------------------------------------------------------------- /src/d3-compat.js: -------------------------------------------------------------------------------- 1 | import * as selection from 'd3-selection'; 2 | 3 | var prop = 'event'; 4 | 5 | export function adaptListener(listener) { 6 | var isv6 = !(prop in selection); 7 | 8 | return function (a, b) { 9 | if (isv6) { 10 | // d3@v6 11 | listener.call(this, a, b); 12 | } else { 13 | // d3@v5 14 | listener.call(this, selection[prop], a); 15 | } 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | sliderHorizontal, 3 | sliderVertical, 4 | sliderTop, 5 | sliderRight, 6 | sliderBottom, 7 | sliderLeft, 8 | } from './slider.js'; 9 | -------------------------------------------------------------------------------- /src/slider.js: -------------------------------------------------------------------------------- 1 | import 'd3-transition'; 2 | 3 | import { axisBottom, axisLeft, axisRight, axisTop } from 'd3-axis'; 4 | import { max, min, scan } from 'd3-array'; 5 | import { scaleLinear, scaleTime } from 'd3-scale'; 6 | 7 | import { adaptListener } from './d3-compat.js'; 8 | import { dispatch } from 'd3-dispatch'; 9 | import { drag } from 'd3-drag'; 10 | import { easeQuadOut } from 'd3-ease'; 11 | import { select } from 'd3-selection'; 12 | 13 | var UPDATE_DURATION = 200; 14 | var SLIDER_END_PADDING = 8; 15 | var KEYBOARD_NUMBER_STEPS = 100; 16 | 17 | var top = 1; 18 | var right = 2; 19 | var bottom = 3; 20 | var left = 4; 21 | 22 | function translateX(x) { 23 | return 'translate(' + x + ',0)'; 24 | } 25 | 26 | function translateY(y) { 27 | return 'translate(0,' + y + ')'; 28 | } 29 | 30 | function slider(orientation, scale) { 31 | scale = typeof scale !== 'undefined' ? scale.copy() : null; 32 | 33 | var value = [0]; 34 | var defaultValue = [0]; 35 | var domain = [0, 10]; 36 | var width = 100; 37 | var height = 100; 38 | var displayValue = true; 39 | var handle = 'M-5.5,-5.5v10l6,5.5l6,-5.5v-10z'; 40 | var step = null; 41 | var tickValues = null; 42 | var tickPadding = 3; 43 | var marks = null; 44 | var tickFormat = null; 45 | var ticks = null; 46 | var displayFormat = null; 47 | var fill = null; 48 | 49 | var listeners = dispatch('onchange', 'start', 'end', 'drag'); 50 | 51 | var selection = null; 52 | var identityClamped = null; 53 | var handleIndex = null; 54 | 55 | var k = orientation === top || orientation === left ? -1 : 1; 56 | var j = orientation === left || orientation === right ? -1 : 1; 57 | var x = orientation === left || orientation === right ? 'y' : 'x'; 58 | var y = orientation === left || orientation === right ? 'x' : 'y'; 59 | 60 | var transformAlong = 61 | orientation === top || orientation === bottom ? translateX : translateY; 62 | 63 | var transformAcross = 64 | orientation === top || orientation === bottom ? translateY : translateX; 65 | 66 | var axisFunction = null; 67 | 68 | switch (orientation) { 69 | case top: 70 | axisFunction = axisTop; 71 | break; 72 | case right: 73 | axisFunction = axisRight; 74 | break; 75 | case bottom: 76 | axisFunction = axisBottom; 77 | break; 78 | case left: 79 | axisFunction = axisLeft; 80 | break; 81 | } 82 | 83 | var handleSelection = null; 84 | var fillSelection = null; 85 | var textSelection = null; 86 | 87 | if (scale) { 88 | domain = [min(scale.domain()), max(scale.domain())]; 89 | 90 | if (orientation === top || orientation === bottom) { 91 | width = max(scale.range()) - min(scale.range()); 92 | } else { 93 | height = max(scale.range()) - min(scale.range()); 94 | } 95 | 96 | scale = scale.clamp(true); 97 | } 98 | 99 | function slider(context) { 100 | selection = context.selection ? context.selection() : context; 101 | 102 | if (!scale) { 103 | scale = domain[0] instanceof Date ? scaleTime() : scaleLinear(); 104 | 105 | scale = scale 106 | .domain(domain) 107 | .range( 108 | orientation === top || orientation === bottom 109 | ? [0, width] 110 | : [height, 0] 111 | ) 112 | .clamp(true); 113 | } 114 | 115 | identityClamped = scaleLinear() 116 | .range(scale.range()) 117 | .domain(scale.range()) 118 | .clamp(true); 119 | 120 | // Ensure value is valid 121 | value = value.map(function (d) { 122 | return scaleLinear().range(domain).domain(domain).clamp(true)(d); 123 | }); 124 | 125 | tickFormat = tickFormat || scale.tickFormat(); 126 | displayFormat = displayFormat || tickFormat || scale.tickFormat(); 127 | 128 | var axis = selection.selectAll('.axis').data([null]); 129 | 130 | axis 131 | .enter() 132 | .append('g') 133 | .attr('transform', transformAcross(k * 7)) 134 | .attr('class', 'axis'); 135 | 136 | var sliderSelection = selection.selectAll('.slider').data([null]); 137 | 138 | var sliderEnter = sliderSelection 139 | .enter() 140 | .append('g') 141 | .attr('class', 'slider') 142 | .attr( 143 | 'cursor', 144 | orientation === top || orientation === bottom 145 | ? 'ew-resize' 146 | : 'ns-resize' 147 | ) 148 | .call( 149 | drag() 150 | .on('start', adaptListener(dragstarted)) 151 | .on('drag', adaptListener(dragged)) 152 | .on('end', adaptListener(dragended)) 153 | ); 154 | 155 | sliderEnter 156 | .append('line') 157 | .attr('class', 'track') 158 | .attr(x + '1', scale.range()[0] - j * SLIDER_END_PADDING) 159 | .attr('stroke', '#bbb') 160 | .attr('stroke-width', 6) 161 | .attr('stroke-linecap', 'round'); 162 | 163 | sliderEnter 164 | .append('line') 165 | .attr('class', 'track-inset') 166 | .attr(x + '1', scale.range()[0] - j * SLIDER_END_PADDING) 167 | .attr('stroke', '#eee') 168 | .attr('stroke-width', 4) 169 | .attr('stroke-linecap', 'round'); 170 | 171 | if (fill) { 172 | sliderEnter 173 | .append('line') 174 | .attr('class', 'track-fill') 175 | .attr( 176 | x + '1', 177 | value.length === 1 178 | ? scale.range()[0] - j * SLIDER_END_PADDING 179 | : scale(value[0]) 180 | ) 181 | .attr('stroke', fill) 182 | .attr('stroke-width', 4) 183 | .attr('stroke-linecap', 'round'); 184 | } 185 | 186 | sliderEnter 187 | .append('line') 188 | .attr('class', 'track-overlay') 189 | .attr(x + '1', scale.range()[0] - j * SLIDER_END_PADDING) 190 | .attr('stroke', 'transparent') 191 | .attr('stroke-width', 40) 192 | .attr('stroke-linecap', 'round') 193 | .merge(sliderSelection.select('.track-overlay')); 194 | 195 | handleSelection = sliderEnter.selectAll('.parameter-value').data( 196 | value.map(function (d, i) { 197 | return { value: d, index: i }; 198 | }) 199 | ); 200 | 201 | var handleEnter = handleSelection 202 | .enter() 203 | .append('g') 204 | .attr('class', 'parameter-value') 205 | .attr('transform', function (d) { 206 | return transformAlong(scale(d.value)); 207 | }) 208 | .attr('font-family', 'sans-serif') 209 | .attr( 210 | 'text-anchor', 211 | orientation === right 212 | ? 'start' 213 | : orientation === left 214 | ? 'end' 215 | : 'middle' 216 | ); 217 | 218 | handleEnter 219 | .append('path') 220 | .attr('transform', 'rotate(' + (orientation + 1) * 90 + ')') 221 | .attr('d', handle) 222 | .attr('class', 'handle') 223 | .attr('aria-label', 'handle') 224 | .attr('aria-valuemax', domain[1]) 225 | .attr('aria-valuemin', domain[0]) 226 | .attr('aria-valuenow', function (d) { 227 | return d.value; 228 | }) 229 | .attr( 230 | 'aria-orientation', 231 | orientation === left || orientation === right 232 | ? 'vertical' 233 | : 'horizontal' 234 | ) 235 | .attr('focusable', 'true') 236 | .attr('tabindex', 0) 237 | .attr('fill', 'white') 238 | .attr('stroke', '#777') 239 | .on( 240 | 'keydown', 241 | adaptListener(function (event, datum) { 242 | var change = step || (domain[1] - domain[0]) / KEYBOARD_NUMBER_STEPS; 243 | 244 | var index = marks 245 | ? scan( 246 | marks.map(function (d) { 247 | return Math.abs(value[datum.index] - d); 248 | }) 249 | ) 250 | : null; 251 | 252 | // TODO: Don't need to loop over value because we know which element needs to change 253 | function newValue(adjustedValue) { 254 | return value.map(function (d, j) { 255 | if (value.length === 2) { 256 | return j === datum.index 257 | ? datum.index === 0 258 | ? Math.min(adjustedValue, alignedValue(value[1])) 259 | : Math.max(adjustedValue, alignedValue(value[0])) 260 | : d; 261 | } else { 262 | return j === datum.index ? adjustedValue : d; 263 | } 264 | }); 265 | } 266 | 267 | switch (event.key) { 268 | case 'ArrowLeft': 269 | case 'ArrowDown': 270 | if (marks) { 271 | slider.value(newValue(marks[Math.max(0, index - 1)])); 272 | } else { 273 | slider.value(newValue(+value[datum.index] - change)); 274 | } 275 | 276 | event.preventDefault(); 277 | break; 278 | case 'PageDown': 279 | if (marks) { 280 | slider.value(newValue(marks[Math.max(0, index - 2)])); 281 | } else { 282 | slider.value(newValue(+value[datum.index] - 2 * change)); 283 | } 284 | 285 | event.preventDefault(); 286 | break; 287 | case 'ArrowRight': 288 | case 'ArrowUp': 289 | if (marks) { 290 | slider.value( 291 | newValue(marks[Math.min(marks.length - 1, index + 1)]) 292 | ); 293 | } else { 294 | slider.value(newValue(+value[datum.index] + change)); 295 | } 296 | 297 | event.preventDefault(); 298 | break; 299 | case 'PageUp': 300 | if (marks) { 301 | slider.value( 302 | newValue(marks[Math.min(marks.length - 1, index + 2)]) 303 | ); 304 | } else { 305 | slider.value(newValue(+value[datum.index] + 2 * change)); 306 | } 307 | 308 | event.preventDefault(); 309 | break; 310 | case 'Home': 311 | slider.value(newValue(domain[0])); 312 | event.preventDefault(); 313 | break; 314 | case 'End': 315 | slider.value(newValue(domain[1])); 316 | event.preventDefault(); 317 | break; 318 | } 319 | }) 320 | ); 321 | 322 | if (displayValue) { 323 | handleEnter 324 | .append('text') 325 | .attr('font-size', 10) // TODO: Remove coupling to font-size in d3-axis 326 | .attr(y, k * (24 + tickPadding)) 327 | .attr( 328 | 'dy', 329 | orientation === top 330 | ? '0em' 331 | : orientation === bottom 332 | ? '.71em' 333 | : '.32em' 334 | ) 335 | .attr('transform', value.length > 1 ? 'translate(0,0)' : null) 336 | .text(function (d, idx) { 337 | return displayFormat(value[idx]); 338 | }); 339 | } 340 | 341 | context 342 | .select('.track') 343 | .attr(x + '2', scale.range()[1] + j * SLIDER_END_PADDING); 344 | 345 | context 346 | .select('.track-inset') 347 | .attr(x + '2', scale.range()[1] + j * SLIDER_END_PADDING); 348 | 349 | if (fill) { 350 | context 351 | .select('.track-fill') 352 | .attr(x + '2', value.length === 1 ? scale(value[0]) : scale(value[1])); 353 | } 354 | 355 | context 356 | .select('.track-overlay') 357 | .attr(x + '2', scale.range()[1] + j * SLIDER_END_PADDING); 358 | 359 | context 360 | .select('.axis') 361 | .call( 362 | axisFunction(scale) 363 | .tickFormat(tickFormat) 364 | .ticks(ticks) 365 | .tickValues(tickValues) 366 | .tickPadding(tickPadding) 367 | ); 368 | 369 | // https://bl.ocks.org/mbostock/4323929 370 | selection.select('.axis').select('.domain').remove(); 371 | 372 | context.select('.axis').attr('transform', transformAcross(k * 7)); 373 | 374 | context 375 | .selectAll('.axis text') 376 | .attr('fill', '#aaa') 377 | .attr(y, k * (17 + tickPadding)) 378 | .attr( 379 | 'dy', 380 | orientation === top ? '0em' : orientation === bottom ? '.71em' : '.32em' 381 | ) 382 | .attr( 383 | 'text-anchor', 384 | orientation === right 385 | ? 'start' 386 | : orientation === left 387 | ? 'end' 388 | : 'middle' 389 | ); 390 | 391 | context.selectAll('.axis line').attr('stroke', '#aaa'); 392 | 393 | context.selectAll('.parameter-value').attr('transform', function (d) { 394 | return transformAlong(scale(d.value)); 395 | }); 396 | 397 | fadeTickText(); 398 | 399 | function computeDragNewValue(pos) { 400 | var adjustedValue = alignedValue(scale.invert(pos)); 401 | return value.map(function (d, i) { 402 | if (value.length === 2) { 403 | return i === handleIndex 404 | ? handleIndex === 0 405 | ? Math.min(adjustedValue, alignedValue(value[1])) 406 | : Math.max(adjustedValue, alignedValue(value[0])) 407 | : d; 408 | } else { 409 | return i === handleIndex ? adjustedValue : d; 410 | } 411 | }); 412 | } 413 | 414 | function dragstarted(event) { 415 | select(this).classed('active', true); 416 | 417 | var pos = identityClamped( 418 | orientation === bottom || orientation === top ? event.x : event.y 419 | ); 420 | 421 | // Handle cases where both handles are at the same end of the slider 422 | if (value[0] === domain[0] && value[1] === domain[0]) { 423 | handleIndex = 1; 424 | } else if (value[0] === domain[1] && value[1] === domain[1]) { 425 | handleIndex = 0; 426 | } else { 427 | handleIndex = scan( 428 | value.map(function (d) { 429 | return Math.abs(d - alignedValue(scale.invert(pos))); 430 | }) 431 | ); 432 | } 433 | 434 | var newValue = value.map(function (d, i) { 435 | return i === handleIndex ? alignedValue(scale.invert(pos)) : d; 436 | }); 437 | 438 | updateHandle(newValue); 439 | listeners.call( 440 | 'start', 441 | sliderSelection, 442 | newValue.length === 1 ? newValue[0] : newValue 443 | ); 444 | updateValue(newValue, true); 445 | } 446 | 447 | function dragged(event) { 448 | var pos = identityClamped( 449 | orientation === bottom || orientation === top ? event.x : event.y 450 | ); 451 | var newValue = computeDragNewValue(pos); 452 | 453 | updateHandle(newValue); 454 | listeners.call( 455 | 'drag', 456 | sliderSelection, 457 | newValue.length === 1 ? newValue[0] : newValue 458 | ); 459 | updateValue(newValue, true); 460 | } 461 | 462 | function dragended(event) { 463 | select(this).classed('active', false); 464 | 465 | var pos = identityClamped( 466 | orientation === bottom || orientation === top ? event.x : event.y 467 | ); 468 | var newValue = computeDragNewValue(pos); 469 | 470 | updateHandle(newValue); 471 | listeners.call( 472 | 'end', 473 | sliderSelection, 474 | newValue.length === 1 ? newValue[0] : newValue 475 | ); 476 | updateValue(newValue, true); 477 | 478 | handleIndex = null; 479 | } 480 | 481 | textSelection = selection.selectAll('.parameter-value text'); 482 | fillSelection = selection.select('.track-fill'); 483 | } 484 | 485 | function fadeTickText() { 486 | if (selection) { 487 | if (displayValue) { 488 | var indices = []; 489 | value.forEach(function (val) { 490 | var distances = []; 491 | 492 | selection.selectAll('.axis .tick').each(function (d) { 493 | distances.push(Math.abs(d - val)); 494 | }); 495 | 496 | indices.push(scan(distances)); 497 | }); 498 | 499 | selection 500 | .selectAll('.axis .tick text') 501 | .attr('opacity', function (d, i) { 502 | return ~indices.indexOf(i) ? 0 : 1; 503 | }); 504 | 505 | if (textSelection && value.length > 1) { 506 | var rect; 507 | var shift; 508 | var pos = []; 509 | var size = []; 510 | 511 | textSelection.nodes().forEach(function (d, i) { 512 | rect = d.getBoundingClientRect(); 513 | 514 | shift = d.getAttribute('transform').split(/[()]/)[1].split(',')[ 515 | x === 'x' ? 0 : 1 516 | ]; 517 | 518 | pos[i] = rect[x] - parseFloat(shift); 519 | size[i] = rect[x === 'x' ? 'width' : 'height']; 520 | }); 521 | 522 | if (x === 'x') { 523 | shift = Math.max(0, (pos[0] + size[0] - pos[1]) / 2); 524 | 525 | textSelection.attr('transform', function (d, i) { 526 | return 'translate(' + (i === 1 ? shift : -shift) + ',0)'; 527 | }); 528 | } else { 529 | shift = Math.max(0, (pos[1] + size[1] - pos[0]) / 2); 530 | 531 | textSelection.attr('transform', function (d, i) { 532 | return 'translate(0,' + (i === 1 ? -shift : shift) + ')'; 533 | }); 534 | } 535 | } 536 | } 537 | } 538 | } 539 | 540 | function alignedValue(newValue) { 541 | if (marks) { 542 | var index = scan( 543 | marks.map(function (d) { 544 | return Math.abs(newValue - d); 545 | }) 546 | ); 547 | 548 | return marks[index]; 549 | } 550 | 551 | if (step) { 552 | var valueModStep = (newValue - domain[0]) % step; 553 | var alignValue = newValue - valueModStep; 554 | 555 | if (valueModStep * 2 > step) { 556 | alignValue += step; 557 | } 558 | 559 | return newValue instanceof Date ? new Date(alignValue) : alignValue; 560 | } 561 | 562 | return newValue; 563 | } 564 | 565 | function updateValue(newValue, notifyListener) { 566 | if ( 567 | value[0] !== newValue[0] || 568 | (value.length > 1 && value[1] !== newValue[1]) 569 | ) { 570 | value = newValue; 571 | 572 | if (notifyListener) { 573 | listeners.call( 574 | 'onchange', 575 | slider, 576 | newValue.length === 1 ? newValue[0] : newValue 577 | ); 578 | } 579 | 580 | fadeTickText(); 581 | } 582 | } 583 | 584 | function updateHandle(newValue, animate) { 585 | if (selection) { 586 | animate = typeof animate !== 'undefined' ? animate : false; 587 | 588 | if (animate) { 589 | selection 590 | .selectAll('.parameter-value') 591 | .data( 592 | newValue.map(function (d, i) { 593 | return { value: d, index: i }; 594 | }) 595 | ) 596 | .transition() 597 | .ease(easeQuadOut) 598 | .duration(UPDATE_DURATION) 599 | .attr('transform', function (d) { 600 | return transformAlong(scale(d.value)); 601 | }) 602 | .select('.handle') 603 | .attr('aria-valuenow', function (d) { 604 | return d.value; 605 | }); 606 | 607 | if (fill) { 608 | fillSelection 609 | .transition() 610 | .ease(easeQuadOut) 611 | .duration(UPDATE_DURATION) 612 | .attr( 613 | x + '1', 614 | value.length === 1 615 | ? scale.range()[0] - k * SLIDER_END_PADDING 616 | : scale(newValue[0]) 617 | ) 618 | .attr( 619 | x + '2', 620 | value.length === 1 ? scale(newValue[0]) : scale(newValue[1]) 621 | ); 622 | } 623 | } else { 624 | selection 625 | .selectAll('.parameter-value') 626 | .data( 627 | newValue.map(function (d, i) { 628 | return { value: d, index: i }; 629 | }) 630 | ) 631 | .attr('transform', function (d) { 632 | return transformAlong(scale(d.value)); 633 | }) 634 | .select('.handle') 635 | .attr('aria-valuenow', function (d) { 636 | return d.value; 637 | }); 638 | 639 | if (fill) { 640 | fillSelection 641 | .attr( 642 | x + '1', 643 | value.length === 1 644 | ? scale.range()[0] - k * SLIDER_END_PADDING 645 | : scale(newValue[0]) 646 | ) 647 | .attr( 648 | x + '2', 649 | value.length === 1 ? scale(newValue[0]) : scale(newValue[1]) 650 | ); 651 | } 652 | } 653 | 654 | if (displayValue) { 655 | textSelection.text(function (d, idx) { 656 | return displayFormat(newValue[idx]); 657 | }); 658 | } 659 | } 660 | } 661 | 662 | slider.min = function (_) { 663 | if (!arguments.length) return domain[0]; 664 | domain[0] = _; 665 | 666 | if (scale) { 667 | scale.domain(domain); 668 | } 669 | 670 | return slider; 671 | }; 672 | 673 | slider.max = function (_) { 674 | if (!arguments.length) return domain[1]; 675 | domain[1] = _; 676 | 677 | if (scale) { 678 | scale.domain(domain); 679 | } 680 | 681 | return slider; 682 | }; 683 | 684 | slider.domain = function (_) { 685 | if (!arguments.length) return domain; 686 | domain = _; 687 | 688 | if (scale) { 689 | scale.domain(domain); 690 | } 691 | 692 | return slider; 693 | }; 694 | 695 | slider.width = function (_) { 696 | if (!arguments.length) return width; 697 | width = _; 698 | 699 | if (scale) { 700 | scale.range([scale.range()[0], scale.range()[0] + width]); 701 | } 702 | 703 | return slider; 704 | }; 705 | 706 | slider.height = function (_) { 707 | if (!arguments.length) return height; 708 | height = _; 709 | 710 | if (scale) { 711 | scale.range([scale.range()[0], scale.range()[0] + height]); 712 | } 713 | 714 | return slider; 715 | }; 716 | 717 | slider.tickFormat = function (_) { 718 | if (!arguments.length) return tickFormat; 719 | tickFormat = _; 720 | return slider; 721 | }; 722 | 723 | slider.displayFormat = function (_) { 724 | if (!arguments.length) return displayFormat; 725 | displayFormat = _; 726 | return slider; 727 | }; 728 | 729 | slider.ticks = function (_) { 730 | if (!arguments.length) return ticks; 731 | ticks = _; 732 | return slider; 733 | }; 734 | 735 | slider.value = function (_) { 736 | if (!arguments.length) { 737 | if (value.length === 1) { 738 | return value[0]; 739 | } 740 | 741 | return value; 742 | } 743 | 744 | var toArray = Array.isArray(_) ? _ : [_]; 745 | toArray.sort(function (a, b) { 746 | return a - b; 747 | }); 748 | 749 | if (scale) { 750 | var pos = toArray.map(scale).map(identityClamped); 751 | var newValue = pos.map(scale.invert).map(alignedValue); 752 | 753 | updateHandle(newValue, true); 754 | updateValue(newValue, true); 755 | } else { 756 | value = toArray; 757 | } 758 | 759 | return slider; 760 | }; 761 | 762 | slider.silentValue = function (_) { 763 | if (!arguments.length) { 764 | if (value.length === 1) { 765 | return value[0]; 766 | } 767 | 768 | return value; 769 | } 770 | 771 | var toArray = Array.isArray(_) ? _ : [_]; 772 | toArray.sort(function (a, b) { 773 | return a - b; 774 | }); 775 | 776 | if (scale) { 777 | var pos = toArray.map(scale).map(identityClamped); 778 | var newValue = pos.map(scale.invert).map(alignedValue); 779 | 780 | updateHandle(newValue, false); 781 | updateValue(newValue, false); 782 | } else { 783 | value = toArray; 784 | } 785 | 786 | return slider; 787 | }; 788 | 789 | slider.default = function (_) { 790 | if (!arguments.length) { 791 | if (defaultValue.length === 1) { 792 | return defaultValue[0]; 793 | } 794 | 795 | return defaultValue; 796 | } 797 | 798 | var toArray = Array.isArray(_) ? _ : [_]; 799 | 800 | toArray.sort(function (a, b) { 801 | return a - b; 802 | }); 803 | 804 | defaultValue = toArray; 805 | value = toArray; 806 | return slider; 807 | }; 808 | 809 | slider.step = function (_) { 810 | if (!arguments.length) return step; 811 | step = _; 812 | return slider; 813 | }; 814 | 815 | slider.tickValues = function (_) { 816 | if (!arguments.length) return tickValues; 817 | tickValues = _; 818 | return slider; 819 | }; 820 | 821 | slider.tickPadding = function (_) { 822 | if (!arguments.length) return tickPadding; 823 | tickPadding = _; 824 | return slider; 825 | }; 826 | 827 | slider.marks = function (_) { 828 | if (!arguments.length) return marks; 829 | marks = _; 830 | return slider; 831 | }; 832 | 833 | slider.handle = function (_) { 834 | if (!arguments.length) return handle; 835 | handle = _; 836 | return slider; 837 | }; 838 | 839 | slider.displayValue = function (_) { 840 | if (!arguments.length) return displayValue; 841 | displayValue = _; 842 | return slider; 843 | }; 844 | 845 | slider.fill = function (_) { 846 | if (!arguments.length) return fill; 847 | fill = _; 848 | return slider; 849 | }; 850 | 851 | slider.on = function () { 852 | var value = listeners.on.apply(listeners, arguments); 853 | return value === listeners ? slider : value; 854 | }; 855 | 856 | return slider; 857 | } 858 | 859 | export function sliderHorizontal(scale) { 860 | return slider(bottom, scale); 861 | } 862 | 863 | export function sliderVertical(scale) { 864 | return slider(left, scale); 865 | } 866 | 867 | export function sliderTop(scale) { 868 | return slider(top, scale); 869 | } 870 | 871 | export function sliderRight(scale) { 872 | return slider(right, scale); 873 | } 874 | 875 | export function sliderBottom(scale) { 876 | return slider(bottom, scale); 877 | } 878 | 879 | export function sliderLeft(scale) { 880 | return slider(left, scale); 881 | } 882 | -------------------------------------------------------------------------------- /stories/basic-functionality.stories.js: -------------------------------------------------------------------------------- 1 | import { event, select } from 'd3-selection'; 2 | import { max, min, range } from 'd3-array'; 3 | import { 4 | sliderBottom, 5 | sliderLeft, 6 | sliderRight, 7 | sliderTop, 8 | } from '../src/slider'; 9 | 10 | /*eslint-env browser*/ 11 | 12 | import { format } from 'd3-format'; 13 | import { timeFormat } from 'd3-time-format'; 14 | 15 | export default { 16 | title: 'Functionality/Basic functionality', 17 | }; 18 | 19 | export const Simple = () => { 20 | const div = window.document.createElement('div'); 21 | 22 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 23 | 24 | const slider = sliderBottom() 25 | .min(min(data)) 26 | .max(max(data)) 27 | .width(300) 28 | .tickFormat(format('.2%')) 29 | .ticks(5) 30 | .default(0.015); 31 | 32 | const g = select(div) 33 | .append('svg') 34 | .attr('width', 500) 35 | .attr('height', 100) 36 | .append('g') 37 | .attr('transform', 'translate(30,30)'); 38 | 39 | g.call(slider); 40 | 41 | return div; 42 | }; 43 | 44 | export const SimpleTop = () => { 45 | const div = window.document.createElement('div'); 46 | 47 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 48 | 49 | const slider = sliderTop() 50 | .min(min(data)) 51 | .max(max(data)) 52 | .width(300) 53 | .tickFormat(format('.2%')) 54 | .ticks(5) 55 | .default(0.015); 56 | 57 | const g = select(div) 58 | .append('svg') 59 | .attr('width', 500) 60 | .attr('height', 100) 61 | .append('g') 62 | .attr('transform', 'translate(30,60)'); 63 | 64 | g.call(slider); 65 | 66 | return div; 67 | }; 68 | 69 | export const Step = () => { 70 | const div = window.document.createElement('div'); 71 | 72 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 73 | 74 | const slider = sliderBottom() 75 | .min(min(data)) 76 | .max(max(data)) 77 | .width(300) 78 | .step(0.005) 79 | .tickFormat(format('.2%')) 80 | .ticks(5) 81 | .default(0.015); 82 | 83 | const g = select(div) 84 | .append('svg') 85 | .attr('width', 500) 86 | .attr('height', 100) 87 | .append('g') 88 | .attr('transform', 'translate(30,30)'); 89 | 90 | g.call(slider); 91 | 92 | return div; 93 | }; 94 | 95 | export const Time = () => { 96 | const div = window.document.createElement('div'); 97 | 98 | const data = range(0, 10).map(function (d) { 99 | return new Date(1995 + d, 10, 3); 100 | }); 101 | 102 | const slider = sliderBottom() 103 | .min(min(data)) 104 | .max(max(data)) 105 | .step(1000 * 60 * 60 * 24 * 365) 106 | .width(300) 107 | .tickFormat(timeFormat('%Y')) 108 | .tickValues(data) 109 | .default(new Date(1998, 10, 3)); 110 | 111 | const g = select(div) 112 | .append('svg') 113 | .attr('width', 500) 114 | .attr('height', 100) 115 | .append('g') 116 | .attr('transform', 'translate(30,30)'); 117 | 118 | g.call(slider); 119 | 120 | return div; 121 | }; 122 | 123 | export const Fill = () => { 124 | const div = window.document.createElement('div'); 125 | 126 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 127 | 128 | const slider = sliderBottom() 129 | .min(min(data)) 130 | .max(max(data)) 131 | .width(300) 132 | .displayValue(false) 133 | .tickFormat(format('.2%')) 134 | .ticks(5) 135 | .default(0.015) 136 | .fill('#2196f3'); 137 | 138 | const g = select(div) 139 | .append('svg') 140 | .attr('width', 500) 141 | .attr('height', 100) 142 | .append('g') 143 | .attr('transform', 'translate(30,30)'); 144 | 145 | g.call(slider); 146 | 147 | return div; 148 | }; 149 | 150 | export const Range = () => { 151 | const div = window.document.createElement('div'); 152 | 153 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 154 | const defaultValue = [0.015, 0.02]; 155 | 156 | const p = select(div) 157 | .append('p') 158 | .attr('id', 'value') 159 | .text(defaultValue.map(format('.2%')).join('-')); 160 | 161 | const slider = sliderBottom() 162 | .min(min(data)) 163 | .max(max(data)) 164 | .width(300) 165 | .tickFormat(format('.2%')) 166 | .ticks(5) 167 | .default(defaultValue) 168 | .fill('skyblue') 169 | .displayValue(true) 170 | .on('onchange', (val) => { 171 | p.text(val.map(format('.2%')).join('-')); 172 | }); 173 | 174 | const g = select(div) 175 | .append('svg') 176 | .attr('width', 500) 177 | .attr('height', 100) 178 | .append('g') 179 | .attr('transform', 'translate(30,30)'); 180 | 181 | g.call(slider); 182 | 183 | select(div) 184 | .append('button') 185 | .text('Reset') 186 | .on('click', () => { 187 | slider.value(defaultValue); 188 | event.preventDefault(); 189 | }); 190 | 191 | return div; 192 | }; 193 | 194 | export const Vertical = () => { 195 | const div = window.document.createElement('div'); 196 | 197 | const data1 = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 198 | 199 | const slider = sliderLeft() 200 | .min(min(data1)) 201 | .max(max(data1)) 202 | .height(300) 203 | .tickFormat(format('.2%')) 204 | .ticks(5) 205 | .default(0.015); 206 | 207 | const g = select(div) 208 | .append('svg') 209 | .attr('width', 100) 210 | .attr('height', 400) 211 | .append('g') 212 | .attr('transform', 'translate(60,30)'); 213 | 214 | g.call(slider); 215 | 216 | return div; 217 | }; 218 | 219 | export const VerticalRange = () => { 220 | const div = window.document.createElement('div'); 221 | 222 | const data1 = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 223 | 224 | const slider = sliderLeft() 225 | .min(min(data1)) 226 | .max(max(data1)) 227 | .height(300) 228 | .tickFormat(format('.2%')) 229 | .ticks(5) 230 | .default([0.008, 0.019]); 231 | 232 | const g = select(div) 233 | .append('svg') 234 | .attr('width', 100) 235 | .attr('height', 400) 236 | .append('g') 237 | .attr('transform', 'translate(60,30)'); 238 | 239 | g.call(slider); 240 | 241 | return div; 242 | }; 243 | 244 | export const VerticalRight = () => { 245 | const div = window.document.createElement('div'); 246 | 247 | const data1 = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 248 | 249 | const slider = sliderRight() 250 | .min(min(data1)) 251 | .max(max(data1)) 252 | .height(300) 253 | .tickFormat(format('.2%')) 254 | .ticks(5) 255 | .default(0.015); 256 | 257 | const g = select(div) 258 | .append('svg') 259 | .attr('width', 100) 260 | .attr('height', 400) 261 | .append('g') 262 | .attr('transform', 'translate(10,30)'); 263 | 264 | g.call(slider); 265 | 266 | return div; 267 | }; 268 | 269 | export const Padding = () => { 270 | const div = window.document.createElement('div'); 271 | 272 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 273 | 274 | const slider = sliderBottom() 275 | .min(min(data)) 276 | .max(max(data)) 277 | .width(300) 278 | .tickFormat(format('.2%')) 279 | .tickPadding(12) 280 | .ticks(5) 281 | .default(0.015); 282 | 283 | const g = select(div) 284 | .append('svg') 285 | .attr('width', 500) 286 | .attr('height', 100) 287 | .append('g') 288 | .attr('transform', 'translate(30,30)'); 289 | 290 | g.call(slider); 291 | 292 | return div; 293 | }; 294 | -------------------------------------------------------------------------------- /stories/demos/color-picker.stories.js: -------------------------------------------------------------------------------- 1 | /*eslint-env browser*/ 2 | 3 | import { select } from 'd3-selection'; 4 | import { sliderBottom } from '../../src'; 5 | 6 | export default { 7 | title: 'Demos/Color picker', 8 | }; 9 | 10 | export const Demo = () => { 11 | const num2hex = (rgb) => { 12 | return rgb 13 | .map((color) => { 14 | let str = color.toString(16); 15 | 16 | if (str.length === 1) { 17 | str = '0' + str; 18 | } 19 | 20 | return str; 21 | }) 22 | .join(''); 23 | }; 24 | 25 | let rgb = [100, 0, 0]; 26 | const colors = ['red', 'green', 'blue']; 27 | 28 | const div = window.document.createElement('div'); 29 | 30 | const g = select(div) 31 | .append('svg') 32 | .attr('width', 600) 33 | .attr('height', 400) 34 | .append('g') 35 | .attr('transform', 'translate(30,30)'); 36 | 37 | const box = g 38 | .append('rect') 39 | .attr('width', 100) 40 | .attr('height', 100) 41 | .attr('transform', 'translate(400,0)') 42 | .attr('fill', `#${num2hex(rgb)}`); 43 | 44 | rgb.forEach((color, i) => { 45 | const slider = sliderBottom() 46 | .min(0) 47 | .max(255) 48 | .step(1) 49 | .width(300) 50 | .default(rgb[i]) 51 | .displayValue(false) 52 | .fill(colors[i]) 53 | .on('onchange', (num) => { 54 | rgb[i] = num; 55 | box.attr('fill', `#${num2hex(rgb)}`); 56 | }); 57 | 58 | g.append('g') 59 | .attr('transform', `translate(30,${60 * i})`) 60 | .call(slider); 61 | }); 62 | 63 | return div; 64 | }; 65 | -------------------------------------------------------------------------------- /stories/demos/music-player-controls.css: -------------------------------------------------------------------------------- 1 | .player-controls svg .slider .track { 2 | stroke: #282828; 3 | } 4 | 5 | .player-controls svg .slider .track-inset { 6 | stroke: #535353; 7 | } 8 | 9 | @font-face { 10 | font-family: 'glue1-spoticon'; 11 | src: url('https://open.spotifycdn.com/cdn/fonts/spoticon_regular_2.d728648c.woff2') 12 | format('woff'); 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | .player-controls { 18 | min-width: 620px; 19 | background-color: #282828; 20 | border-top: 1px solid #000; 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | align-items: center; 25 | height: auto; 26 | user-select: none; 27 | color: #b3b3b3; 28 | padding: 16px; 29 | font-family: spotify-circular, spotify-circular-cyrillic, 30 | spotify-circular-arabic, spotify-circular-hebrew, Helvetica Neue, helvetica, 31 | arial, Hiragino Kaku Gothic Pro, Meiryo, MS Gothic, sans-serif; 32 | } 33 | 34 | .player-controls__buttons { 35 | margin-bottom: 12px; 36 | width: 224px; 37 | justify-content: space-between; 38 | flex-flow: row nowrap; 39 | display: flex; 40 | } 41 | 42 | .playback-bar { 43 | width: 100%; 44 | display: flex; 45 | flex-direction: row; 46 | justify-content: space-between; 47 | align-items: center; 48 | } 49 | 50 | .playback-bar__progress-time { 51 | min-width: 40px; 52 | text-align: center; 53 | font-size: 11px; 54 | font-weight: 400; 55 | line-height: 16px; 56 | letter-spacing: normal; 57 | text-transform: none; 58 | } 59 | 60 | .control-button { 61 | font-family: 'glue1-spoticon'; 62 | position: relative; 63 | background-color: transparent; 64 | border: none; 65 | color: #b3b3b3; 66 | width: 32px; 67 | min-width: 32px; 68 | height: 32px; 69 | } 70 | 71 | .skip-back:before { 72 | content: '\f146'; 73 | font-size: 16px; 74 | } 75 | 76 | .skip-forward:before { 77 | content: '\f148'; 78 | font-size: 16px; 79 | } 80 | 81 | .pause:before { 82 | content: '\f130'; 83 | font-size: 16px; 84 | } 85 | 86 | .pause:after { 87 | content: ''; 88 | position: absolute; 89 | top: 0; 90 | right: 0; 91 | bottom: 0; 92 | left: 0; 93 | border-radius: 500px; 94 | border: 1px solid hsla(0, 0%, 100%, 0.6); 95 | } 96 | 97 | .play:before { 98 | content: '\f132'; 99 | font-size: 16px; 100 | } 101 | 102 | .play:after { 103 | content: ''; 104 | position: absolute; 105 | top: 0; 106 | right: 0; 107 | bottom: 0; 108 | left: 0; 109 | border-radius: 500px; 110 | border: 1px solid hsla(0, 0%, 100%, 0.6); 111 | } 112 | -------------------------------------------------------------------------------- /stories/demos/music-player-controls.stories.js: -------------------------------------------------------------------------------- 1 | import './music-player-controls.css'; 2 | 3 | import { extent, range } from 'd3-array'; 4 | import { symbol, symbolCircle } from 'd3-shape'; 5 | 6 | /*eslint-env browser*/ 7 | import { format } from 'd3-format'; 8 | import { scaleLinear } from 'd3-scale'; 9 | import { select } from 'd3-selection'; 10 | import { sliderBottom } from '../../src'; 11 | 12 | export default { 13 | title: 'Demos/Music Player Controls', 14 | }; 15 | 16 | const timeFormat = (seconds) => { 17 | return `${Math.floor(seconds / 60)}:${format('02')(seconds % 60)}`; 18 | }; 19 | 20 | export const Demo = () => { 21 | const playerControlsDiv = window.document.createElement('div'); 22 | playerControlsDiv.classList.add('player-controls'); 23 | 24 | const playerControlsButtonsDiv = window.document.createElement('div'); 25 | playerControlsButtonsDiv.classList.add('player-controls__buttons'); 26 | playerControlsDiv.append(playerControlsButtonsDiv); 27 | 28 | const playbackBarDiv = window.document.createElement('div'); 29 | playbackBarDiv.classList.add('playback-bar'); 30 | playerControlsDiv.append(playbackBarDiv); 31 | 32 | let isPlaying = false; 33 | let position = 0; 34 | let intervalID; 35 | 36 | select(playerControlsButtonsDiv) 37 | .append('button') 38 | .attr('class', 'control-button skip-back') 39 | .on('click', () => { 40 | position = 0; 41 | draw(); 42 | }); 43 | 44 | const playButton = select(playerControlsButtonsDiv) 45 | .append('button') 46 | .attr('class', 'control-button pause') 47 | .on('click', () => { 48 | if (isPlaying) { 49 | clearInterval(intervalID); 50 | isPlaying = false; 51 | draw(); 52 | } else { 53 | intervalID = window.setInterval(() => { 54 | position++; 55 | draw(); 56 | }, 1000); 57 | 58 | isPlaying = true; 59 | draw(); 60 | } 61 | }); 62 | 63 | select(playerControlsButtonsDiv) 64 | .append('button') 65 | .attr('class', 'control-button skip-forward') 66 | .on('click', () => { 67 | position = data[data.length - 1]; 68 | draw(); 69 | }); 70 | 71 | const width = 565; 72 | const height = 12; 73 | const margin = { top: 0, right: 8, bottom: 0, left: 8 }; 74 | 75 | const data = range(0, 186); 76 | 77 | const timestamp = select(playbackBarDiv) 78 | .append('text') 79 | .attr('class', 'playback-bar__progress-time'); 80 | 81 | const svg = select(playbackBarDiv) 82 | .append('svg') 83 | .attr('width', width) 84 | .attr('height', height); 85 | 86 | select(playbackBarDiv) 87 | .append('text') 88 | .attr('class', 'playback-bar__progress-time') 89 | .text(timeFormat(data[data.length - 1])); 90 | 91 | const xLinear = scaleLinear() 92 | .domain(extent(data)) 93 | .range([margin.left, width - margin.right]); 94 | 95 | const slider = sliderBottom(xLinear) 96 | .step(1) 97 | .ticks(0) 98 | .displayValue(false) 99 | .fill('#1db954') 100 | .default(position) 101 | .handle(symbol().type(symbolCircle).size(100)()) 102 | .on('onchange', (value) => { 103 | position = value; 104 | draw(); 105 | }); 106 | 107 | const g = svg.append('g').attr('transform', `translate(0,${height / 2})`); 108 | 109 | g.call(slider); 110 | 111 | const draw = () => { 112 | g.call(slider.value(position)); 113 | 114 | playButton.classed('pause', isPlaying).classed('play', !isPlaying); 115 | timestamp.text(timeFormat(position)); 116 | }; 117 | 118 | draw(position); 119 | 120 | return playerControlsDiv; 121 | }; 122 | -------------------------------------------------------------------------------- /stories/demos/new-york-times.stories.js: -------------------------------------------------------------------------------- 1 | import { max, min, range } from 'd3-array'; 2 | import { scaleBand, scaleLinear } from 'd3-scale'; 3 | 4 | import { axisRight } from 'd3-axis'; 5 | /*eslint-env browser*/ 6 | 7 | import { format } from 'd3-format'; 8 | import { select } from 'd3-selection'; 9 | import { sliderBottom } from '../../src'; 10 | 11 | export default { 12 | title: 'Demos/New York Times', 13 | }; 14 | 15 | export const Demo = () => { 16 | const div = window.document.createElement('div'); 17 | 18 | const width = 565; 19 | const height = 120; 20 | const margin = { top: 20, right: 50, bottom: 50, left: 40 }; 21 | 22 | const data = range(1, 41).map((d) => ({ 23 | year: d, 24 | value: 10000 * Math.exp(-(d - 1) / 40), 25 | })); 26 | 27 | const svg = select(div) 28 | .append('svg') 29 | .attr('width', width) 30 | .attr('height', height); 31 | 32 | const padding = 0.1; 33 | 34 | const xBand = scaleBand() 35 | .domain(data.map((d) => d.year)) 36 | .range([margin.left, width - margin.right]) 37 | .padding(padding); 38 | 39 | const xLinear = scaleLinear() 40 | .domain([min(data, (d) => d.year), max(data, (d) => d.year)]) 41 | .range([ 42 | margin.left + xBand.bandwidth() / 2 + xBand.step() * padding - 0.5, 43 | width - 44 | margin.right - 45 | xBand.bandwidth() / 2 - 46 | xBand.step() * padding - 47 | 0.5, 48 | ]); 49 | 50 | const y = scaleLinear() 51 | .domain([0, max(data, (d) => d.value)]) 52 | .nice() 53 | .range([height - margin.bottom, margin.top]); 54 | 55 | const yAxis = (g) => 56 | g 57 | .attr('transform', `translate(${width - margin.right},0)`) 58 | .call(axisRight(y).tickValues([1e4]).tickFormat(format('($.2s'))) 59 | .call((g) => g.select('.domain').remove()); 60 | 61 | const slider = (g) => 62 | g.attr('transform', `translate(0,${height - margin.bottom})`).call( 63 | sliderBottom(xLinear) 64 | .step(1) 65 | .ticks(4) 66 | .default(9) 67 | .on('onchange', (value) => draw(value)) 68 | ); 69 | 70 | const bars = svg.append('g').selectAll('rect').data(data); 71 | 72 | const barsEnter = bars 73 | .enter() 74 | .append('rect') 75 | .attr('x', (d) => xBand(d.year)) 76 | .attr('y', (d) => y(d.value)) 77 | .attr('height', (d) => y(0) - y(d.value)) 78 | .attr('width', xBand.bandwidth()); 79 | 80 | svg.append('g').call(yAxis); 81 | svg.append('g').call(slider); 82 | 83 | svg.select('.track-overlay').attr('stroke-width', 120); // Ensure drag zone covers everything 84 | 85 | const draw = (selected) => { 86 | barsEnter 87 | .merge(bars) 88 | .attr('fill', (d) => (d.year === selected ? '#bad80a' : '#e0e0e0')); 89 | }; 90 | 91 | draw(9); 92 | 93 | return div; 94 | }; 95 | -------------------------------------------------------------------------------- /stories/extended-functionality.stories.js: -------------------------------------------------------------------------------- 1 | import { max, min } from 'd3-array'; 2 | import { symbol, symbolCircle } from 'd3-shape'; 3 | 4 | /*eslint-env browser*/ 5 | 6 | import { format } from 'd3-format'; 7 | import { select } from 'd3-selection'; 8 | import { sliderBottom } from '../src/slider'; 9 | import { timeFormat } from 'd3-time-format'; 10 | 11 | export default { 12 | title: 'Functionality/Extended functionality', 13 | }; 14 | 15 | export const AlternativeHandle = () => { 16 | const div = window.document.createElement('div'); 17 | 18 | const data1 = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 19 | 20 | const slider = sliderBottom() 21 | .min(min(data1)) 22 | .max(max(data1)) 23 | .width(300) 24 | .tickFormat(format('.2%')) 25 | .ticks(5) 26 | .default(0.015) 27 | .handle(symbol().type(symbolCircle).size(200)()); 28 | 29 | const g = select(div) 30 | .append('svg') 31 | .attr('width', 500) 32 | .attr('height', 100) 33 | .append('g') 34 | .attr('transform', 'translate(30,30)'); 35 | 36 | g.call(slider); 37 | 38 | return div; 39 | }; 40 | 41 | export const Transition = () => { 42 | const div = window.document.createElement('div'); 43 | 44 | const data = [0, 0.005, 0.01, 0.015, 0.02, 0.025]; 45 | 46 | const slider = sliderBottom() 47 | .min(min(data)) 48 | .max(max(data)) 49 | .width(300) 50 | .tickFormat(format('.2%')) 51 | .ticks(5) 52 | .default(0.015) 53 | .fill('#2196f3'); 54 | 55 | const g = select(div) 56 | .append('svg') 57 | .attr('width', 500) 58 | .attr('height', 100) 59 | .append('g') 60 | .attr('transform', 'translate(30,30)'); 61 | 62 | g.call(slider); 63 | 64 | setInterval(() => { 65 | slider.width(Math.random() * 100 + 200); 66 | g.transition().duration(200).call(slider); 67 | }, 1000); 68 | 69 | return div; 70 | }; 71 | 72 | export const DynamicMaxAndMin = () => { 73 | const div = window.document.createElement('div'); 74 | 75 | const slider = sliderBottom().min(2).max(15).width(300).step(1).default(5); 76 | 77 | const g = select(div) 78 | .append('svg') 79 | .attr('width', 500) 80 | .attr('height', 100) 81 | .append('g') 82 | .attr('transform', 'translate(30,30)'); 83 | 84 | g.call(slider); 85 | 86 | setInterval(() => { 87 | slider.max(Math.floor(Math.random() * 5) + 10); 88 | slider.min(Math.floor(Math.random() * 5)); 89 | 90 | g.transition().duration(200).call(slider); 91 | }, 3000); 92 | 93 | return div; 94 | }; 95 | 96 | export const Marks = () => { 97 | const div = window.document.createElement('div'); 98 | 99 | const data = [ 100 | new Date(1995, 1, 3), 101 | new Date(1995, 3, 3), 102 | new Date(1995, 4, 3), 103 | new Date(1995, 5, 3), 104 | new Date(1995, 9, 3), 105 | ]; 106 | 107 | const slider = sliderBottom() 108 | .min(min(data)) 109 | .max(max(data)) 110 | .width(500) 111 | .tickFormat(timeFormat('%b %Y')) 112 | .tickValues(data) 113 | .marks(data) 114 | .fill('#2196f3') 115 | .default([data[1], data[2]]); 116 | 117 | const g = select(div) 118 | .append('svg') 119 | .attr('width', 600) 120 | .attr('height', 100) 121 | .append('g') 122 | .attr('transform', 'translate(30,30)'); 123 | 124 | g.call(slider); 125 | 126 | return div; 127 | }; 128 | -------------------------------------------------------------------------------- /stories/styling.stories.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs/blocks'; 2 | 3 | 4 | 5 | # Styling 6 | 7 | To change the font size: 8 | 9 | ```css 10 | .axis text, 11 | .slider text { 12 | font-size: 18px; 13 | } 14 | ``` 15 | 16 | To change the tick text color: 17 | 18 | ```css 19 | .axis text { 20 | fill: red; 21 | } 22 | ``` 23 | 24 | To change the parameter value text color: 25 | 26 | ```css 27 | .slider text { 28 | fill: green; 29 | } 30 | ``` 31 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "ecmaVersion": 11 6 | }, 7 | "env": { 8 | "mocha": true, 9 | "node": true, 10 | "es6": true, 11 | "browser": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/slider-horizontal.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 0 8 | 9 | 10 | 11 | 1 12 | 13 | 14 | 15 | 2 16 | 17 | 18 | 19 | 3 20 | 21 | 22 | 23 | 4 24 | 25 | 26 | 27 | 5 28 | 29 | 30 | 31 | 6 32 | 33 | 34 | 35 | 7 36 | 37 | 38 | 39 | 8 40 | 41 | 42 | 43 | 9 44 | 45 | 46 | 47 | 10 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 0 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /test/slider-test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import jsdom from 'jsdom'; 3 | import { readFileSync } from 'fs'; 4 | import { resolve, dirname, join } from 'path'; 5 | import { fileURLToPath } from 'url'; 6 | 7 | import { select } from 'd3-selection'; 8 | 9 | import * as d3 from '../src/index.js'; 10 | 11 | it('sliderHorizontal() has the expected defaults', () => { 12 | const s = d3.sliderHorizontal(); 13 | assert.equal(s.tickFormat(), null); 14 | }); 15 | 16 | it('slider.min(value), slider.max(value) set the expected values', () => { 17 | const s = d3.sliderHorizontal().min(100).max(200); 18 | assert.equal(s.min(), 100); 19 | assert.equal(s.max(), 200); 20 | assert.deepEqual(s.domain(), [100, 200]); 21 | }); 22 | 23 | it('slider.domain([min,max]) sets the expected values', () => { 24 | const s = d3.sliderHorizontal().domain([100, 200]); 25 | assert.deepEqual(s.domain(), [100, 200]); 26 | assert.equal(s.min(), 100); 27 | assert.equal(s.max(), 200); 28 | }); 29 | 30 | it('slider.default(value) sets the default value', () => { 31 | const s = d3.sliderHorizontal().default(10); 32 | assert.equal(s.value(), 10); 33 | assert.equal(s.default(), 10); 34 | }); 35 | 36 | it('slider.default(value) sets the default range', () => { 37 | const s = d3.sliderHorizontal().default([4, 8]); 38 | assert.deepEqual(s.value(), [4, 8]); 39 | assert.deepEqual(s.default(), [4, 8]); 40 | }); 41 | 42 | it('sliderHorizontal(selection) produces the expected result', () => { 43 | const window = new jsdom.JSDOM('').window; 44 | global.window = window; 45 | global.document = window.document; 46 | global.navigator = { 47 | userAgent: 'node.js', 48 | }; 49 | copyProps(window, global); 50 | const bodyActual = window.document.body; 51 | const bodyExpected = new jsdom.JSDOM(file('slider-horizontal.html')).window 52 | .document.body; 53 | 54 | select(bodyActual).select('g').call(d3.sliderHorizontal()); 55 | 56 | assert.equal(bodyActual.outerHTML, bodyExpected.outerHTML); 57 | }); 58 | 59 | it('sliderVertical(selection) produces the expected result', () => { 60 | const window = new jsdom.JSDOM('').window; 61 | global.window = window; 62 | global.document = window.document; 63 | global.navigator = { 64 | userAgent: 'node.js', 65 | }; 66 | copyProps(window, global); 67 | const bodyActual = window.document.body; 68 | const bodyExpected = new jsdom.JSDOM(file('slider-vertical.html')).window 69 | .document.body; 70 | 71 | select(bodyActual).select('g').call(d3.sliderVertical()); 72 | 73 | assert.equal(bodyActual.outerHTML, bodyExpected.outerHTML); 74 | }); 75 | 76 | function file(file) { 77 | const __dirname = resolve( 78 | dirname(fileURLToPath(new URL('./test', import.meta.url))) 79 | ); 80 | 81 | return readFileSync(join(__dirname, file), 'utf8').replace(/\n\s*/gm, ''); 82 | } 83 | 84 | function copyProps(src, target) { 85 | Object.defineProperties(target, { 86 | ...Object.getOwnPropertyDescriptors(src), 87 | ...Object.getOwnPropertyDescriptors(target), 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /test/slider-vertical.html: -------------------------------------------------------------------------------- 1 | 0123456789100 --------------------------------------------------------------------------------